codersdk/appname_test.go
Rama main · réplica de lectura del repositorio original.
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)
}
}