mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
* feat(packages): add apk update flow + pkg-helper v2 protocol - APK update checker/executor via helper IPC (runtime detection, upgrade scan via apk list --upgradable) - BREAKING: pkg-helper v2 protocol (5 actions: check_apk/check_pip/check_npm/exec_apk/exec_pip, code/data fields, renewable 10min deadline, apkMutex, 1MB scanner) - Edition gating: SupportsApk + IsAlpineRuntime double-gate (Standard/Full only) - Backend 3-branch wiring: alpine/apt/yum routes + update_registry, dep_installer helpers - i18n: 5 apk keys (EN/VI/ZH catalogs) - Frontend: source pill Alpine badge, APK in updates-list/summary-bar/update-all modal - E2E tests: apk_e2e build tag covering checker/executor/helper protocol - Docs: packages-apk.md, security/changelog updates - Plans + reports under plans/260417-1500-packages-update-phase2b-apk-pkghelper/ + plans/reports/ * docs(packages): journal Phase 2b apk + pkg-helper v2
456 lines
12 KiB
Go
456 lines
12 KiB
Go
package skills
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsPipPreRelease(t *testing.T) {
|
|
cases := []struct {
|
|
version string
|
|
want bool
|
|
}{
|
|
// Pre-release: bare identifiers (no digit) — M-1 fix
|
|
{"1.0.0rc", true},
|
|
{"1.0.0a", true},
|
|
{"1.0.0b", true},
|
|
// Pre-release: with digit
|
|
{"1.0.0rc1", true},
|
|
{"1.0.0a1", true},
|
|
{"1.0.0b0", true},
|
|
{"2.0.0.dev1", true},
|
|
{"1.0.0.dev0", true},
|
|
// Pre-release: .pre / .preview suffix
|
|
{"1.0.0.pre", true},
|
|
{"1.0.0.preview", true},
|
|
// Stable releases
|
|
{"1.0.0", false},
|
|
{"2.3.4", false},
|
|
{"1.0.0.post1", false},
|
|
{"1.0.0.post0", false},
|
|
}
|
|
for _, tc := range cases {
|
|
got := IsPipPreRelease(tc.version)
|
|
if got != tc.want {
|
|
t.Errorf("IsPipPreRelease(%q) = %v, want %v", tc.version, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsNpmPreRelease(t *testing.T) {
|
|
cases := []struct {
|
|
version string
|
|
want bool
|
|
}{
|
|
// Pre-release labels
|
|
{"5.0.0-beta.1", true},
|
|
{"5.0.0-rc.0", true},
|
|
{"5.0.0-alpha.1", true},
|
|
{"5.0.0-pre", true},
|
|
{"5.0.0-preview.2", true},
|
|
{"5.0.0-dev", true},
|
|
{"5.0.0-nightly", true},
|
|
{"5.0.0-snapshot", true},
|
|
// Stable
|
|
{"5.0.0", false},
|
|
{"5.0.0-foo", false}, // unknown label → not pre-release
|
|
{"5.0.0-stable", false}, // "stable" not in list
|
|
}
|
|
for _, tc := range cases {
|
|
got := IsNpmPreRelease(tc.version)
|
|
if got != tc.want {
|
|
t.Errorf("IsNpmPreRelease(%q) = %v, want %v", tc.version, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidatePipPackageName(t *testing.T) {
|
|
accept := []string{
|
|
"Django",
|
|
"my-pkg",
|
|
"pip_tools",
|
|
"PyJWT",
|
|
"numpy",
|
|
"scikit-learn",
|
|
"A1",
|
|
}
|
|
for _, name := range accept {
|
|
if err := ValidatePipPackageName(name); err != nil {
|
|
t.Errorf("ValidatePipPackageName(%q) rejected valid name: %v", name, err)
|
|
}
|
|
}
|
|
|
|
reject := []string{
|
|
"",
|
|
"typescript@latest", // @ suffix
|
|
"pkg@@", // double @
|
|
"pkg;rm", // shell metachar
|
|
"pkg space", // space
|
|
"-pkg", // leading hyphen
|
|
".pkg", // leading dot
|
|
"pkg|other", // pipe
|
|
"pkg>1.0", // gt
|
|
}
|
|
for _, name := range reject {
|
|
if err := ValidatePipPackageName(name); err == nil {
|
|
t.Errorf("ValidatePipPackageName(%q) accepted invalid name", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateNpmPackageName(t *testing.T) {
|
|
accept := []string{
|
|
"typescript",
|
|
"@angular/core",
|
|
"@scope/name-2",
|
|
"react",
|
|
"@babel/core",
|
|
"lodash.get",
|
|
}
|
|
for _, name := range accept {
|
|
if err := ValidateNpmPackageName(name); err != nil {
|
|
t.Errorf("ValidateNpmPackageName(%q) rejected valid name: %v", name, err)
|
|
}
|
|
}
|
|
|
|
reject := []string{
|
|
"",
|
|
"TypeScript", // uppercase (npm forbids)
|
|
"typescript@latest", // @ version suffix on bare name
|
|
"pkg@@", // double @
|
|
"@scope/PKG", // uppercase in scoped path
|
|
"@Scope/name", // uppercase scope
|
|
"pkg space", // space
|
|
"@/name", // empty scope
|
|
}
|
|
for _, name := range reject {
|
|
if err := ValidateNpmPackageName(name); err == nil {
|
|
t.Errorf("ValidateNpmPackageName(%q) accepted invalid name", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifyPipStderr(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
stderr string
|
|
wantSentinel error
|
|
}{
|
|
{
|
|
name: "externally managed environment",
|
|
stderr: "error: externally-managed-environment\nsome extra text",
|
|
wantSentinel: ErrUpdatePipExternallyManaged,
|
|
},
|
|
{
|
|
name: "EXTERNALLY-MANAGED upper",
|
|
stderr: "This environment is EXTERNALLY-MANAGED",
|
|
wantSentinel: ErrUpdatePipExternallyManaged,
|
|
},
|
|
{
|
|
name: "permission denied",
|
|
stderr: "ERROR: Could not install packages: Permission denied",
|
|
wantSentinel: ErrUpdatePipPermission,
|
|
},
|
|
{
|
|
name: "no matching distribution",
|
|
stderr: "ERROR: No matching distribution found for nonexistent-pkg==99.0",
|
|
wantSentinel: ErrUpdatePipNotFound,
|
|
},
|
|
{
|
|
name: "could not find a version",
|
|
stderr: "ERROR: Could not find a version that satisfies the requirement",
|
|
wantSentinel: ErrUpdatePipNotFound,
|
|
},
|
|
{
|
|
name: "network read timeout",
|
|
stderr: "Read timed out. (read timeout=15)",
|
|
wantSentinel: ErrUpdatePipNetwork,
|
|
},
|
|
{
|
|
name: "dependency conflict",
|
|
stderr: "ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.",
|
|
wantSentinel: ErrUpdatePipConflict,
|
|
},
|
|
{
|
|
name: "shallow backtracking",
|
|
stderr: "Shallow backtracking detected: could not find a matching version",
|
|
wantSentinel: ErrUpdatePipConflict,
|
|
},
|
|
{
|
|
name: "unclassified returns nil sentinel",
|
|
stderr: "some random pip error output",
|
|
wantSentinel: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sentinel, reason := ClassifyPipStderr(tc.stderr)
|
|
if sentinel != tc.wantSentinel {
|
|
t.Errorf("ClassifyPipStderr sentinel = %v, want %v", sentinel, tc.wantSentinel)
|
|
}
|
|
if reason == "" {
|
|
t.Error("reason must not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyNpmStderr(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
stderr string
|
|
wantSentinel error
|
|
}{
|
|
{
|
|
name: "EACCES permission",
|
|
stderr: "npm ERR! code EACCES\nnpm ERR! path /usr/local/lib",
|
|
wantSentinel: ErrUpdateNpmPermission,
|
|
},
|
|
{
|
|
name: "ERESOLVE conflict",
|
|
stderr: "npm ERR! code ERESOLVE\nnpm ERR! ERESOLVE unable to resolve dependency tree",
|
|
wantSentinel: ErrUpdateNpmConflict,
|
|
},
|
|
{
|
|
name: "ETIMEDOUT network",
|
|
stderr: "npm ERR! code ETIMEDOUT\nnpm ERR! errno ETIMEDOUT",
|
|
wantSentinel: ErrUpdateNpmNetwork,
|
|
},
|
|
{
|
|
name: "ENOTFOUND network",
|
|
stderr: "npm ERR! code ENOTFOUND\nnpm ERR! errno ENOTFOUND registry.npmjs.org",
|
|
wantSentinel: ErrUpdateNpmNetwork,
|
|
},
|
|
{
|
|
name: "ETARGET version missing",
|
|
stderr: "npm ERR! code ETARGET\nnpm ERR! notarget No matching version found for typescript@99.0.0",
|
|
wantSentinel: ErrUpdateNpmTargetMissing,
|
|
},
|
|
{
|
|
name: "E404 not found",
|
|
stderr: "npm ERR! code E404\nnpm ERR! 404 Not Found",
|
|
wantSentinel: ErrUpdateNpmNotFound,
|
|
},
|
|
{
|
|
name: "not in this registry",
|
|
stderr: "npm ERR! my-private-pkg is not in this registry",
|
|
wantSentinel: ErrUpdateNpmNotFound,
|
|
},
|
|
{
|
|
name: "unclassified returns nil sentinel",
|
|
stderr: "npm ERR! some random error",
|
|
wantSentinel: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sentinel, reason := ClassifyNpmStderr(tc.stderr)
|
|
if sentinel != tc.wantSentinel {
|
|
t.Errorf("ClassifyNpmStderr sentinel = %v, want %v", sentinel, tc.wantSentinel)
|
|
}
|
|
if reason == "" {
|
|
t.Error("reason must not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateApkPackageName(t *testing.T) {
|
|
accept := []string{
|
|
"curl",
|
|
"bash",
|
|
"py3-pip",
|
|
"gcc",
|
|
"libstdc++",
|
|
"gtk+3.0",
|
|
"ca-certificates",
|
|
"bash-completion",
|
|
"musl",
|
|
"openssl3",
|
|
"libc6-compat",
|
|
"e2fsprogs",
|
|
}
|
|
for _, name := range accept {
|
|
if err := ValidateApkPackageName(name); err != nil {
|
|
t.Errorf("ValidateApkPackageName(%q) rejected valid name: %v", name, err)
|
|
}
|
|
}
|
|
|
|
reject := []string{
|
|
"",
|
|
"CURL", // uppercase
|
|
"curl;rm -rf /", // shell metachar
|
|
"curl@edge", // @ not valid for apk
|
|
"../evil", // path traversal
|
|
"-dash-start", // leading hyphen
|
|
"pkg space", // space
|
|
"@scope/pkg", // npm-style scoped pkg
|
|
"pkg|other", // pipe
|
|
"pkg>1.0", // gt
|
|
"Uppercase", // uppercase in middle
|
|
}
|
|
for _, name := range reject {
|
|
if err := ValidateApkPackageName(name); err == nil {
|
|
t.Errorf("ValidateApkPackageName(%q) accepted invalid name", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateApkPackageName_SentinelError(t *testing.T) {
|
|
err := ValidateApkPackageName("CURL")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid name, got nil")
|
|
}
|
|
// Must wrap ErrInvalidApkPackageName so callers can use errors.Is.
|
|
if !strings.Contains(err.Error(), "invalid") {
|
|
t.Errorf("error message should mention 'invalid': %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClassifyApkStderr(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
stderr string
|
|
wantSentinel error
|
|
}{
|
|
{
|
|
name: "database locked",
|
|
stderr: "ERROR: unable to lock database: Permission denied\n",
|
|
wantSentinel: ErrUpdateApkLocked, // locked wins over permission (priority order)
|
|
},
|
|
{
|
|
name: "permission denied standalone",
|
|
stderr: "ERROR: Permission denied writing /var/cache/apk",
|
|
wantSentinel: ErrUpdateApkPermission,
|
|
},
|
|
{
|
|
name: "no space left on device",
|
|
stderr: "ERROR: No space left on device",
|
|
wantSentinel: ErrUpdateApkDiskFull,
|
|
},
|
|
{
|
|
name: "disk full keyword",
|
|
stderr: "write error: disk full",
|
|
wantSentinel: ErrUpdateApkDiskFull,
|
|
},
|
|
{
|
|
name: "unsatisfiable constraints not found",
|
|
stderr: "ERROR: unsatisfiable constraints: nonexistent-pkg (missing)",
|
|
wantSentinel: ErrUpdateApkNotFound,
|
|
},
|
|
{
|
|
name: "unsatisfiable constraints with required by",
|
|
stderr: "ERROR: unsatisfiable constraints: foo-2.0 required by bar-1.0",
|
|
wantSentinel: ErrUpdateApkConflict,
|
|
},
|
|
{
|
|
name: "unsatisfiable constraints with breaks world",
|
|
stderr: "ERROR: unsatisfiable constraints: openssl-3.1 breaks: world",
|
|
wantSentinel: ErrUpdateApkConflict,
|
|
},
|
|
{
|
|
name: "breaks world standalone",
|
|
stderr: "ERROR: musl breaks: world",
|
|
wantSentinel: ErrUpdateApkConflict,
|
|
},
|
|
{
|
|
name: "unable to fetch network",
|
|
stderr: "ERROR: unable to fetch APKINDEX from dl-cdn.alpinelinux.org",
|
|
wantSentinel: ErrUpdateApkNetwork,
|
|
},
|
|
{
|
|
name: "timed out network",
|
|
stderr: "fetch http://dl-cdn.alpinelinux.org/alpine/v3.19/main: timed out",
|
|
wantSentinel: ErrUpdateApkNetwork,
|
|
},
|
|
{
|
|
name: "hostname resolution failed",
|
|
stderr: "ERROR: hostname resolution failed: dl-cdn.alpinelinux.org",
|
|
wantSentinel: ErrUpdateApkNetwork,
|
|
},
|
|
{
|
|
name: "unrecognized error returns nil sentinel",
|
|
stderr: "apk: some unrecognized error occurred",
|
|
wantSentinel: nil,
|
|
},
|
|
{
|
|
name: "empty stderr returns nil sentinel",
|
|
stderr: "",
|
|
wantSentinel: nil,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
sentinel, reason := ClassifyApkStderr(tc.stderr)
|
|
if sentinel != tc.wantSentinel {
|
|
t.Errorf("ClassifyApkStderr sentinel = %v, want %v", sentinel, tc.wantSentinel)
|
|
}
|
|
// reason must always be non-nil string (may be empty if stderr is empty)
|
|
_ = reason
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyApkStderr_ReasonNonEmpty(t *testing.T) {
|
|
// For non-empty stderr, reason must be non-empty.
|
|
_, reason := ClassifyApkStderr("ERROR: unable to lock database")
|
|
if reason == "" {
|
|
t.Error("reason must not be empty for non-empty stderr")
|
|
}
|
|
}
|
|
|
|
func TestTruncateStderr(t *testing.T) {
|
|
t.Run("strips ANSI codes", func(t *testing.T) {
|
|
in := "\x1b[31mERROR\x1b[0m: something failed"
|
|
got := truncateStderr(in, 500)
|
|
if strings.Contains(got, "\x1b") {
|
|
t.Errorf("ANSI codes not stripped: %q", got)
|
|
}
|
|
if !strings.Contains(got, "ERROR") {
|
|
t.Errorf("content should remain after strip: %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("normalizes CRLF to space", func(t *testing.T) {
|
|
in := "line1\r\nline2\r\nline3"
|
|
got := truncateStderr(in, 500)
|
|
// After normalization CRLF → LF → Fields() collapses to spaces
|
|
if strings.Contains(got, "\r") {
|
|
t.Errorf("CRLF not normalized: %q", got)
|
|
}
|
|
if !strings.Contains(got, "line1") || !strings.Contains(got, "line2") {
|
|
t.Errorf("content lost: %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("caps at n bytes with ellipsis", func(t *testing.T) {
|
|
in := strings.Repeat("a", 600)
|
|
got := truncateStderr(in, 500)
|
|
if len([]rune(got)) > 502 { // 500 + len("…") rune (3 bytes but 1 rune)
|
|
t.Errorf("not capped: len=%d", len(got))
|
|
}
|
|
if !strings.HasSuffix(got, "…") {
|
|
t.Errorf("missing ellipsis: %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("short string unchanged", func(t *testing.T) {
|
|
in := "short error"
|
|
got := truncateStderr(in, 500)
|
|
if got != in {
|
|
t.Errorf("short string modified: got %q, want %q", got, in)
|
|
}
|
|
})
|
|
|
|
t.Run("collapses whitespace", func(t *testing.T) {
|
|
in := "err msg\t\twith\n\ntabs"
|
|
got := truncateStderr(in, 500)
|
|
if strings.Contains(got, " ") || strings.Contains(got, "\t") || strings.Contains(got, "\n") {
|
|
t.Errorf("whitespace not collapsed: %q", got)
|
|
}
|
|
})
|
|
}
|