Ctrl K
ComunidadGuías…

codersdk/appname_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package codersdk_test

import (
	"encoding/json"
	"fmt"
	"path"
	"path/filepath"
	"slices"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/codersdk"
)

// Duplicated from the package so the limit stays unexported.
const maxAppNameLength = 64

func TestNormalizeAppName(t *testing.T) {
	t.Parallel()

	for _, tc := range []struct {
		name  string
		input string
		want  string
	}{
		{"NormalizedPassthrough", "vscode", "vscode"},
		{"KnownNameCaseInsensitive", "JetBrains", "jetbrains"},
		{"LegacyAlias", "reconnecting-pty", "reconnecting_pty"},
		{"HyphenatedKnownName", "vscode-insiders", "vscode_insiders"},
		{"UnknownHyphensFolded", "my-future-ide", "my_future_ide"},
		{"UnknownLowercased", "Cursor Nightly", "cursor nightly"},
		{"UnknownPreservesUnicode", "エディタ", "エディタ"},
		{"StripsNullBytes", "cur\x00sor", "cursor"},
		{"StripsControlCharacters", "vs\ncode\r\t", "vscode"},
		// Only the escape byte itself is a control character, so the rest of
		// an ANSI sequence survives as ordinary text.
		{"StripsANSIEscape", "\x1b[31mvscode\x1b[0m", "[31mvscode[0m"},
		{"OnlyControlCharacters", "\n\r\t\x1b", "unknown"},
		{"TrimsWhitespace", " vscode\t", "vscode"},
		{"Empty", "", "unknown"},
		{"OnlyNullBytes", "\x00\x00", "unknown"},
		{"OnlyWhitespace", "   ", "unknown"},
		{"TruncatesToMaxRunes", strings.Repeat("a", maxAppNameLength+10), strings.Repeat("a", maxAppNameLength)},
		{"TruncatesMultibyteSafely", strings.Repeat("あ", maxAppNameLength+1), strings.Repeat("あ", maxAppNameLength)},
		// Padding is trimmed before truncating, so it does not spend the budget.
		{"TrimsPaddingBeforeTruncating", "  " + strings.Repeat("a", maxAppNameLength) + "  ", strings.Repeat("a", maxAppNameLength)},
		{"TrimsWhitespaceLeftByTruncation", strings.Repeat("a", 60) + "     tail", strings.Repeat("a", 60)},
	} {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tc.want, codersdk.NormalizeAppName(tc.input))
		})
	}
}

// The usage-app vocabulary is declared independently of the normalized app
// names because its values are part of the HTTP API. This pins the two
// together so they cannot drift.
func TestUsageAppNamesAreNormalized(t *testing.T) {
	t.Parallel()

	for sdkName, want := range map[codersdk.UsageAppName]string{
		codersdk.UsageAppNameVscode:          "vscode",
		codersdk.UsageAppNameJetbrains:       "jetbrains",
		codersdk.UsageAppNameReconnectingPty: "reconnecting_pty",
		codersdk.UsageAppNameSSH:             "ssh",
	} {
		require.Equal(t, want, codersdk.NormalizeAppName(string(sdkName)))
	}
}

func TestAppNameFamily(t *testing.T) {
	t.Parallel()

	for _, tc := range []struct {
		name  string
		input string
		want  codersdk.AppFamilyName
	}{
		{"VSCode", "vscode", codersdk.AppFamilyVSCode},
		{"VSCodeFork", "cursor", codersdk.AppFamilyVSCode},
		{"VSCodeInsidersHyphenated", "vscode-insiders", codersdk.AppFamilyVSCode},
		{"CaseInsensitive", "JetBrains", codersdk.AppFamilyJetBrains},
		{"SSHClientJoinsSSHFamily", "zed", codersdk.AppFamilySSH},
		{"Alias", "reconnecting-pty", codersdk.AppFamilyReconnectingPTY},
		{"PortForwarding", "port_forwarding", codersdk.AppFamilyPortForwarding},
		{"Unknown", "SomeFutureIDE", codersdk.AppFamilyUnknown},
	} {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tc.want, codersdk.AppNameFamily(tc.input))
		})
	}
}

// IDE clients report these names, so each must resolve to a registry key in
// its family. The VS Code extension sends vscode.env.uriScheme.
func TestIDEAppNamesAreRegistered(t *testing.T) {
	t.Parallel()

	for _, tc := range []struct {
		reported    string
		appName     string
		family      codersdk.AppFamilyName
		displayName string
	}{
		{"vscode", "vscode", codersdk.AppFamilyVSCode, "VS Code"},
		{"vscode-insiders", "vscode_insiders", codersdk.AppFamilyVSCode, "VS Code Insiders"},
		{"code-oss", "code_oss", codersdk.AppFamilyVSCode, "Code - OSS"},
		{"vscodium", "vscodium", codersdk.AppFamilyVSCode, "VSCodium"},
		{"vscodium-insiders", "vscodium_insiders", codersdk.AppFamilyVSCode, "VSCodium Insiders"},
		{"positron", "positron", codersdk.AppFamilyVSCode, "Positron"},
		{"cursor", "cursor", codersdk.AppFamilyVSCode, "Cursor"},
		{"devin", "devin", codersdk.AppFamilyVSCode, "Devin Desktop"},
		{"devin-next", "devin_next", codersdk.AppFamilyVSCode, "Devin Desktop Next"},
		{"windsurf", "windsurf", codersdk.AppFamilyVSCode, "Windsurf"},
		{"antigravity", "antigravity", codersdk.AppFamilyVSCode, "Antigravity"},
		{"trae", "trae", codersdk.AppFamilyVSCode, "Trae"},
		{"trae-cn", "trae_cn", codersdk.AppFamilyVSCode, "Trae CN"},
		{"kiro", "kiro", codersdk.AppFamilyVSCode, "Kiro"},
		// The JetBrains plugins map the IDE's product code to one name per IDE.
		{"intellij", "intellij", codersdk.AppFamilyJetBrains, "IntelliJ IDEA"},
		{"pycharm", "pycharm", codersdk.AppFamilyJetBrains, "PyCharm"},
		{"goland", "goland", codersdk.AppFamilyJetBrains, "GoLand"},
		{"webstorm", "webstorm", codersdk.AppFamilyJetBrains, "WebStorm"},
		{"phpstorm", "phpstorm", codersdk.AppFamilyJetBrains, "PhpStorm"},
		{"rubymine", "rubymine", codersdk.AppFamilyJetBrains, "RubyMine"},
		{"clion", "clion", codersdk.AppFamilyJetBrains, "CLion"},
		{"rider", "rider", codersdk.AppFamilyJetBrains, "Rider"},
		{"rustrover", "rustrover", codersdk.AppFamilyJetBrains, "RustRover"},
		{"datagrip", "datagrip", codersdk.AppFamilyJetBrains, "DataGrip"},
		{"dataspell", "dataspell", codersdk.AppFamilyJetBrains, "DataSpell"},
		{"mps", "mps", codersdk.AppFamilyJetBrains, "MPS"},
		{"android_studio", "android_studio", codersdk.AppFamilyJetBrains, "Android Studio"},
	} {
		t.Run(tc.reported, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tc.appName, codersdk.NormalizeAppName(tc.reported))
			require.Equal(t, tc.family, codersdk.AppNameFamily(tc.reported))
			app := codersdk.SessionCountApps(map[string]int64{tc.appName: 1})[tc.appName]
			require.Equal(t, tc.displayName, app.DisplayName)
		})
	}
}

// Family sets are derived from the one registry, so callers that need the app
// names in a family do not need a second list.
func TestRegistryFamilySets(t *testing.T) {
	t.Parallel()

	registry := codersdk.SessionCountAppFamilies()
	inFamily := func(want codersdk.AppFamilyName) []string {
		var names []string
		for appName, family := range registry {
			if family == want {
				names = append(names, appName)
			}
		}
		slices.Sort(names)
		return names
	}

	// Forks share the VS Code family.
	vscode := inFamily(codersdk.AppFamilyVSCode)
	require.Contains(t, vscode, "cursor")
	require.Contains(t, vscode, "vscode")

	// Zed speaks SSH, so it reports under the SSH family.
	require.Equal(t, []string{"ssh", "zed"}, inFamily(codersdk.AppFamilySSH))

	require.Empty(t, inFamily("no_such_family"))
}

func TestSessionCountAppFamilies(t *testing.T) {
	t.Parallel()

	registry := codersdk.SessionCountAppFamilies()
	require.NotEmpty(t, registry)
	for appName, family := range registry {
		require.Equal(t, family, codersdk.AppNameFamily(appName),
			"registry entry %q must agree with lookup", appName)
	}

	// The registry is a copy, so a caller cannot corrupt attribution.
	registry["cursor"] = codersdk.AppFamilySSH
	require.Equal(t, codersdk.AppFamilyVSCode, codersdk.AppNameFamily("cursor"))
	require.Equal(t, codersdk.AppFamilyVSCode, codersdk.SessionCountAppFamilies()["cursor"])
}

func TestSumByFamily(t *testing.T) {
	t.Parallel()

	for _, tc := range []struct {
		name      string
		appCounts map[string]int64
		want      map[codersdk.AppFamilyName]int64
	}{
		{
			name:      "Empty",
			appCounts: map[string]int64{},
			want:      map[codersdk.AppFamilyName]int64{},
		},
		{
			name:      "Nil",
			appCounts: nil,
			want:      map[codersdk.AppFamilyName]int64{},
		},
		{
			name:      "SingleApp",
			appCounts: map[string]int64{"jetbrains": 3},
			want:      map[codersdk.AppFamilyName]int64{codersdk.AppFamilyJetBrains: 3},
		},
		{
			// Simultaneous forks are additive within their family.
			name:      "OverlappingAppsInOneFamily",
			appCounts: map[string]int64{"vscode": 1, "cursor": 2, "windsurf": 4},
			want:      map[codersdk.AppFamilyName]int64{codersdk.AppFamilyVSCode: 7},
		},
		{
			name:      "UnregisteredAppIsUnknown",
			appCounts: map[string]int64{"some_future_ide": 5, "ssh": 1},
			want: map[codersdk.AppFamilyName]int64{
				codersdk.AppFamilyUnknown: 5,
				codersdk.AppFamilySSH:     1,
			},
		},
		{
			// Storage normalizes app names, but folding must not depend on it.
			name:      "DenormalizedNamesFold",
			appCounts: map[string]int64{"VSCode-Insiders": 2, "vscode_insiders": 1},
			want:      map[codersdk.AppFamilyName]int64{codersdk.AppFamilyVSCode: 3},
		},
		{
			name:      "AllFamilies",
			appCounts: map[string]int64{"vscode": 1, "jetbrains": 2, "zed": 3, "reconnecting_pty": 4, "": 5},
			want: map[codersdk.AppFamilyName]int64{
				codersdk.AppFamilyVSCode:          1,
				codersdk.AppFamilyJetBrains:       2,
				codersdk.AppFamilySSH:             3,
				codersdk.AppFamilyReconnectingPTY: 4,
				codersdk.AppFamilyUnknown:         5,
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			require.Equal(t, tc.want, codersdk.SumByFamily(tc.appCounts))
		})
	}
}

// A family is registered by adding app names alone, with no SQL, column, or
// second list to update.
func TestSumByFamilyCoversEveryRegisteredFamily(t *testing.T) {
	t.Parallel()

	appCounts := map[string]int64{}
	want := map[codersdk.AppFamilyName]int64{}
	for appName, family := range codersdk.SessionCountAppFamilies() {
		appCounts[appName] = 1
		want[family]++
	}
	require.Equal(t, want, codersdk.SumByFamily(appCounts))
}

func TestDecodeAppMap(t *testing.T) {
	t.Parallel()

	for name, raw := range map[string]json.RawMessage{
		"NotJSON":     json.RawMessage(`{`),
		"NotAnObject": json.RawMessage(`[1, 2]`),
		"WrongValue":  json.RawMessage(`{"vscode": "sixty"}`),
	} {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			// A malformed payload must not decode to zero usage, or an
			// encoding bug would look like an idle deployment.
			got, err := codersdk.DecodeAppMap[int64](raw)
			require.Error(t, err)
			require.Nil(t, got)
		})
	}

	t.Run("UsageSeconds", func(t *testing.T) {
		t.Parallel()

		got, err := codersdk.DecodeAppMap[int64](json.RawMessage(`{"cursor": 60}`))
		require.NoError(t, err)
		require.Equal(t, map[string]int64{"cursor": 60}, got)
	})

	// A query with no rows aggregates to SQL NULL: no usage, not an error.
	for name, raw := range map[string]json.RawMessage{
		"Absent":      nil,
		"EmptyObject": json.RawMessage(`{}`),
		"JSONNull":    json.RawMessage(`null`),
	} {
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			got, err := codersdk.DecodeAppMap[int64](raw)
			require.NoError(t, err)
			require.Equal(t, map[string]int64{}, got)
			require.Zero(t, got["ssh"])
		})
	}
}

func TestFoldUnregisteredSessionApps(t *testing.T) {
	t.Parallel()

	// Exempt names carry the lowest counts, so only the exemption keeps them.
	apps := map[string]int64{"vscode": 1, "unknown": 1, codersdk.AppNameOverflow: 1}
	for i := range codersdk.TemplateInsightsMaxUnregisteredApps - 1 {
		apps[fmt.Sprintf("tool_%02d", i)] = 10
	}
	// A tie at the cutoff keeps the name that sorts first.
	apps["tie_a"], apps["tie_b"], apps["quiet"] = 5, 5, 2

	require.Equal(t, 2, codersdk.FoldUnregisteredSessionApps(apps))
	require.EqualValues(t, 5, apps["tie_a"])
	require.NotContains(t, apps, "tie_b")
	require.NotContains(t, apps, "quiet")
	require.EqualValues(t, 1+5+2, apps[codersdk.AppNameOverflow])
	require.EqualValues(t, 1, apps["vscode"])
	require.EqualValues(t, 1, apps["unknown"])
	require.Zero(t, codersdk.FoldUnregisteredSessionApps(map[string]int64{"a": 1}))
}

func TestSessionCountApps(t *testing.T) {
	t.Parallel()

	apps := codersdk.SessionCountApps(map[string]int64{"cursor": 2, "vscodium": 1, "future_ide": 3, "overflow": 4, "unknown": 5})
	require.Equal(t, map[string]codersdk.SessionCountApp{
		"cursor":   {Count: 2, DisplayName: "Cursor", Icon: "/icon/cursor.svg", Family: codersdk.AppFamilyVSCode},
		"vscodium": {Count: 1, DisplayName: "VSCodium", Family: codersdk.AppFamilyVSCode},
		// An app the registry does not know shows its own name.
		"future_ide": {Count: 3, DisplayName: "future_ide", Family: codersdk.AppFamilyUnknown},
		// The accounting names show a label, still under the unknown family.
		"overflow": {Count: 4, DisplayName: "Other apps", Family: codersdk.AppFamilyUnknown},
		"unknown":  {Count: 5, DisplayName: "Unknown", Family: codersdk.AppFamilyUnknown},
	}, apps)
	require.Equal(t, map[string]codersdk.SessionCountApp{}, codersdk.SessionCountApps(nil))

	for _, tt := range []struct {
		name        string
		displayName string
		icon        string
		family      codersdk.AppFamilyName
	}{
		{" Cursor ", "Cursor", "/icon/cursor.svg", codersdk.AppFamilyVSCode},
		{"VSCodium", "VSCodium", "", codersdk.AppFamilyVSCode},
		{"Future-IDE", "future_ide", "", codersdk.AppFamilyUnknown},
		{"", "Unknown", "", codersdk.AppFamilyUnknown},
		{" OVERFLOW ", "Other apps", "", codersdk.AppFamilyUnknown},
	} {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			displayName, icon, family := codersdk.SessionAppPresentation(tt.name)
			require.Equal(t, tt.displayName, displayName)
			require.Equal(t, tt.icon, icon)
			require.Equal(t, tt.family, family)
		})
	}

	// Accounting labels must not bypass limits for unregistered apps.
	require.NotContains(t, codersdk.SessionCountAppFamilies(), codersdk.AppNameOverflow)
	require.NotContains(t, codersdk.SessionCountAppFamilies(), string(codersdk.AppFamilyUnknown))
}

func TestSessionCountAppIcons(t *testing.T) {
	t.Parallel()

	// Every icon is a bundled, clean path, never an arbitrary URL.
	for name := range codersdk.SessionCountAppFamilies() {
		app := codersdk.SessionCountApps(map[string]int64{name: 1})[name]
		require.NotEmpty(t, app.DisplayName, name)
		if app.Icon == "" {
			continue
		}
		require.True(t, strings.HasPrefix(app.Icon, "/icon/"), name)
		// The icon is a URL path, so path.Clean holds on every OS where
		// filepath.Clean would rewrite the separators.
		require.Equal(t, path.Clean(app.Icon), app.Icon)
		require.FileExists(t, filepath.Join("..", "site", "static", app.Icon), "icon for %s must be bundled", name)
	}
}