Files
goclaw/internal/skills/pkg_update_helpers_test.go
Duy /zuey/ 425cecb9a3 feat(packages): Phase 2b — apk update flow + pkg-helper v2 protocol (#900) (#7)
* 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
2026-05-11 15:41:27 +07:00

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)
}
})
}