feat: 跨平台命令助手 kopsc 初始实现
- 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令 - 内置 docker.clearsafe、net.ports、pkg.refreshindex - --dry-run 预览、list/explain/platform/version 辅助命令 - 纯 Go 跨平台构建脚本 scripts/build
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
// Package resolve picks the most specific registry variant for a detected
|
||||
// platform, and explains why each candidate did or did not match.
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
|
||||
)
|
||||
|
||||
// Scoring weights. A variant must match on every declared constraint; the
|
||||
// score decides among the variants that do match.
|
||||
const (
|
||||
scoreDistroExact = 3
|
||||
scoreDistroLike = 2
|
||||
scoreRuntime = 2
|
||||
)
|
||||
|
||||
// ErrNoMatch is returned when no variant fits the environment.
|
||||
var ErrNoMatch = errors.New("no matching variant")
|
||||
|
||||
// ErrAmbiguous is returned when two or more variants tie for best match.
|
||||
var ErrAmbiguous = errors.New("ambiguous match")
|
||||
|
||||
// Candidate is one variant together with the evaluation outcome.
|
||||
type Candidate struct {
|
||||
Index int
|
||||
Variant registry.Variant
|
||||
Score int
|
||||
// Matched reports whether every declared constraint was satisfied.
|
||||
Matched bool
|
||||
// Reason explains the decision in human-readable form.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Result is a successful resolution.
|
||||
type Result struct {
|
||||
Command *registry.Command
|
||||
Info platform.Info
|
||||
Variant registry.Variant
|
||||
VariantIdx int
|
||||
Score int
|
||||
Candidates []Candidate
|
||||
}
|
||||
|
||||
// NoMatchError describes why resolution failed.
|
||||
type NoMatchError struct {
|
||||
CommandID string
|
||||
Info platform.Info
|
||||
Candidates []Candidate
|
||||
}
|
||||
|
||||
func (e *NoMatchError) Error() string {
|
||||
return fmt.Sprintf("命令 %q 不支持当前平台 %s", e.CommandID, DescribeInfo(e.Info))
|
||||
}
|
||||
|
||||
// Unwrap lets callers test with errors.Is(err, ErrNoMatch).
|
||||
func (e *NoMatchError) Unwrap() error { return ErrNoMatch }
|
||||
|
||||
// AmbiguousError reports tied candidates.
|
||||
type AmbiguousError struct {
|
||||
CommandID string
|
||||
Info platform.Info
|
||||
Candidates []Candidate
|
||||
}
|
||||
|
||||
func (e *AmbiguousError) Error() string {
|
||||
return fmt.Sprintf("命令 %q 在当前平台 %s 上匹配到多个同样具体的变体", e.CommandID, DescribeInfo(e.Info))
|
||||
}
|
||||
|
||||
// Unwrap lets callers test with errors.Is(err, ErrAmbiguous).
|
||||
func (e *AmbiguousError) Unwrap() error { return ErrAmbiguous }
|
||||
|
||||
// Resolve evaluates every variant of cmd against info and returns the single
|
||||
// most specific match. The returned Result always carries all candidates so
|
||||
// callers can render an explanation.
|
||||
func Resolve(cmd *registry.Command, info platform.Info) (Result, error) {
|
||||
result := Result{Command: cmd, Info: info, VariantIdx: -1}
|
||||
var best []Candidate
|
||||
|
||||
for i, variant := range cmd.Variants {
|
||||
score, matched, reason := Evaluate(variant.Match, info)
|
||||
candidate := Candidate{
|
||||
Index: i,
|
||||
Variant: variant,
|
||||
Score: score,
|
||||
Matched: matched,
|
||||
Reason: reason,
|
||||
}
|
||||
result.Candidates = append(result.Candidates, candidate)
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(best) == 0:
|
||||
best = []Candidate{candidate}
|
||||
case betterThan(candidate, best[0]):
|
||||
best = []Candidate{candidate}
|
||||
case ties(candidate, best[0]):
|
||||
best = append(best, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(best) == 0:
|
||||
return result, &NoMatchError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates}
|
||||
case len(best) > 1:
|
||||
return result, &AmbiguousError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates}
|
||||
}
|
||||
|
||||
result.Variant = best[0].Variant
|
||||
result.VariantIdx = best[0].Index
|
||||
result.Score = best[0].Score
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveID looks a command up by ID or alias and resolves it.
|
||||
func ResolveID(reg *registry.Registry, id string, info platform.Info) (Result, error) {
|
||||
cmd, ok := reg.Lookup(id)
|
||||
if !ok {
|
||||
return Result{}, fmt.Errorf("未知命令 %q", id)
|
||||
}
|
||||
return Resolve(cmd, info)
|
||||
}
|
||||
|
||||
// Evaluate checks a single variant against the environment. It returns the
|
||||
// specificity score, whether all constraints matched, and the reasoning.
|
||||
func Evaluate(m registry.Match, info platform.Info) (score int, matched bool, reason string) {
|
||||
var notes []string
|
||||
rejected := ""
|
||||
|
||||
reject := func(note string) {
|
||||
if rejected == "" {
|
||||
rejected = note
|
||||
}
|
||||
}
|
||||
add := func(note string) { notes = append(notes, note) }
|
||||
|
||||
if m.OS != "" {
|
||||
if m.OS != info.OS {
|
||||
reject(fmt.Sprintf("os=%s 不匹配(当前 %s)", m.OS, info.OS))
|
||||
} else {
|
||||
add("os=" + m.OS)
|
||||
}
|
||||
}
|
||||
if m.Arch != "" {
|
||||
if m.Arch != info.Arch {
|
||||
reject(fmt.Sprintf("arch=%s 不匹配(当前 %s)", m.Arch, info.Arch))
|
||||
} else {
|
||||
add("arch=" + m.Arch)
|
||||
}
|
||||
}
|
||||
if m.Distro != "" {
|
||||
if m.Distro != info.Distro {
|
||||
reject(fmt.Sprintf("distro=%s 不匹配(当前 %s)", m.Distro, orUnknown(info.Distro)))
|
||||
} else {
|
||||
score += scoreDistroExact
|
||||
add(fmt.Sprintf("distro=%s (+%d)", m.Distro, scoreDistroExact))
|
||||
}
|
||||
}
|
||||
if len(m.DistroLike) > 0 {
|
||||
if !distroLikeMatches(m.DistroLike, info) {
|
||||
reject(fmt.Sprintf("ID_LIKE 要求 %s(当前 %s)", strings.Join(m.DistroLike, "|"), describeDistro(info)))
|
||||
} else {
|
||||
score += scoreDistroLike
|
||||
add(fmt.Sprintf("ID_LIKE∈{%s} (+%d)", strings.Join(m.DistroLike, ","), scoreDistroLike))
|
||||
}
|
||||
}
|
||||
if m.Runtime != "" {
|
||||
if !contains(info.Runtimes, m.Runtime) {
|
||||
reject(fmt.Sprintf("需要运行时 %s(未在 PATH 找到)", m.Runtime))
|
||||
} else {
|
||||
score += scoreRuntime
|
||||
add(fmt.Sprintf("runtime=%s (+%d)", m.Runtime, scoreRuntime))
|
||||
}
|
||||
}
|
||||
|
||||
if rejected != "" {
|
||||
return 0, false, rejected
|
||||
}
|
||||
if len(notes) == 0 {
|
||||
return 0, true, "通用变体(无平台约束, 0 分)"
|
||||
}
|
||||
return score, true, "匹配 " + strings.Join(notes, ", ")
|
||||
}
|
||||
|
||||
// runtimeRank orders runtimes by preference when two variants are otherwise
|
||||
// equally specific: podman beats docker because it is the rootless default on
|
||||
// CentOS-family hosts, and a variant with no runtime constraint ranks last.
|
||||
func runtimeRank(runtime string) int {
|
||||
switch strings.ToLower(runtime) {
|
||||
case "podman":
|
||||
return 0
|
||||
case "docker":
|
||||
return 1
|
||||
case "":
|
||||
return 3
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// betterThan reports whether a is a strictly better match than b: higher
|
||||
// score, then the runtime preference, then the more constrained variant.
|
||||
func betterThan(a, b Candidate) bool {
|
||||
if a.Score != b.Score {
|
||||
return a.Score > b.Score
|
||||
}
|
||||
if ra, rb := runtimeRank(a.Variant.Match.Runtime), runtimeRank(b.Variant.Match.Runtime); ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
return specificity(a.Variant.Match) > specificity(b.Variant.Match)
|
||||
}
|
||||
|
||||
// ties reports whether two candidates are equally good, in which case the
|
||||
// resolution is ambiguous rather than silently picking one.
|
||||
func ties(a, b Candidate) bool {
|
||||
return a.Score == b.Score &&
|
||||
runtimeRank(a.Variant.Match.Runtime) == runtimeRank(b.Variant.Match.Runtime) &&
|
||||
specificity(a.Variant.Match) == specificity(b.Variant.Match)
|
||||
}
|
||||
|
||||
// specificity counts the constraints a variant declares, so an OS-only
|
||||
// variant outranks a fully generic one at equal score.
|
||||
func specificity(m registry.Match) int {
|
||||
n := 0
|
||||
if m.OS != "" {
|
||||
n++
|
||||
}
|
||||
if m.Arch != "" {
|
||||
n++
|
||||
}
|
||||
if m.Distro != "" {
|
||||
n++
|
||||
}
|
||||
if len(m.DistroLike) > 0 {
|
||||
n++
|
||||
}
|
||||
if m.Runtime != "" {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func distroLikeMatches(want []string, info platform.Info) bool {
|
||||
current := make([]string, 0, 1+len(info.DistroLike))
|
||||
if info.Distro != "" {
|
||||
current = append(current, info.Distro)
|
||||
}
|
||||
current = append(current, info.DistroLike...)
|
||||
for _, c := range current {
|
||||
for _, w := range want {
|
||||
if strings.EqualFold(c, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contains(values []string, want string) bool {
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(v, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if s == "" {
|
||||
return "未知"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func describeDistro(info platform.Info) string {
|
||||
if info.Distro == "" && len(info.DistroLike) == 0 {
|
||||
return "未知"
|
||||
}
|
||||
if len(info.DistroLike) == 0 {
|
||||
return info.Distro
|
||||
}
|
||||
return info.Distro + " (" + strings.Join(info.DistroLike, " ") + ")"
|
||||
}
|
||||
|
||||
// DescribeInfo renders the environment compactly for error messages.
|
||||
func DescribeInfo(info platform.Info) string {
|
||||
s := info.OS + "/" + info.Arch
|
||||
if info.Distro != "" {
|
||||
s += ", distro=" + info.Distro
|
||||
}
|
||||
if len(info.Runtimes) > 0 {
|
||||
s += ", runtimes=" + strings.Join(info.Runtimes, ",")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package resolve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform"
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
|
||||
)
|
||||
|
||||
func env(osName, arch, distro string, like []string, runtimes ...string) platform.Info {
|
||||
return platform.Info{
|
||||
OS: osName,
|
||||
Arch: arch,
|
||||
Distro: distro,
|
||||
DistroLike: like,
|
||||
Runtimes: runtimes,
|
||||
}
|
||||
}
|
||||
|
||||
func builtin(t *testing.T, id string) *registry.Command {
|
||||
t.Helper()
|
||||
cmd, ok := registry.Builtin().Lookup(id)
|
||||
if !ok {
|
||||
t.Fatalf("builtin command %q missing", id)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestResolveBuiltin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
info platform.Info
|
||||
wantArgv []string
|
||||
wantScore int
|
||||
wantIdx int
|
||||
}{
|
||||
{
|
||||
name: "centos with podman and docker prefers the distro-specific podman variant",
|
||||
id: "docker.clearsafe",
|
||||
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker", "podman"),
|
||||
wantArgv: []string{"podman", "system", "prune", "-f"},
|
||||
wantScore: 5,
|
||||
wantIdx: 0,
|
||||
},
|
||||
{
|
||||
name: "centos with docker only",
|
||||
id: "docker.clearsafe",
|
||||
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker"),
|
||||
wantArgv: []string{"docker", "system", "prune", "-f"},
|
||||
wantScore: 5,
|
||||
wantIdx: 1,
|
||||
},
|
||||
{
|
||||
name: "generic linux with podman and docker prefers podman",
|
||||
id: "docker.clearsafe",
|
||||
info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker", "podman"),
|
||||
wantArgv: []string{"podman", "system", "prune", "-f"},
|
||||
wantScore: 2,
|
||||
wantIdx: 2,
|
||||
},
|
||||
{
|
||||
name: "rocky inherits the generic linux docker variant",
|
||||
id: "docker.clearsafe",
|
||||
info: env("linux", "amd64", "rocky", []string{"rhel", "centos"}, "docker"),
|
||||
wantArgv: []string{"docker", "system", "prune", "-f"},
|
||||
wantScore: 2,
|
||||
wantIdx: 3,
|
||||
},
|
||||
{
|
||||
name: "darwin docker",
|
||||
id: "docker.clearsafe",
|
||||
info: env("darwin", "arm64", "", nil, "docker"),
|
||||
wantArgv: []string{"docker", "system", "prune", "-f"},
|
||||
wantScore: 2,
|
||||
wantIdx: 4,
|
||||
},
|
||||
{
|
||||
name: "windows docker",
|
||||
id: "docker.clearsafe",
|
||||
info: env("windows", "amd64", "", nil, "docker"),
|
||||
wantArgv: []string{"docker", "system", "prune", "-f"},
|
||||
wantScore: 2,
|
||||
wantIdx: 5,
|
||||
},
|
||||
{
|
||||
name: "centos refreshes with dnf exactly",
|
||||
id: "pkg.refreshindex",
|
||||
info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}),
|
||||
wantArgv: []string{"dnf", "makecache"},
|
||||
wantScore: 3,
|
||||
wantIdx: 0,
|
||||
},
|
||||
{
|
||||
name: "rocky inherits rhel-like dnf",
|
||||
id: "pkg.refreshindex",
|
||||
info: env("linux", "amd64", "rocky", []string{"rhel"}),
|
||||
wantArgv: []string{"dnf", "makecache"},
|
||||
wantScore: 2,
|
||||
wantIdx: 1,
|
||||
},
|
||||
{
|
||||
name: "ubuntu resolves through ID_LIKE debian",
|
||||
id: "pkg.refreshindex",
|
||||
info: env("linux", "amd64", "ubuntu", []string{"debian"}),
|
||||
wantArgv: []string{"apt-get", "update"},
|
||||
wantScore: 2,
|
||||
wantIdx: 2,
|
||||
},
|
||||
{
|
||||
name: "alpine has its own distro variant",
|
||||
id: "pkg.refreshindex",
|
||||
info: env("linux", "amd64", "alpine", nil),
|
||||
wantArgv: []string{"apk", "update"},
|
||||
wantScore: 3,
|
||||
wantIdx: 3,
|
||||
},
|
||||
{
|
||||
name: "darwin homebrew",
|
||||
id: "pkg.refreshindex",
|
||||
info: env("darwin", "arm64", "", nil),
|
||||
wantArgv: []string{"brew", "update"},
|
||||
wantIdx: 4,
|
||||
},
|
||||
{
|
||||
name: "windows winget",
|
||||
id: "pkg.refreshindex",
|
||||
info: env("windows", "amd64", "", nil),
|
||||
wantArgv: []string{"winget", "source", "update"},
|
||||
wantIdx: 5,
|
||||
},
|
||||
{
|
||||
name: "linux listens with ss",
|
||||
id: "net.ports",
|
||||
info: env("linux", "amd64", "centos", []string{"rhel"}),
|
||||
wantArgv: []string{"ss", "-tulnp"},
|
||||
wantIdx: 0,
|
||||
},
|
||||
{
|
||||
name: "darwin listens with lsof",
|
||||
id: "net.ports",
|
||||
info: env("darwin", "amd64", "", nil),
|
||||
wantArgv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"},
|
||||
wantIdx: 1,
|
||||
},
|
||||
{
|
||||
name: "windows listens with netstat",
|
||||
id: "net.ports",
|
||||
info: env("windows", "amd64", "", nil),
|
||||
wantArgv: []string{"netstat", "-ano"},
|
||||
wantIdx: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := Resolve(builtin(t, tc.id), tc.info)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Variant.Argv, tc.wantArgv) {
|
||||
t.Errorf("argv = %v, want %v", got.Variant.Argv, tc.wantArgv)
|
||||
}
|
||||
if got.VariantIdx != tc.wantIdx {
|
||||
t.Errorf("variant index = %d, want %d", got.VariantIdx, tc.wantIdx)
|
||||
}
|
||||
if got.Score != tc.wantScore {
|
||||
t.Errorf("score = %d, want %d", got.Score, tc.wantScore)
|
||||
}
|
||||
if len(got.Candidates) != len(got.Command.Variants) {
|
||||
t.Errorf("candidates = %d, want %d", len(got.Candidates), len(got.Command.Variants))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNoMatch(t *testing.T) {
|
||||
info := env("linux", "amd64", "centos", []string{"rhel"}) // no container runtime on PATH
|
||||
cmd := builtin(t, "docker.clearsafe")
|
||||
|
||||
_, err := Resolve(cmd, info)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error without a container runtime")
|
||||
}
|
||||
if !errors.Is(err, ErrNoMatch) {
|
||||
t.Fatalf("error = %v, want ErrNoMatch", err)
|
||||
}
|
||||
|
||||
var noMatch *NoMatchError
|
||||
if !errors.As(err, &noMatch) {
|
||||
t.Fatalf("error type = %T, want *NoMatchError", err)
|
||||
}
|
||||
if len(noMatch.Candidates) != len(cmd.Variants) {
|
||||
t.Errorf("candidates = %d, want %d", len(noMatch.Candidates), len(cmd.Variants))
|
||||
}
|
||||
for _, c := range noMatch.Candidates {
|
||||
if c.Matched {
|
||||
t.Errorf("candidate %v should not match", c.Variant.Argv)
|
||||
}
|
||||
if c.Reason == "" {
|
||||
t.Errorf("candidate %v has no reason", c.Variant.Argv)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(err.Error(), "runtime") && !strings.Contains(err.Error(), "不支持") {
|
||||
t.Errorf("error message lacks detail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAmbiguous(t *testing.T) {
|
||||
cmd := ®istry.Command{
|
||||
ID: "demo.ambiguous",
|
||||
Summary: "two equally generic variants",
|
||||
Variants: []registry.Variant{
|
||||
{Argv: []string{"one"}},
|
||||
{Argv: []string{"two"}},
|
||||
},
|
||||
}
|
||||
_, err := Resolve(cmd, env("linux", "amd64", "", nil))
|
||||
if err == nil {
|
||||
t.Fatal("expected an ambiguity error")
|
||||
}
|
||||
if !errors.Is(err, ErrAmbiguous) {
|
||||
t.Fatalf("error = %v, want ErrAmbiguous", err)
|
||||
}
|
||||
var ambiguous *AmbiguousError
|
||||
if !errors.As(err, &ambiguous) {
|
||||
t.Fatalf("error type = %T, want *AmbiguousError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveArchMismatch(t *testing.T) {
|
||||
cmd := ®istry.Command{
|
||||
ID: "demo.arch",
|
||||
Summary: "arm64 only",
|
||||
Variants: []registry.Variant{
|
||||
{Match: registry.Match{OS: "linux", Arch: "arm64"}, Argv: []string{"arm"}},
|
||||
},
|
||||
}
|
||||
_, err := Resolve(cmd, env("linux", "amd64", "", nil))
|
||||
if !errors.Is(err, ErrNoMatch) {
|
||||
t.Fatalf("error = %v, want ErrNoMatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpecificBeatsGeneric(t *testing.T) {
|
||||
cmd := ®istry.Command{
|
||||
ID: "demo.specific",
|
||||
Summary: "specific and generic",
|
||||
Variants: []registry.Variant{
|
||||
{Argv: []string{"generic"}},
|
||||
{Match: registry.Match{OS: "linux"}, Argv: []string{"linux"}},
|
||||
},
|
||||
}
|
||||
got, err := Resolve(cmd, env("linux", "amd64", "centos", []string{"rhel"}))
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error: %v", err)
|
||||
}
|
||||
// Both score 0 here, so the runtime tier and then declaration order decide;
|
||||
// the important property is that a single variant wins.
|
||||
if len(got.Variant.Argv) == 0 {
|
||||
t.Fatal("no variant chosen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRejects(t *testing.T) {
|
||||
info := env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
match registry.Match
|
||||
want string
|
||||
}{
|
||||
{"os", registry.Match{OS: "darwin"}, "os=darwin"},
|
||||
{"arch", registry.Match{Arch: "arm64"}, "arch=arm64"},
|
||||
{"distro", registry.Match{Distro: "debian"}, "distro=debian"},
|
||||
{"distro like", registry.Match{DistroLike: []string{"debian"}}, "ID_LIKE"},
|
||||
{"runtime", registry.Match{Runtime: "podman"}, "podman"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, matched, reason := Evaluate(tc.match, info)
|
||||
if matched {
|
||||
t.Fatalf("match should fail: %+v", tc.match)
|
||||
}
|
||||
if !strings.Contains(reason, tc.want) {
|
||||
t.Fatalf("reason %q does not contain %q", reason, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user