mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience. Changes: - Add official skills index JSON parsing in `internal/skillscheck/sync.go` - Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go` - Add reason-chain details when both discovery layers fall back to `fallbackFullInstall` - Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go` - Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go` Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
374 lines
11 KiB
Go
374 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
// executableTestFS mocks vfs for tests that still need vfs.Executable.
|
|
type executableTestFS struct {
|
|
vfs.OsFs
|
|
exe string
|
|
}
|
|
|
|
func (f executableTestFS) Executable() (string, error) { return f.exe, nil }
|
|
|
|
// lookPathMock patches execLookPath within VerifyBinary for controlled testing.
|
|
// Do not use t.Parallel() in tests that install this mock — it mutates a package-level var.
|
|
type lookPathMock struct {
|
|
oldLookPath func(string) (string, error)
|
|
result string
|
|
resultErr error
|
|
}
|
|
|
|
func (m *lookPathMock) install(bin string) {
|
|
m.oldLookPath = execLookPath
|
|
execLookPath = func(name string) (string, error) {
|
|
if name == bin {
|
|
return m.result, m.resultErr
|
|
}
|
|
return m.oldLookPath(name)
|
|
}
|
|
}
|
|
|
|
func (m *lookPathMock) restore() {
|
|
execLookPath = m.oldLookPath
|
|
}
|
|
|
|
func TestResolveExe(t *testing.T) {
|
|
u := New()
|
|
p, err := u.resolveExe()
|
|
if err != nil {
|
|
t.Fatalf("resolveExe() error: %v", err)
|
|
}
|
|
if !filepath.IsAbs(p) {
|
|
t.Errorf("expected absolute path, got: %s", p)
|
|
}
|
|
}
|
|
|
|
func TestPrepareSelfReplace_ReturnsNoError(t *testing.T) {
|
|
u := New()
|
|
restore, err := u.PrepareSelfReplace()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
restore()
|
|
}
|
|
|
|
func TestCleanupStaleFiles_NoPanic(t *testing.T) {
|
|
u := New()
|
|
u.CleanupStaleFiles()
|
|
}
|
|
|
|
func TestVerifyBinaryLookPath(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a POSIX shell script")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "lark-cli")
|
|
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
|
|
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
|
|
t.Fatalf("write test binary: %v", err)
|
|
}
|
|
|
|
mock := &lookPathMock{result: bin}
|
|
mock.install("lark-cli")
|
|
t.Cleanup(mock.restore)
|
|
|
|
if err := New().VerifyBinary("2.1.0"); err != nil {
|
|
t.Fatalf("VerifyBinary(2.1.0) error = %v, want nil", err)
|
|
}
|
|
|
|
if err := New().VerifyBinary("3.0.0"); err == nil {
|
|
t.Fatal("VerifyBinary(mismatched) expected error, got nil")
|
|
}
|
|
|
|
// Regression: version must match exactly (not substring / prefix).
|
|
if err := New().VerifyBinary("0.0"); err == nil {
|
|
t.Fatal("VerifyBinary(substring-style mismatch) expected error, got nil")
|
|
}
|
|
if err := New().VerifyBinary("12.1.0"); err == nil {
|
|
t.Fatal("VerifyBinary(prefix-style mismatch) expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryLookPathNotFound(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not found")}
|
|
mock.install("lark-cli")
|
|
t.Cleanup(mock.restore)
|
|
|
|
oldFS := vfs.DefaultFS
|
|
t.Cleanup(func() { vfs.DefaultFS = oldFS })
|
|
// Without this, VerifyBinary would fall back to the real test binary, which
|
|
// is not a lark-cli --version implementation.
|
|
vfs.DefaultFS = executableTestFS{exe: filepath.Join(t.TempDir(), "missing-lark-cli")}
|
|
|
|
if err := New().VerifyBinary("2.0.0"); err == nil {
|
|
t.Fatal("VerifyBinary(not-found) expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryFallbackExecutableWhenNotOnPath(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a POSIX shell script")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "lark-cli-abs")
|
|
script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"lark-cli version 2.1.0\"; exit 0; fi\nexit 12\n"
|
|
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write test binary: %v", err)
|
|
}
|
|
|
|
mock := &lookPathMock{result: "", resultErr: fmt.Errorf("not on PATH")}
|
|
mock.install("lark-cli")
|
|
t.Cleanup(mock.restore)
|
|
|
|
oldFS := vfs.DefaultFS
|
|
t.Cleanup(func() { vfs.DefaultFS = oldFS })
|
|
vfs.DefaultFS = executableTestFS{exe: bin}
|
|
|
|
if err := New().VerifyBinary("2.1.0"); err != nil {
|
|
t.Fatalf("VerifyBinary(fallback executable) error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBinaryEmptyOutput(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a POSIX shell script")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "lark-cli")
|
|
script := "#!/bin/sh\necho\nexit 0\n"
|
|
if err := os.WriteFile(bin, []byte(script), 0755); err != nil {
|
|
t.Fatalf("write test binary: %v", err)
|
|
}
|
|
|
|
mock := &lookPathMock{result: bin}
|
|
mock.install("lark-cli")
|
|
t.Cleanup(mock.restore)
|
|
|
|
if err := New().VerifyBinary("2.0.0"); err == nil {
|
|
t.Fatal("VerifyBinary(empty output) expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(*Updater) *NpmResult
|
|
want string
|
|
}{
|
|
{
|
|
name: "list official primary",
|
|
run: func(u *Updater) *NpmResult {
|
|
return u.runSkillsListOfficial("https://open.feishu.cn")
|
|
},
|
|
want: "-y skills add https://open.feishu.cn --list",
|
|
},
|
|
{
|
|
name: "list global",
|
|
run: func(u *Updater) *NpmResult {
|
|
return u.runSkillsListGlobal()
|
|
},
|
|
want: "-y skills ls -g",
|
|
},
|
|
{
|
|
name: "list global json",
|
|
run: func(u *Updater) *NpmResult {
|
|
return u.ListGlobalSkillsJSON()
|
|
},
|
|
want: "-y skills ls -g --json",
|
|
},
|
|
{
|
|
name: "install skill primary",
|
|
run: func(u *Updater) *NpmResult {
|
|
return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"})
|
|
},
|
|
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a POSIX shell script")
|
|
}
|
|
dir := t.TempDir()
|
|
script := filepath.Join(dir, "npx")
|
|
logPath := filepath.Join(dir, "npx.log")
|
|
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
result := tt.run(New())
|
|
if result.Err != nil {
|
|
t.Fatalf("command err = %v, want nil", result.Err)
|
|
}
|
|
raw, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.TrimSpace(string(raw)) != tt.want {
|
|
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
oldURL := officialSkillsIndexURL
|
|
officialSkillsIndexURL = server.URL
|
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
|
|
|
result := New().ListOfficialSkillsIndex()
|
|
if result.Err != nil {
|
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
|
}
|
|
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
|
|
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
oldURL := officialSkillsIndexURL
|
|
officialSkillsIndexURL = server.URL
|
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
|
|
|
result := New().ListOfficialSkillsIndex()
|
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
|
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
|
|
}))
|
|
defer server.Close()
|
|
|
|
oldURL := officialSkillsIndexURL
|
|
officialSkillsIndexURL = server.URL
|
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
|
|
|
result := New().ListOfficialSkillsIndex()
|
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
|
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
|
|
}
|
|
if result.Stdout.Len() != 0 {
|
|
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
oldURL := officialSkillsIndexURL
|
|
oldTimeout := skillsIndexFetchTimeout
|
|
officialSkillsIndexURL = server.URL
|
|
skillsIndexFetchTimeout = 50 * time.Millisecond
|
|
t.Cleanup(func() {
|
|
officialSkillsIndexURL = oldURL
|
|
skillsIndexFetchTimeout = oldTimeout
|
|
})
|
|
|
|
result := New().ListOfficialSkillsIndex()
|
|
var netErr net.Error
|
|
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
|
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
oldURL := officialSkillsIndexURL
|
|
officialSkillsIndexURL = server.URL
|
|
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
|
|
|
result := New().ListOfficialSkillsIndex()
|
|
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
|
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
|
|
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
|
|
r := &NpmResult{}
|
|
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
|
|
return r
|
|
}}).ListOfficialSkillsIndex()
|
|
if result.Err != nil {
|
|
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
|
}
|
|
if !strings.Contains(result.Stdout.String(), "override-skill") {
|
|
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|
called := []string{}
|
|
updater := &Updater{
|
|
SkillsCommandOverride: func(args ...string) *NpmResult {
|
|
called = append(called, strings.Join(args, " "))
|
|
r := &NpmResult{}
|
|
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
|
|
r.Err = fmt.Errorf("primary failed")
|
|
return r
|
|
}
|
|
r.Stdout.WriteString("lark-calendar\n")
|
|
return r
|
|
},
|
|
}
|
|
|
|
result := updater.ListOfficialSkills()
|
|
if result.Err != nil {
|
|
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
|
|
}
|
|
if len(called) != 2 {
|
|
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
|
|
}
|
|
if !strings.Contains(called[1], "larksuite/cli --list") {
|
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
|
}
|
|
}
|