diff --git a/cmd/root.go b/cmd/root.go index 0bb93cce..5c0739a7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ import ( resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" signupcmd "github.com/launchdarkly/ldcli/cmd/signup" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" + symbolscmd "github.com/launchdarkly/ldcli/cmd/symbols" whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" @@ -259,6 +260,7 @@ func NewRootCommand( cmd.AddCommand(resourcecmd.NewResourcesCmd()) cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) + cmd.AddCommand(symbolscmd.NewSymbolsCmd(clients.ResourcesClient, analyticsTrackerFn)) cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) diff --git a/cmd/sourcemaps/upload.go b/cmd/sourcemaps/upload.go index 51b04f7e..6dac8c31 100644 --- a/cmd/sourcemaps/upload.go +++ b/cmd/sourcemaps/upload.go @@ -168,21 +168,6 @@ func runE(client resources.Client) func(cmd *cobra.Command, args []string) error } } -var sourceMapUploadSuffixes = []string{ - ".js.map", ".js", - ".jsbundle.map", ".jsbundle", - ".bundle.map", ".bundle", -} - -func isSourceMapUploadFile(name string) bool { - for _, suffix := range sourceMapUploadSuffixes { - if strings.HasSuffix(name, suffix) { - return true - } - } - return false -} - func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { var files []SourceMapFile routeGroupPattern := regexp.MustCompile(`\(.+?\)/`) @@ -200,6 +185,7 @@ func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { return files, nil } + skippedReactNative := false err = filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { if err != nil { return err @@ -209,7 +195,7 @@ func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { return filepath.SkipDir } - if !d.IsDir() && isSourceMapUploadFile(filePath) { + if !d.IsDir() && (strings.HasSuffix(filePath, ".js.map") || strings.HasSuffix(filePath, ".js")) { relPath, err := filepath.Rel(path, filePath) if err != nil { return err @@ -227,6 +213,8 @@ func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { Name: routeGroupRemovedPath, }) } + } else if !d.IsDir() && isReactNativeArtifact(filePath) { + skippedReactNative = true } return nil @@ -236,13 +224,33 @@ func getAllSourceMapFiles(path string) ([]SourceMapFile, error) { return nil, err } + // Transitional notice: `sourcemaps upload` used to also collect React Native + // bundles, but they now go through the dedicated `symbols upload` flow. Warn + // (instead of silently skipping) so anyone relying on the old behavior knows + // to switch, rather than shipping a build with no usable RN symbols. + if skippedReactNative { + fmt.Fprintln(os.Stderr, "warning: skipped React Native bundle(s) (*.jsbundle / *.bundle). `sourcemaps upload` only handles web sourcemaps (*.js / *.js.map); upload React Native symbols with `ldcli symbols upload --type react-native` instead.") + } + if len(files) == 0 { - return nil, fmt.Errorf("no sourcemap files found (looked for *.js.map, *.jsbundle.map, *.bundle.map and their minified files). Please double check that you have generated sourcemaps for your app") + return nil, fmt.Errorf("no .js.map files found. Please double check that you have generated sourcemaps for your app") } return files, nil } +// isReactNativeArtifact reports whether name looks like a React Native bundle or +// its sourcemap. Used only to warn that these are skipped by `sourcemaps upload` +// (they belong to `symbols upload`), never to select files for upload here. +func isReactNativeArtifact(name string) bool { + for _, suffix := range []string{".jsbundle.map", ".jsbundle", ".bundle.map", ".bundle"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + func getS3Key(version, basePath, fileName string) string { if version == "" { version = "unversioned" diff --git a/cmd/sourcemaps/upload_test.go b/cmd/sourcemaps/upload_test.go index eba068fb..3f78d0cd 100644 --- a/cmd/sourcemaps/upload_test.go +++ b/cmd/sourcemaps/upload_test.go @@ -198,56 +198,7 @@ func TestGetAllSourceMapFiles(t *testing.T) { defer os.RemoveAll(emptyDir) _, err = getAllSourceMapFiles(emptyDir) assert.Error(t, err) - assert.Contains(t, err.Error(), "no sourcemap files found") -} - -func TestIsSourceMapUploadFile(t *testing.T) { - // Web bundles + maps. - assert.True(t, isSourceMapUploadFile("app.js")) - assert.True(t, isSourceMapUploadFile("app.js.map")) - // React Native iOS bundle + map. - assert.True(t, isSourceMapUploadFile("main.jsbundle")) - assert.True(t, isSourceMapUploadFile("main.jsbundle.map")) - // React Native Android bundle + map. - assert.True(t, isSourceMapUploadFile("index.android.bundle")) - assert.True(t, isSourceMapUploadFile("index.android.bundle.map")) - // Unrelated files are ignored. - assert.False(t, isSourceMapUploadFile("styles.css")) - assert.False(t, isSourceMapUploadFile("styles.css.map")) - assert.False(t, isSourceMapUploadFile("README.md")) -} - -func TestGetAllSourceMapFilesReactNative(t *testing.T) { - tempDir, err := os.MkdirTemp("", "sourcemap-rn-test") - assert.NoError(t, err) - defer os.RemoveAll(tempDir) - - // The names React Native's `react-native bundle` produces. - rnFiles := []string{ - "main.jsbundle", - "main.jsbundle.map", - "index.android.bundle", - "index.android.bundle.map", - } - for _, name := range rnFiles { - err = os.WriteFile(filepath.Join(tempDir, name), []byte("{}"), 0644) - assert.NoError(t, err) - } - // A non-sourcemap file that must be skipped. - err = os.WriteFile(filepath.Join(tempDir, "assets.png"), []byte("x"), 0644) - assert.NoError(t, err) - - files, err := getAllSourceMapFiles(tempDir) - assert.NoError(t, err) - - found := make(map[string]bool) - for _, f := range files { - found[f.Name] = true - } - for _, name := range rnFiles { - assert.True(t, found[name], "expected %s to be discovered for upload", name) - } - assert.False(t, found["assets.png"], "non-sourcemap files must be skipped") + assert.Contains(t, err.Error(), "no .js.map files found") } func TestGetSourceMapUploadUrlsErrors(t *testing.T) { diff --git a/cmd/symbols/symbols.go b/cmd/symbols/symbols.go new file mode 100644 index 00000000..6e353803 --- /dev/null +++ b/cmd/symbols/symbols.go @@ -0,0 +1,23 @@ +package symbols + +import ( + "github.com/spf13/cobra" + + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func NewSymbolsCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Use: "symbols", + Short: "Manage symbol files", + Long: "Manage symbol files (for example, React Native sourcemaps) for LaunchDarkly error monitoring", + Args: cobra.MinimumNArgs(1), + } + + cmd.AddCommand(NewUploadCmd(client, analyticsTrackerFn)) + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + return cmd +} diff --git a/cmd/symbols/upload.go b/cmd/symbols/upload.go new file mode 100644 index 00000000..a656a55a --- /dev/null +++ b/cmd/symbols/upload.go @@ -0,0 +1,488 @@ +package symbols + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/cmd/validators" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" +) + +const ( + typeFlag = "type" + appVersionFlag = "app-version" + symbolsIdFlag = "symbols-id" + pathFlag = "path" + basePathFlag = "base-path" + backendUrlFlag = "backend-url" + + defaultPath = "." + defaultBackendUrl = "https://pri.observability.app.launchdarkly.com" + + // reactNativeSymbolsIDPrefix is the storage "version" segment for symbols-id + // addressed JS maps (Symbols Id Lane). Keys become _sym/js/id//, + // matching what the symbolication backend derives from the reported symbols id. + reactNativeSymbolsIDPrefix = "_sym/js/id" + + // androidSymbolsIDPrefix is the equivalent Symbols Id Lane segment for Android + // R8 / ProGuard mappings. Keys become _sym/android/id//mapping.txt. + androidSymbolsIDPrefix = "_sym/android/id" + + // symbolsIDSidecarSuffix names the file written next to an artifact to record + // its symbols id (the Metro plugin for React Native, the Gradle task for + // Android), so `ldcli` can upload with the exact id the app reports without a + // manual --symbols-id. + symbolsIDSidecarSuffix = ".symbolsid" + + // androidMappingFileName is the R8/ProGuard mapping file `ldcli` discovers + // for --type android. + androidMappingFileName = "mapping.txt" + + // typeReactNative uploads React Native Hermes/Metro sourcemaps (ordinary + // JavaScript sourcemaps). + typeReactNative = "react-native" + + // typeAndroid uploads an Android R8/ProGuard `mapping.txt` for Java/Kotlin + // stack-trace retrace. + typeAndroid = "android" + + // getSymbolUrlsQuery uses the dedicated `get_symbol_upload_urls_ld` query + // (separate from `sourcemaps upload`) so symbol uploads travel over the + // symbol endpoint, which accepts larger, multi-segment uploads. + getSymbolUrlsQuery = ` + query GetSymbolUploadUrls($api_key: String!, $project_id: String!, $paths: [String!]!) { + get_symbol_upload_urls_ld( + api_key: $api_key + project_id: $project_id + paths: $paths + ) + } + ` +) + +// reactNativeUploadSuffixes are the files produced by `react-native bundle`: +// `main.jsbundle`(.map) on iOS and `index.android.bundle`(.map) on Android. +// The minified bundle is uploaded alongside its map so `sourceMappingURL` and +// column offsets resolve during symbolication. +var reactNativeUploadSuffixes = []string{ + ".jsbundle.map", ".jsbundle", + ".bundle.map", ".bundle", +} + +type SymbolUrlsResponse struct { + Data struct { + GetSymbolUploadUrls []string `json:"get_symbol_upload_urls_ld"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type SymbolFile struct { + Path string + Name string +} + +func NewUploadCmd(client resources.Client, analyticsTrackerFn analytics.TrackerFn) *cobra.Command { + cmd := &cobra.Command{ + Args: validators.Validate(), + Use: "upload", + Short: "Upload symbol files", + Long: "Upload symbol files (React Native sourcemaps or Android R8/ProGuard mappings) to LaunchDarkly for error monitoring", + RunE: runE(client), + PersistentPreRun: func(cmd *cobra.Command, args []string) { + tracker := analyticsTrackerFn( + viper.GetString(cliflags.AccessTokenFlag), + viper.GetString(cliflags.BaseURIFlag), + viper.GetBool(cliflags.AnalyticsOptOut), + ) + tracker.SendCommandRunEvent(cmdAnalytics.CmdRunEventProperties( + cmd, + "symbols", + map[string]interface{}{ + "action": cmd.Name(), + })) + }, + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + initFlags(cmd) + + return cmd +} + +func runE(client resources.Client) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + symbolType := viper.GetString(typeFlag) + if !isSupportedType(symbolType) { + return fmt.Errorf("unsupported --type %q; supported types: %s, %s", symbolType, typeReactNative, typeAndroid) + } + + projectKey := viper.GetString(cliflags.ProjectFlag) + u, _ := url.JoinPath( + viper.GetString(cliflags.BaseURIFlag), + "api/v2/projects", + projectKey, + ) + res, err := client.MakeRequest( + viper.GetString(cliflags.AccessTokenFlag), + "GET", + u, + "application/json", + nil, + nil, + false, + ) + if err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + + var projectResult struct { + ID string `json:"_id"` + } + if err = json.Unmarshal(res, &projectResult); err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + if projectResult.ID == "" { + return fmt.Errorf("project %s not found", projectKey) + } + + appVersion := viper.GetString(appVersionFlag) + symbolsID := viper.GetString(symbolsIdFlag) + path := viper.GetString(pathFlag) + basePath := viper.GetString(basePathFlag) + backendUrl := viper.GetString(backendUrlFlag) + + if backendUrl == "" { + backendUrl = defaultBackendUrl + } + + symbolsIDPrefix := symbolsIDPrefixForType(symbolType) + + fmt.Printf("Starting to upload %s symbols from %s\n", symbolType, path) + if symbolsID != "" { + fmt.Printf("Using symbols id %s for all files (Symbols Id Lane: %s/%s)\n", symbolsID, symbolsIDPrefix, symbolsID) + } + + files, err := getAllSymbolFiles(path, symbolType) + if err != nil { + return fmt.Errorf("failed to find symbol files: %w", err) + } + + if len(files) == 0 { + return fmt.Errorf("no symbol files found in %s, is this the correct path?", path) + } + + // Symbols Id Lane: resolve the id per file so a single upload of multiple + // platforms (e.g. iOS + Android maps in one dir) keys each artifact by the + // id its app reports. An explicit --symbols-id overrides all files; + // otherwise each artifact's *.symbolsid sidecar (or its sibling's — see + // symbolsIDForArtifact) is used, falling back to the Version Lane + // (version+basePath) when there is none. + s3Keys := make([]string, 0, len(files)) + for _, file := range files { + fileSymbolsID := symbolsID + if fileSymbolsID == "" { + fileSymbolsID = symbolsIDForArtifact(file.Path) + if fileSymbolsID != "" { + fmt.Printf("Using symbols id %s for %s (Symbols Id Lane: %s/%s)\n", fileSymbolsID, file.Name, symbolsIDPrefix, fileSymbolsID) + } + } + s3Keys = append(s3Keys, getS3Key(symbolsIDPrefix, fileSymbolsID, appVersion, basePath, file.Name)) + } + + uploadUrls, err := getSymbolUploadUrls(viper.GetString(cliflags.AccessTokenFlag), projectResult.ID, s3Keys, backendUrl) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + + // The loop below pairs each file with uploadUrls[i], so a short list + // (fewer URLs than files) would panic. Require one URL per requested key. + if len(uploadUrls) != len(files) { + return fmt.Errorf("expected %d upload URLs but received %d", len(files), len(uploadUrls)) + } + + for i, file := range files { + if err := uploadFile(file.Path, uploadUrls[i], file.Name); err != nil { + return fmt.Errorf("failed to upload file %s: %w", file.Path, err) + } + } + + fmt.Println("Successfully uploaded all symbols") + return nil + } +} + +func isSupportedType(symbolType string) bool { + return symbolType == typeReactNative || symbolType == typeAndroid +} + +// symbolsIDPrefixForType picks the Symbols Id Lane storage segment for the symbol +// type so JS and Android maps never collide in the same symbols-id namespace. +func symbolsIDPrefixForType(symbolType string) string { + if symbolType == typeAndroid { + return androidSymbolsIDPrefix + } + return reactNativeSymbolsIDPrefix +} + +func isReactNativeUploadFile(name string) bool { + for _, suffix := range reactNativeUploadSuffixes { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + +// isSymbolUploadFile reports whether a discovered file should be uploaded for +// the given symbol type: React Native bundles/maps, or an Android mapping.txt. +func isSymbolUploadFile(symbolType, name string) bool { + if symbolType == typeAndroid { + return filepath.Base(name) == androidMappingFileName + } + return isReactNativeUploadFile(name) +} + +func getAllSymbolFiles(path, symbolType string) ([]SymbolFile, error) { + var files []SymbolFile + + fileInfo, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + // Validate the explicit file against --type too, so a single --path can't + // upload an unrelated file under this type's symbol keys. + if !isSymbolUploadFile(symbolType, path) { + return nil, unexpectedSymbolFileError(path, symbolType) + } + files = append(files, SymbolFile{ + Path: path, + Name: filepath.Base(path), + }) + return files, nil + } + + err = filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() && d.Name() == "node_modules" { + return filepath.SkipDir + } + + if !d.IsDir() && isSymbolUploadFile(symbolType, filePath) { + relPath, err := filepath.Rel(path, filePath) + if err != nil { + return err + } + + files = append(files, SymbolFile{ + Path: filePath, + Name: relPath, + }) + } + + return nil + }) + + if err != nil { + return nil, err + } + + if len(files) == 0 { + if symbolType == typeAndroid { + return nil, fmt.Errorf("no Android symbol files found (looked for %s). Please double check that R8/ProGuard produced a mapping file", androidMappingFileName) + } + return nil, fmt.Errorf("no React Native symbol files found (looked for *.jsbundle, *.jsbundle.map, *.bundle, *.bundle.map). Please double check that you have generated sourcemaps for your app") + } + + return files, nil +} + +// unexpectedSymbolFileError reports that an explicit --path file doesn't match +// the artifacts expected for the given --type. +func unexpectedSymbolFileError(path, symbolType string) error { + if symbolType == typeAndroid { + return fmt.Errorf("file %s is not an Android symbol file (expected %s)", path, androidMappingFileName) + } + return fmt.Errorf("file %s is not a React Native symbol file (expected *.jsbundle, *.jsbundle.map, *.bundle, *.bundle.map)", path) +} + +func getS3Key(symbolsIDPrefix, symbolsID, version, basePath, fileName string) string { + // Symbols Id Lane: a symbols id fully addresses the artifact, so it supersedes + // the version+basePath scheme. The key becomes // + // so it matches the key the backend derives from the reported symbols id. + if symbolsID != "" { + return fmt.Sprintf("%s/%s/%s", symbolsIDPrefix, symbolsID, filepath.Base(fileName)) + } + + if version == "" { + version = "unversioned" + } + + if basePath != "" && !strings.HasSuffix(basePath, "/") { + basePath = basePath + "/" + } + + return fmt.Sprintf("%s/%s%s", version, basePath, fileName) +} + +// symbolsIDForArtifact resolves the symbols id for one uploaded artifact from a +// *.symbolsid sidecar. A React Native build's bundle and its .map share a single +// id, but the Metro plugin writes only one sidecar (named after the source map +// it's handed). So for a .map we also check the bundle's sidecar and for a +// bundle we also check the .map's sidecar — keeping both files on the same lane +// instead of splitting one to the Version Lane. Returns "" when none is found. +func symbolsIDForArtifact(filePath string) string { + candidates := []string{filePath + symbolsIDSidecarSuffix} + if strings.HasSuffix(filePath, ".map") { + candidates = append(candidates, strings.TrimSuffix(filePath, ".map")+symbolsIDSidecarSuffix) + } else { + candidates = append(candidates, filePath+".map"+symbolsIDSidecarSuffix) + } + for _, candidate := range candidates { + if id := readSymbolsIDFile(candidate); id != "" { + return id + } + } + return "" +} + +// readSymbolsIDFile returns the symbols id recorded in a *.symbolsid sidecar +// (the Metro plugin writes it next to the composed source map; the Android +// Gradle task writes mapping.txt.symbolsid). Best-effort: any error, or no +// sidecar, yields "" so the caller falls back to the Version Lane addressing. +func readSymbolsIDFile(filePath string) string { + content, err := os.ReadFile(filePath) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + +func getSymbolUploadUrls(apiKey, projectID string, paths []string, backendUrl string) ([]string, error) { + variables := map[string]interface{}{ + "api_key": apiKey, + "project_id": projectID, + "paths": paths, + } + + reqBody, err := json.Marshal(map[string]interface{}{ + "query": getSymbolUrlsQuery, + "variables": variables, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", backendUrl, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var urlsResp SymbolUrlsResponse + if err := json.Unmarshal(body, &urlsResp); err != nil { + return nil, err + } + + if len(urlsResp.Errors) > 0 { + messages := make([]string, 0, len(urlsResp.Errors)) + for _, e := range urlsResp.Errors { + messages = append(messages, e.Message) + } + return nil, fmt.Errorf("unable to generate symbol upload urls: %s", strings.Join(messages, "; ")) + } + + if len(urlsResp.Data.GetSymbolUploadUrls) == 0 { + return nil, fmt.Errorf("unable to generate symbol upload urls: server returned no urls for %d path(s)", len(paths)) + } + + return urlsResp.Data.GetSymbolUploadUrls, nil +} + +func uploadFile(filePath, uploadUrl, name string) error { + fileContent, err := os.ReadFile(filePath) + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", uploadUrl, bytes.NewBuffer(fileContent)) + if err != nil { + return err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload failed with status code: %d", resp.StatusCode) + } + + fmt.Printf("[LaunchDarkly] Uploaded %s to %s\n", filePath, name) + return nil +} + +func initFlags(cmd *cobra.Command) { + cmd.Flags().String(typeFlag, "", fmt.Sprintf("The symbol type to upload (supported: %s, %s)", typeReactNative, typeAndroid)) + _ = cmd.MarkFlagRequired(typeFlag) + _ = cmd.Flags().SetAnnotation(typeFlag, "required", []string{"true"}) + _ = viper.BindPFlag(typeFlag, cmd.Flags().Lookup(typeFlag)) + + cmd.Flags().String(cliflags.ProjectFlag, "", "The project key") + _ = cmd.MarkFlagRequired(cliflags.ProjectFlag) + _ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"}) + _ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag)) + + cmd.Flags().String(appVersionFlag, "", "The current version of your deploy") + _ = viper.BindPFlag(appVersionFlag, cmd.Flags().Lookup(appVersionFlag)) + + cmd.Flags().String(symbolsIdFlag, "", "The symbols id (launchdarkly.symbols_id.htlhash) to key uploads by (Symbols Id Lane). If omitted, a *.symbolsid sidecar next to the bundle is used when present") + _ = viper.BindPFlag(symbolsIdFlag, cmd.Flags().Lookup(symbolsIdFlag)) + + cmd.Flags().String(pathFlag, defaultPath, "Sets the directory of where the symbol files are") + _ = viper.BindPFlag(pathFlag, cmd.Flags().Lookup(pathFlag)) + + cmd.Flags().String(basePathFlag, "", "An optional base path for the uploaded symbol files") + _ = viper.BindPFlag(basePathFlag, cmd.Flags().Lookup(basePathFlag)) + + cmd.Flags().String(backendUrlFlag, defaultBackendUrl, "An optional backend url for self-hosted deployments") + _ = viper.BindPFlag(backendUrlFlag, cmd.Flags().Lookup(backendUrlFlag)) +} diff --git a/cmd/symbols/upload_test.go b/cmd/symbols/upload_test.go new file mode 100644 index 00000000..3bef3aed --- /dev/null +++ b/cmd/symbols/upload_test.go @@ -0,0 +1,258 @@ +package symbols + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +func TestNewUploadCmd(t *testing.T) { + client := resources.NewClient("") + cmd := NewUploadCmd(client, func(accessToken, baseURI string, analyticsOptOut bool) analytics.Tracker { + return &analytics.MockTracker{} + }) + + assert.Equal(t, "upload", cmd.Use) + assert.Equal(t, "Upload symbol files", cmd.Short) + + assert.NotNil(t, cmd.Flags().Lookup(typeFlag)) + assert.NotNil(t, cmd.Flags().Lookup("project")) + assert.NotNil(t, cmd.Flags().Lookup(appVersionFlag)) + assert.NotNil(t, cmd.Flags().Lookup(symbolsIdFlag)) + assert.NotNil(t, cmd.Flags().Lookup(pathFlag)) + assert.NotNil(t, cmd.Flags().Lookup(basePathFlag)) + assert.NotNil(t, cmd.Flags().Lookup(backendUrlFlag)) + + assert.Equal(t, []string{"true"}, cmd.Flags().Lookup(typeFlag).Annotations["required"]) + assert.Equal(t, []string{"true"}, cmd.Flags().Lookup("project").Annotations["required"]) +} + +func TestIsReactNativeUploadFile(t *testing.T) { + // React Native iOS bundle + map. + assert.True(t, isReactNativeUploadFile("main.jsbundle")) + assert.True(t, isReactNativeUploadFile("main.jsbundle.map")) + // React Native Android bundle + map. + assert.True(t, isReactNativeUploadFile("index.android.bundle")) + assert.True(t, isReactNativeUploadFile("index.android.bundle.map")) + // Web bundles are handled by `sourcemaps upload`, not here. + assert.False(t, isReactNativeUploadFile("app.js")) + assert.False(t, isReactNativeUploadFile("app.js.map")) + // Unrelated files are ignored. + assert.False(t, isReactNativeUploadFile("assets.png")) + assert.False(t, isReactNativeUploadFile("README.md")) +} + +func TestGetAllSymbolFilesReactNative(t *testing.T) { + tempDir, err := os.MkdirTemp("", "symbols-rn-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + rnFiles := []string{ + "main.jsbundle", + "main.jsbundle.map", + "index.android.bundle", + "index.android.bundle.map", + } + for _, name := range rnFiles { + err = os.WriteFile(filepath.Join(tempDir, name), []byte("{}"), 0644) + assert.NoError(t, err) + } + // Non-symbol files that must be skipped. + err = os.WriteFile(filepath.Join(tempDir, "assets.png"), []byte("x"), 0644) + assert.NoError(t, err) + err = os.WriteFile(filepath.Join(tempDir, "app.js"), []byte("x"), 0644) + assert.NoError(t, err) + + files, err := getAllSymbolFiles(tempDir, typeReactNative) + assert.NoError(t, err) + + found := make(map[string]bool) + for _, f := range files { + found[f.Name] = true + } + for _, name := range rnFiles { + assert.True(t, found[name], "expected %s to be discovered for upload", name) + } + assert.False(t, found["assets.png"], "non-symbol files must be skipped") + assert.False(t, found["app.js"], "web bundles must be skipped (handled by sourcemaps upload)") +} + +func TestGetAllSymbolFilesEmpty(t *testing.T) { + emptyDir, err := os.MkdirTemp("", "symbols-empty") + assert.NoError(t, err) + defer os.RemoveAll(emptyDir) + + _, err = getAllSymbolFiles(emptyDir, typeReactNative) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no React Native symbol files found") + + _, err = getAllSymbolFiles(emptyDir, typeAndroid) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no Android symbol files found") +} + +func TestGetAllSymbolFilesSingleFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "symbols-single") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + validMap := filepath.Join(tempDir, "main.jsbundle.map") + assert.NoError(t, os.WriteFile(validMap, []byte("{}"), 0644)) + unrelated := filepath.Join(tempDir, "secrets.txt") + assert.NoError(t, os.WriteFile(unrelated, []byte("nope"), 0644)) + mapping := filepath.Join(tempDir, androidMappingFileName) + assert.NoError(t, os.WriteFile(mapping, []byte("a -> b:\n"), 0644)) + + // A matching file for the type is accepted. + files, err := getAllSymbolFiles(validMap, typeReactNative) + assert.NoError(t, err) + assert.Len(t, files, 1) + assert.Equal(t, "main.jsbundle.map", files[0].Name) + + // An unrelated single file is rejected instead of uploaded under symbol keys. + _, err = getAllSymbolFiles(unrelated, typeReactNative) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is not a React Native symbol file") + + // A file valid for one type is rejected when the wrong type is chosen. + _, err = getAllSymbolFiles(validMap, typeAndroid) + assert.Error(t, err) + assert.Contains(t, err.Error(), "is not an Android symbol file") + + _, err = getAllSymbolFiles(mapping, typeAndroid) + assert.NoError(t, err) +} + +func TestIsSymbolUploadFileAndroid(t *testing.T) { + // Only mapping.txt is uploaded for the android type. + assert.True(t, isSymbolUploadFile(typeAndroid, "mapping.txt")) + assert.True(t, isSymbolUploadFile(typeAndroid, "outputs/mapping/release/mapping.txt")) + assert.False(t, isSymbolUploadFile(typeAndroid, "seeds.txt")) + assert.False(t, isSymbolUploadFile(typeAndroid, "main.jsbundle.map")) + // React Native discovery is unaffected. + assert.True(t, isSymbolUploadFile(typeReactNative, "index.android.bundle.map")) + assert.False(t, isSymbolUploadFile(typeReactNative, "mapping.txt")) +} + +func TestGetAllSymbolFilesAndroid(t *testing.T) { + tempDir, err := os.MkdirTemp("", "symbols-android-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + err = os.WriteFile(filepath.Join(tempDir, androidMappingFileName), []byte("com.example.Foo -> a:\n"), 0644) + assert.NoError(t, err) + // A React Native map alongside must NOT be picked up for the android type. + err = os.WriteFile(filepath.Join(tempDir, "main.jsbundle.map"), []byte("{}"), 0644) + assert.NoError(t, err) + + files, err := getAllSymbolFiles(tempDir, typeAndroid) + assert.NoError(t, err) + assert.Len(t, files, 1) + assert.Equal(t, androidMappingFileName, files[0].Name) +} + +func TestGetS3Key(t *testing.T) { + // Version Lane (version/basePath) addressing; prefix is unused when symbolsID is "". + assert.Equal(t, "1.0.1/index.android.bundle.map", getS3Key(reactNativeSymbolsIDPrefix, "", "1.0.1", "", "index.android.bundle.map")) + assert.Equal(t, "unversioned/main.jsbundle.map", getS3Key(reactNativeSymbolsIDPrefix, "", "", "", "main.jsbundle.map")) + assert.Equal(t, "1.0.1/dist/main.jsbundle", getS3Key(reactNativeSymbolsIDPrefix, "", "1.0.1", "dist", "main.jsbundle")) + assert.Equal(t, "1.0.1/mapping.txt", getS3Key(androidSymbolsIDPrefix, "", "1.0.1", "", "mapping.txt")) +} + +func TestGetS3KeySymbolsID(t *testing.T) { + symbolsID := "0123456789abcdef0123456789abcdef" + // Symbols Id Lane: a symbols id supersedes version/basePath and keys by basename. + assert.Equal(t, + "_sym/js/id/"+symbolsID+"/main.jsbundle.map", + getS3Key(reactNativeSymbolsIDPrefix, symbolsID, "1.0.1", "dist", "main.jsbundle.map")) + assert.Equal(t, + "_sym/js/id/"+symbolsID+"/index.android.bundle.map", + getS3Key(reactNativeSymbolsIDPrefix, symbolsID, "", "", "nested/index.android.bundle.map")) + // Android uses its own Symbols Id Lane namespace so JS and mapping ids never collide. + assert.Equal(t, + "_sym/android/id/"+symbolsID+"/mapping.txt", + getS3Key(androidSymbolsIDPrefix, symbolsID, "1.0.0", "", "mapping.txt")) +} + +func TestSymbolsIDPrefixForType(t *testing.T) { + assert.Equal(t, "_sym/js/id", symbolsIDPrefixForType(typeReactNative)) + assert.Equal(t, "_sym/android/id", symbolsIDPrefixForType(typeAndroid)) +} + +func TestReadSymbolsIDFile(t *testing.T) { + tempDir, err := os.MkdirTemp("", "symbols-id") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + map1 := filepath.Join(tempDir, "main.jsbundle.map") + map2 := filepath.Join(tempDir, "index.android.bundle.map") + + // No sidecar yet -> empty (falls back to the Version Lane). + assert.Equal(t, "", readSymbolsIDFile(map1+symbolsIDSidecarSuffix)) + + err = os.WriteFile(map1+symbolsIDSidecarSuffix, []byte("iosid\n"), 0644) + assert.NoError(t, err) + err = os.WriteFile(map2+symbolsIDSidecarSuffix, []byte("androidid\n"), 0644) + assert.NoError(t, err) + + // Each artifact resolves its own adjacent sidecar so a mixed-platform dir + // keys each map by the id its app reports (not the first one found). + assert.Equal(t, "iosid", readSymbolsIDFile(map1+symbolsIDSidecarSuffix)) + assert.Equal(t, "androidid", readSymbolsIDFile(map2+symbolsIDSidecarSuffix)) + assert.Equal(t, + "_sym/js/id/iosid/main.jsbundle.map", + getS3Key(reactNativeSymbolsIDPrefix, "iosid", "1.0.0", "", "main.jsbundle.map")) + assert.Equal(t, + "_sym/js/id/androidid/index.android.bundle.map", + getS3Key(reactNativeSymbolsIDPrefix, "androidid", "1.0.0", "", "index.android.bundle.map")) +} + +func TestSymbolsIDForArtifact(t *testing.T) { + tempDir, err := os.MkdirTemp("", "symbols-id-sibling") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + bundle := filepath.Join(tempDir, "main.jsbundle") + bundleMap := bundle + ".map" + + // The Metro plugin writes a single sidecar named after the source map. + err = os.WriteFile(bundleMap+symbolsIDSidecarSuffix, []byte("sharedid\n"), 0644) + assert.NoError(t, err) + + // Both the bundle and its .map must resolve to the same id, even though only + // the map has an adjacent sidecar — otherwise the bundle would drop to the + // Version Lane while the map stays on the Symbols Id Lane. + assert.Equal(t, "sharedid", symbolsIDForArtifact(bundleMap)) + assert.Equal(t, "sharedid", symbolsIDForArtifact(bundle)) + + // Symmetric case: sidecar written beside the bundle instead of the map. + tempDir2, err := os.MkdirTemp("", "symbols-id-sibling2") + assert.NoError(t, err) + defer os.RemoveAll(tempDir2) + + bundle2 := filepath.Join(tempDir2, "index.android.bundle") + err = os.WriteFile(bundle2+symbolsIDSidecarSuffix, []byte("bundleid\n"), 0644) + assert.NoError(t, err) + assert.Equal(t, "bundleid", symbolsIDForArtifact(bundle2)) + assert.Equal(t, "bundleid", symbolsIDForArtifact(bundle2+".map")) + + // No sidecar anywhere -> empty (Version Lane fallback). + assert.Equal(t, "", symbolsIDForArtifact(filepath.Join(tempDir2, "other.jsbundle"))) +} + +func TestUnsupportedType(t *testing.T) { + viper.Set(typeFlag, "apple-dsym") + defer viper.Set(typeFlag, "") + + client := resources.NewClient("") + err := runE(client)(&cobra.Command{}, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --type") +} diff --git a/cmd/templates.go b/cmd/templates.go index 6fe0d68a..b806ed7b 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -26,6 +26,7 @@ Common resource commands: {{rpad "members" 29}} Invite new members to an account {{rpad "segments" 29}} List, create, modify, and delete segments {{rpad "sourcemaps" 29}} Manage sourcemaps for error monitoring + {{rpad "symbols" 29}} Manage symbol files for error monitoring {{rpad "..." 29}} To see more resource commands, run 'ldcli resources' Flags: