diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a08f3d2..31f90de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,30 @@ jobs: ./vertc version ./vertc --help >/dev/null + coverage: + name: Coverage gate (Linux) + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v6 + with: + go-version-file: go.mod + cache: true + - name: Enforce aggregate Go unit coverage + run: make check-coverage COVERAGE_THRESHOLD=70.0 + - name: Retain coverage reports + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: go-unit-coverage + path: | + coverage.out + coverage.html + if-no-files-found: warn + retention-days: 14 + template-build: name: Build generated web templates runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 0895d50..7fd4ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.test *.prof *.out +coverage.html # Node (generated web projects / scaffolds run in-tree) node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6992f64..4421e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ ## 未发布 +## 0.0.6 + +_发布日期:2026-08-13_ + +### 改进 + +- RTC 文档 MCP 调用会复用 CLI 的 OpenAPI invocation User-Agent,便于下游服务识别来源并保持调用链路一致。 +- 新增 Go 测试覆盖率治理门禁,`make ci` 和公开 CI 会统一执行覆盖率检查。 + +### 测试 + +- 补充根命令行为、affordance、路径、防回归发布命令、自更新、模板渲染和 E2E 等测试覆盖,降低公开发布前的回归风险。 + ## 0.0.5 _发布日期:2026-08-12_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 790f9e2..0e7e239 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,29 @@ command or E2E test, error codes need their snapshot, and template sources need template tests. It uses the MR diff base or push base in CI, and `origin/HEAD` when run locally. +### Go unit coverage + +Run the canonical coverage workflow from the repository root: + +```bash +make coverage # writes coverage.out and coverage.html, then prints the total +make check-coverage # regenerates both reports and enforces the configured floor +``` + +The metric uses uncached atomic statement coverage for production packages in +`cmd/` and `internal/`, driven by tests in those same package trees. Subprocess +E2E tests under `tests/` remain a separate functional gate because execution in +their child CLI binary is not represented by a normal Go unit-test profile. + +CI pipelines enforce an aggregate floor of 70.0% through the same Make target. +They intentionally do not apply +one threshold to every package: command entry points, platform adapters, and +pure logic have different testability. New tests should assert observable +success, failure, boundary, dry-run, or rollback behavior rather than execute +lines only to increase the percentage. Override report paths or the local floor +when needed with `COVERAGE_PROFILE`, `COVERAGE_HTML`, and +`COVERAGE_THRESHOLD`. + ## Contribution recipes ### Add / change a command diff --git a/Makefile b/Makefile index c76be02..c77ba2c 100644 --- a/Makefile +++ b/Makefile @@ -21,12 +21,17 @@ GOLANGCI = $(shell go env GOPATH)/bin/golangci-lint GORELEASER_VERSION := v2.17.0 GORELEASER_GO_TOOLCHAIN := go1.26.4 GORELEASER = $(shell go env GOPATH)/bin/goreleaser +COVERAGE_PROFILE ?= coverage.out +COVERAGE_HTML ?= coverage.html +COVERAGE_THRESHOLD ?= 70.0 +COVERAGE_PACKAGES := ./cmd,./internal/... +COVERAGE_TEST_PACKAGES := ./cmd ./internal/... # Repository-specific CI extensions may add prerequisites without changing the # portable public build definition. -include .make/ci-extra.mk -.PHONY: build test test-node vet fmt fmt-check lint check-error-codes skills-check check-change-contract check-change-contract-test check-release-files prepare-release-version check-release-version release-tools release-snapshot release-snapshot-test toolchain-test ci ci-go e2e tools install clean +.PHONY: build test test-node coverage check-coverage check-coverage-test vet fmt fmt-check lint check-error-codes skills-check check-change-contract check-change-contract-test check-release-files prepare-release-version check-release-version release-tools release-snapshot release-snapshot-test toolchain-test ci ci-go e2e tools install clean build: go build -ldflags "$(LDFLAGS)" -o bin/$(BIN) . @@ -37,6 +42,18 @@ test: test-node: node --test scripts/*.test.js +# Canonical unit-coverage scope. Subprocess E2E tests remain a separate gate. +coverage: + go test -count=1 -covermode=atomic -coverpkg=$(COVERAGE_PACKAGES) -coverprofile="$(COVERAGE_PROFILE)" $(COVERAGE_TEST_PACKAGES) + go tool cover -func="$(COVERAGE_PROFILE)" | tail -n 1 + go tool cover -html="$(COVERAGE_PROFILE)" -o "$(COVERAGE_HTML)" + +check-coverage: coverage + ./scripts/check-coverage.sh "$(COVERAGE_PROFILE)" "$(COVERAGE_THRESHOLD)" + +check-coverage-test: + ./scripts/check-coverage_test.sh + vet: go vet ./... @@ -93,7 +110,7 @@ toolchain-test: ./scripts/toolchain_test.sh # The Go-only gate used by CI jobs whose image intentionally has no Node.js. -ci-go: toolchain-test fmt-check vet lint test check-error-codes check-change-contract-test check-change-contract build +ci-go: toolchain-test fmt-check vet lint test check-coverage-test check-error-codes check-change-contract-test check-change-contract build # The complete configured local gate. ci: ci-go $(CI_EXTRA_TARGETS) test-node diff --git a/cmd/coverage_behavior_test.go b/cmd/coverage_behavior_test.go new file mode 100644 index 0000000..850fa23 --- /dev/null +++ b/cmd/coverage_behavior_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Beijing Volcano Engine Technology Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/volcengine/VolcEngineRTC_CLI/internal/config" + "github.com/volcengine/VolcEngineRTC_CLI/internal/errs" + "github.com/volcengine/VolcEngineRTC_CLI/internal/meta" +) + +func TestConfigCommandsReadWriteDryRunAndValidate(t *testing.T) { + oldDryRun, oldFormat := flagDryRun, flagFormat + t.Cleanup(func() { flagDryRun, flagFormat = oldDryRun, oldFormat }) + flagFormat = "json" + t.Setenv("RTC_APP_ID", "app123456789012345678901") + dir := t.TempDir() + t.Chdir(dir) + cfg := config.Default("demo", "voice-agent", "web") + if err := config.Save(cfg, meta.ConfigFileName); err != nil { + t.Fatal(err) + } + + output := captureCommandStdout(t, func() error { return newConfigShowCmd().Execute() }) + if !strings.Contains(output, `"Name": "demo"`) { + t.Fatalf("config show output missing project name: %s", output) + } + + get := newConfigGetCmd() + get.SetArgs([]string{"rtc.room_id"}) + output = captureCommandStdout(t, get.Execute) + if !strings.Contains(output, `"value": "room-01"`) { + t.Fatalf("config get output missing room id: %s", output) + } + + flagDryRun = true + dryRun := newConfigSetCmd() + dryRun.SetArgs([]string{"rtc.room_id", "room-preview"}) + output = captureCommandStdout(t, dryRun.Execute) + if !strings.Contains(output, `"written": "false"`) { + t.Fatalf("config dry-run output=%s", output) + } + loaded, _, err := config.LoadNearest(".") + if err != nil { + t.Fatal(err) + } + if loaded.RTC.RoomID != "room-01" { + t.Fatalf("dry-run persisted room id %q", loaded.RTC.RoomID) + } + + flagDryRun = false + set := newConfigSetCmd() + set.SetArgs([]string{"rtc.room_id", "room-real"}) + output = captureCommandStdout(t, set.Execute) + if !strings.Contains(output, `"written": "true"`) { + t.Fatalf("config set output=%s", output) + } + loaded, _, err = config.LoadNearest(".") + if err != nil || loaded.RTC.RoomID != "room-real" { + t.Fatalf("persisted room id=%q err=%v", loaded.RTC.RoomID, err) + } + + output = captureCommandStdout(t, func() error { return newConfigValidateCmd().Execute() }) + if !strings.Contains(output, `"ok": true`) { + t.Fatalf("valid config report=%s", output) + } + + loaded.Project.Name = "" + if err := config.Save(loaded, meta.ConfigFileName); err != nil { + t.Fatal(err) + } + var validateErr error + output = captureCommandStdout(t, func() error { + validateErr = newConfigValidateCmd().Execute() + return nil + }) + typed, ok := errs.As(validateErr) + if !ok || typed.Code != "vertc.config.missing_field" || !typed.IsReported() { + t.Fatalf("validate error=%v", validateErr) + } + if !strings.Contains(output, `"field": "project.name"`) { + t.Fatalf("invalid config report missing finding: %s", output) + } +} + +func TestExplainErrorCommandCoversKnownAndUnknownCodes(t *testing.T) { + oldFormat := flagFormat + t.Cleanup(func() { flagFormat = oldFormat }) + flagFormat = "json" + + known := newExplainErrorCmd() + known.SetArgs([]string{"INVALID_TOKEN"}) + output := captureCommandStdout(t, known.Execute) + if !strings.Contains(output, `"found": true`) || !strings.Contains(output, `"doctor_check": "token.valid"`) { + t.Fatalf("known code output=%s", output) + } + + unknown := newExplainErrorCmd() + unknown.SetArgs([]string{"NOT_A_REAL_CODE"}) + var commandErr error + output = captureCommandStdout(t, func() error { + commandErr = unknown.Execute() + return nil + }) + typed, ok := errs.As(commandErr) + if !ok || typed.Code != "vertc.explain.unknown_code" || !typed.IsReported() { + t.Fatalf("unknown code error=%v", commandErr) + } + if !strings.Contains(output, `"found": false`) || !strings.Contains(output, `"query": "NOT_A_REAL_CODE"`) { + t.Fatalf("unknown code output=%s", output) + } +} + +func TestEmitTemplateListUsesRegistry(t *testing.T) { + oldFormat := flagFormat + t.Cleanup(func() { flagFormat = oldFormat }) + flagFormat = "json" + output := captureCommandStdout(t, emitTemplateList) + for _, want := range []string{`"scene": "voice-agent"`, `"platform": "web"`, `"available": true`, `"sdk": "@volcengine/rtc@4.68.1"`} { + if !strings.Contains(output, want) { + t.Fatalf("template list output missing %q: %s", want, output) + } + } +} + +func TestInitListRoutesToTemplateRegistryWithoutProjectWrites(t *testing.T) { + oldFormat := flagFormat + t.Cleanup(func() { flagFormat = oldFormat }) + flagFormat = "json" + dir := t.TempDir() + t.Chdir(dir) + command := newInitCmd() + command.SetArgs([]string{"--list"}) + output := captureCommandStdout(t, command.Execute) + if !strings.Contains(output, `"scene": "voice-agent"`) { + t.Fatalf("init --list output=%s", output) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("init --list wrote project files: %v", entries) + } +} + +func captureCommandStdout(t *testing.T, run func() error) string { + t.Helper() + previous := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + defer func() { os.Stdout = previous }() + if err := run(); err != nil { + _ = writer.Close() + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = previous + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/cmd/root_test.go b/cmd/root_test.go index 48f0a31..c869777 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -4,11 +4,16 @@ package cmd import ( + "bytes" "io" "os" "sort" + "strings" "testing" + "github.com/volcengine/VolcEngineRTC_CLI/internal/config" + "github.com/volcengine/VolcEngineRTC_CLI/internal/doctor" + "github.com/volcengine/VolcEngineRTC_CLI/internal/errs" "github.com/volcengine/VolcEngineRTC_CLI/internal/output" ) @@ -145,6 +150,227 @@ func TestRemovedCommandsRejected(t *testing.T) { } } +func TestCommandSuggestionAndEditDistance(t *testing.T) { + root := NewRootCmd() + if got := nearestCommand(root, "doctr"); got != "doctor" { + t.Fatalf("nearestCommand(doctr) = %q, want doctor", got) + } + if got := nearestCommand(root, "config"); got == "config" { + t.Fatalf("hidden command must not be suggested, got %q", got) + } + if got := nearestCommand(root, "completely-unrelated"); got != "" { + t.Fatalf("distant command unexpectedly suggested: %q", got) + } + for _, tc := range []struct { + a, b string + want int + }{ + {a: "doctor", b: "doctor", want: 0}, + {a: "doctr", b: "doctor", want: 1}, + {a: "", b: "init", want: 4}, + {a: "技能", b: "技", want: 1}, + } { + if got := levenshtein(tc.a, tc.b); got != tc.want { + t.Errorf("levenshtein(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestUnknownCommandErrorIsTypedAndActionable(t *testing.T) { + typed := unknownCommandError(NewRootCmd(), &plainError{message: `unknown command "doctr" for "vertc"`}) + if typed.Code != "vertc.cli.unknown_command" || !strings.Contains(typed.Hint, "doctor") { + t.Fatalf("unexpected typed error: %+v", typed) + } + typed = unknownCommandError(NewRootCmd(), &plainError{message: "unknown command"}) + if typed.Code != "vertc.cli.unknown_command" || !strings.Contains(typed.Hint, "--help") { + t.Fatalf("unexpected fallback error: %+v", typed) + } +} + +type plainError struct{ message string } + +func (e *plainError) Error() string { return e.message } + +func TestVersionProbeAndDryRunRefreshPolicy(t *testing.T) { + if !isVersionProbe([]string{"--format", "json", "--version"}) || !isVersionProbe([]string{"version"}) { + t.Fatal("version probes were not recognized") + } + if isVersionProbe([]string{"doctor"}) { + t.Fatal("doctor must not be classified as a version probe") + } + oldArgs, oldDryRun := os.Args, flagDryRun + t.Cleanup(func() { os.Args, flagDryRun = oldArgs, oldDryRun }) + os.Args = []string{"vertc", "version"} + root := NewRootCmd() + flagDryRun = true + if shouldRefreshAfterCommand(root) { + t.Fatal("dry-run must suppress detached refresh") + } +} + +func TestInitCommandValidationStopsBeforeSideEffects(t *testing.T) { + oldDryRun, oldFormat := flagDryRun, flagFormat + t.Cleanup(func() { flagDryRun, flagFormat = oldDryRun, oldFormat }) + flagDryRun, flagFormat = false, "json" + + tests := []struct { + args []string + code string + }{ + {args: nil, code: "vertc.cli.invalid_flag"}, + {args: []string{"--scene", "missing", "--platform", "web"}, code: "vertc.template.not_found"}, + {args: []string{"--scene", "voice-agent", "--platform", "web", "--room-id", "bad\nroom"}, code: "vertc.config.invalid_identity"}, + } + for _, tc := range tests { + t.Run(strings.Join(tc.args, " "), func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + command := newInitCmd() + command.SetArgs(tc.args) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + err := command.Execute() + typed, ok := errs.As(err) + if !ok || typed.Code != tc.code { + t.Errorf("init %q error = %v, want %s", tc.args, err, tc.code) + } + entries, readErr := os.ReadDir(dir) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatalf("validation failure wrote project files: %v", entries) + } + }) + } +} + +func TestCommandPrettyResultsExposeRecoveryContext(t *testing.T) { + var buf bytes.Buffer + initResult{ + Scene: "voice-agent", Platform: "web", Dir: "demo", RoomID: "room", UserID: "user", AppID: "app", + DryRun: true, Files: []string{"vertc.config.yaml"}, Next: []string{"vertc dev"}, + }.Pretty(&buf) + for _, want := range []string{"would generate (dry-run)", "identity: room=room user=user app=app", "demo/vertc.config.yaml", "vertc dev"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("init pretty output missing %q:\n%s", want, buf.String()) + } + } + + buf.Reset() + explainResult{Query: "999", Hint: "run doctor"}.Pretty(&buf) + if !strings.Contains(buf.String(), "not in the offline knowledge base") || !strings.Contains(buf.String(), "run doctor") { + t.Fatalf("unknown-code output lacks recovery context: %s", buf.String()) + } + buf.Reset() + explainResult{Found: true, Code: "1001", Enum: "TOKEN", Domain: "web", Meaning: "expired", Fix: "refresh", DoctorCheck: "auth", Source: "official", Verified: "no"}.Pretty(&buf) + for _, want := range []string{"1001 (TOKEN) [web]", "fix: refresh", "related doctor check", "source: official", "未验证"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("known-code output missing %q:\n%s", want, buf.String()) + } + } +} + +func TestTemplateListPrettyAndInitHelpers(t *testing.T) { + var buf bytes.Buffer + templateListResult{Templates: []templateEntry{ + {Scene: "voice-agent", Platform: "web", Title: "Voice", Available: true, Default: true}, + {Scene: "future", Platform: "web", Title: "Future"}, + }}.Pretty(&buf) + for _, want := range []string{"[available]", "(default)", "[reserved]"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("template list missing %q:\n%s", want, buf.String()) + } + } + + if got := voiceAgentNextSteps("voice-agent"); len(got) != 2 || !strings.Contains(got[0], "auth login") { + t.Fatalf("voice-agent next steps = %q", got) + } + if got := voiceAgentNextSteps("other"); len(got) != 4 || !strings.Contains(got[0], "env write") { + t.Fatalf("generic next steps = %q", got) + } + dir := t.TempDir() + if nonEmptyDir(dir) || nonEmptyDir(dir+"-missing") { + t.Fatal("empty or missing directory reported non-empty") + } + if err := os.WriteFile(dir+"/entry", []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if !nonEmptyDir(dir) { + t.Fatal("directory containing a file reported empty") + } +} + +func TestCountErrorsCountsOnlyErrorSeverity(t *testing.T) { + report := config.Report{Findings: []config.Finding{ + {Severity: config.SevError}, {Severity: config.SevWarn}, {Severity: config.SevError}, + }} + if got := countErrors(report); got != 2 { + t.Fatalf("countErrors = %d, want 2", got) + } +} + +func TestLifecyclePrettyResultsPreserveStatusAndActions(t *testing.T) { + var buf bytes.Buffer + doctorReport{doctor.Report{ + Checks: []doctor.Check{ + {Status: doctor.PASS, Title: "ready", Detail: "ok", Hint: "must stay hidden"}, + {Status: doctor.WARN, Title: "warning", Detail: "attention", Hint: "fix warning"}, + {Status: doctor.FAIL, Title: "failure", Detail: "blocked", Hint: "fix failure"}, + {Status: doctor.SKIP, Title: "skipped", Detail: "disabled"}, + {Status: doctor.UNKNOWN, Title: "unknown", Detail: "no evidence"}, + }, + Passed: 1, Warned: 1, Failed: 1, Skipped: 1, Unknown: 1, + }}.Pretty(&buf) + for _, tc := range []struct { + marker string + status doctor.Status + }{ + {marker: "✓", status: doctor.PASS}, + {marker: "!", status: doctor.WARN}, + {marker: "✗", status: doctor.FAIL}, + {marker: "-", status: doctor.SKIP}, + {marker: "?", status: doctor.UNKNOWN}, + } { + want := tc.marker + " [" + string(tc.status) + "]" + if !strings.Contains(buf.String(), want) { + t.Fatalf("doctor output missing %q:\n%s", want, buf.String()) + } + } + for _, want := range []string{"hint: fix warning", "1 passed, 1 warned"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("doctor output missing %q:\n%s", want, buf.String()) + } + } + if strings.Contains(buf.String(), "must stay hidden") { + t.Fatalf("PASS hint should not be rendered: %s", buf.String()) + } + + buf.Reset() + updateResult{Current: "1.0.0", Latest: "2.0.0", Status: "manual_required", SkillsError: "offline", ManualCommand: "npm install"}.Pretty(&buf) + for _, want := range []string{"1.0.0 → 2.0.0", "skills: sync failed: offline", "manual update: npm install"} { + if !strings.Contains(buf.String(), want) { + t.Fatalf("update output missing %q:\n%s", want, buf.String()) + } + } + buf.Reset() + updateResult{SkillsSynced: true}.Pretty(&buf) + if !strings.Contains(buf.String(), "skills: synchronized") { + t.Fatalf("sync success missing: %s", buf.String()) + } + buf.Reset() + updateResult{SkillsStatus: "stale"}.Pretty(&buf) + if !strings.Contains(buf.String(), "skills: stale") { + t.Fatalf("skills status missing: %s", buf.String()) + } + + buf.Reset() + versionInfo{Name: "vertc", Version: "1.2.3", Commit: "abc", BuildDate: "today"}.Pretty(&buf) + if got := buf.String(); got != "vertc 1.2.3 (commit abc, built today)\n" { + t.Fatalf("version output=%q", got) + } +} + func equalStringSlice(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/affordance/affordance_test.go b/internal/affordance/affordance_test.go new file mode 100644 index 0000000..3d5de00 --- /dev/null +++ b/internal/affordance/affordance_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Beijing Volcano Engine Technology Ltd. +// SPDX-License-Identifier: MIT + +package affordance + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestAttachRoundTripsMetadataAndExtendsOwnHelp(t *testing.T) { + want := Affordance{ + When: []string{"starting"}, Avoid: []string{"stopping"}, + Prereq: []string{"configured"}, Examples: []string{"vertc demo"}, + } + cmd := &cobra.Command{Use: "demo"} + var output bytes.Buffer + cmd.SetOut(&output) + Attach(cmd, want) + + got, ok := Get(cmd) + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("Get() = %+v, %t; want %+v", got, ok, want) + } + if err := cmd.Help(); err != nil { + t.Fatal(err) + } + for _, text := range []string{"Affordance:", "When to use:", "Avoid when:", "Prerequisites:", "Examples:", "vertc demo"} { + if !strings.Contains(output.String(), text) { + t.Fatalf("help missing %q:\n%s", text, output.String()) + } + } +} + +func TestGetRejectsMissingOrMalformedAnnotation(t *testing.T) { + if _, ok := Get(&cobra.Command{}); ok { + t.Fatal("command without annotations unexpectedly has an affordance") + } + cmd := &cobra.Command{Annotations: map[string]string{annotationKey: "malformed\nwhen\tvalid\nunknown\tignored\n"}} + got, ok := Get(cmd) + if !ok || !reflect.DeepEqual(got.When, []string{"valid"}) { + t.Fatalf("decoded malformed annotation = %+v, %t", got, ok) + } +} diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go index 1b2c955..03f7b21 100644 --- a/internal/paths/paths_test.go +++ b/internal/paths/paths_test.go @@ -35,3 +35,25 @@ func TestWriteFileAtomic(t *testing.T) { t.Fatalf("state file must be private: info=%v", info) } } + +func TestStateDirUsesPlatformConfigWhenNotOverridden(t *testing.T) { + t.Setenv("VERTC_STATE_DIR", "") + got := StateDir() + if got == "" || filepath.Base(got) != "vertc" { + t.Fatalf("StateDir() = %q, want platform config path ending in vertc", got) + } +} + +func TestWriteFileAtomicReplacesExistingContent(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + if err := WriteFileAtomic(path, []byte("old")); err != nil { + t.Fatal(err) + } + if err := WriteFileAtomic(path, []byte("new")); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "new" { + t.Fatalf("replacement data=%q err=%v", data, err) + } +} diff --git a/internal/releasecmd/main_test.go b/internal/releasecmd/main_test.go index fd10c02..30200eb 100644 --- a/internal/releasecmd/main_test.go +++ b/internal/releasecmd/main_test.go @@ -4,10 +4,14 @@ package main import ( + "errors" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/volcengine/VolcEngineRTC_CLI/internal/releasecontract" ) func commandFixture(t *testing.T) string { @@ -86,3 +90,99 @@ func TestSourceVersionCheckRejectsPlaceholderChangelogEntry(t *testing.T) { t.Fatalf("error=%v", err) } } + +func TestReleaseCommandArgumentValidation(t *testing.T) { + tests := []struct { + name string + run func() error + want string + }{ + {name: "source required", run: func() error { return sourceVersion(nil) }, want: "requires --repo and --version"}, + {name: "source positional", run: func() error { + return sourceVersion([]string{"--repo", commandFixture(t), "--version", "1.2.3", "extra"}) + }, want: "no positional"}, + {name: "resolve invalid", run: func() error { return resolve([]string{"--stability", "invalid", "--publication", "none"}) }, want: "stability"}, + {name: "prepare required", run: func() error { return prepare(nil) }, want: "prepare requires"}, + {name: "verify required", run: func() error { return verify(nil) }, want: "verify requires"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.run() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error=%v, want containing %q", err, tc.want) + } + }) + } + missingManifest := filepath.Join(t.TempDir(), "missing.json") + if err := manifestVersion([]string{"--manifest", missingManifest}); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing manifest error=%v, want os.ErrNotExist", err) + } +} + +func TestResolveAndManifestVersionPrintCanonicalVersion(t *testing.T) { + got := captureStdout(t, func() error { + return resolve([]string{"--stability", "stable", "--publication", "public", "--version", "v1.2.3"}) + }) + if got != "1.2.3\n" { + t.Fatalf("resolve output=%q", got) + } + + manifestPath := filepath.Join(t.TempDir(), "manifest.json") + manifest := releasecontract.Manifest{ + Identity: releasecontract.Identity{Version: "2.3.4"}, + SourceRoot: t.TempDir(), + Skills: []releasecontract.Skill{{Name: "sample", Path: "skills/sample/SKILL.md"}}, + Targets: []releasecontract.Target{{GOOS: "linux", GOARCH: "amd64"}}, + PackageName: "fixture", + ChecksumFile: "checksums.txt", + } + if err := releasecontract.WriteManifest(manifestPath, manifest); err != nil { + t.Fatal(err) + } + got = captureStdout(t, func() error { return manifestVersion([]string{"--manifest", manifestPath}) }) + if got != "2.3.4\n" { + t.Fatalf("manifest-version output=%q", got) + } +} + +func TestVerifyReadsManifestBeforeArtifactValidation(t *testing.T) { + manifestPath := filepath.Join(t.TempDir(), "malformed.json") + if err := os.WriteFile(manifestPath, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + err := verify([]string{ + "--manifest", manifestPath, + "--artifacts", filepath.Join(t.TempDir(), "missing-artifacts"), + "--checksums", filepath.Join(t.TempDir(), "missing-checksums.txt"), + }) + if err == nil || !strings.Contains(err.Error(), "read release manifest") { + t.Fatalf("error=%v, want manifest read failure before artifact validation", err) + } +} + +func captureStdout(t *testing.T, run func() error) string { + t.Helper() + previous := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + t.Cleanup(func() { os.Stdout = previous }) + if err := run(); err != nil { + _ = writer.Close() + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = previous + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 3768986..8acb757 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -8,6 +8,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -242,3 +243,73 @@ func TestSyncSkillsFailureDoesNotAdvanceVersion(t *testing.T) { t.Fatalf("sync failure advanced state: %s", state) } } + +func TestDetectInstallMethodPropagatesExecutableFailure(t *testing.T) { + t.Cleanup(resetOverrides) + ExecutableOverride = func() (string, error) { return "", errors.New("executable unavailable") } + method, path, err := DetectInstallMethod() + if method != InstallManual || path != "" || err == nil || !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("method=%s path=%q err=%v", method, path, err) + } +} + +func TestBackupLifecycleHandlesAbsentCurrentBinary(t *testing.T) { + binary := filepath.Join(t.TempDir(), "vertc") + if HasBackup(binary) { + t.Fatal("backup unexpectedly exists") + } + if err := RollbackBinary(binary); err != nil { + t.Fatalf("rollback without backup must be idempotent: %v", err) + } + if err := os.WriteFile(binary+".old", []byte("old"), 0o700); err != nil { + t.Fatal(err) + } + if !HasBackup(binary) { + t.Fatal("backup was not detected") + } + if err := RollbackBinary(binary); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(binary); err != nil || string(data) != "old" { + t.Fatalf("restored data=%q err=%v", data, err) + } +} + +func TestVerifyBinaryOutputContract(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-specific") + } + dir := t.TempDir() + write := func(name, body string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + return path + } + for _, tc := range []struct { + name, body, expected, wantError string + }{ + {name: "ok", body: `echo "vertc v1.2.3"`, expected: "1.2.3"}, + {name: "mismatch", body: `echo "vertc 2.0.0"`, expected: "1.2.3", wantError: "expected 1.2.3"}, + {name: "short", body: `echo "vertc"`, expected: "1.2.3", wantError: "unexpected version output"}, + {name: "failure", body: `echo "broken"; exit 4`, expected: "1.2.3", wantError: "broken"}, + } { + err := VerifyBinary(context.Background(), tc.expected, write(tc.name, tc.body)) + if tc.wantError == "" && err != nil { + t.Errorf("%s: unexpected error: %v", tc.name, err) + } + if tc.wantError != "" && (err == nil || !strings.Contains(err.Error(), tc.wantError)) { + t.Errorf("%s: error=%v, want containing %q", tc.name, err, tc.wantError) + } + } +} + +func TestRunNpmInstallRejectsPathOutsideNpmPrefix(t *testing.T) { + t.Cleanup(resetOverrides) + err := RunNpmInstall(context.Background(), "1.2.3", filepath.Join(t.TempDir(), "vertc")) + if err == nil || !strings.Contains(err.Error(), "cannot derive npm prefix") { + t.Fatalf("error=%v", err) + } +} diff --git a/internal/template/render_test.go b/internal/template/render_test.go index 98813cb..b350816 100644 --- a/internal/template/render_test.go +++ b/internal/template/render_test.go @@ -42,3 +42,24 @@ func TestWriteMaterializes(t *testing.T) { t.Fatalf("expected src/main.js written: %v", err) } } + +func TestRenderAvailableTemplateWithMissingEmbeddedRootIsTyped(t *testing.T) { + tmpl := Template{Scene: "test", Platform: "web", Available: true, dir: "missing"} + _, err := Render(tmpl, config.Default("x", "test", "web")) + typed, ok := errs.As(err) + if !ok || typed.Code != "vertc.template.render_failed" { + t.Fatalf("error=%v, want vertc.template.render_failed", err) + } +} + +func TestWriteRejectsParentThatIsAFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "src"), []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + err := Write(dir, []RenderedFile{{Path: "src/main.js", Content: "x"}}) + typed, ok := errs.As(err) + if !ok || typed.Code != "vertc.template.render_failed" { + t.Fatalf("error=%v, want vertc.template.render_failed", err) + } +} diff --git a/internal/template/taskfile_test.go b/internal/template/taskfile_test.go index 9120279..45e1f07 100644 --- a/internal/template/taskfile_test.go +++ b/internal/template/taskfile_test.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/volcengine/VolcEngineRTC_CLI/internal/errs" ) func TestLoadTaskfileV1KeepsLegacyRuntime(t *testing.T) { @@ -76,6 +78,20 @@ runtime: } } +func TestLoadTaskfileReportsMissingAndMalformedContracts(t *testing.T) { + _, err := LoadTaskfile(t.TempDir()) + typed, ok := errs.As(err) + if !ok || typed.Code != "vertc.template.not_found" { + t.Fatalf("missing taskfile error=%v", err) + } + dir := writeTaskfile(t, "version: [not-an-int]\n") + _, err = LoadTaskfile(dir) + typed, ok = errs.As(err) + if !ok || typed.Code != "vertc.template.render_failed" { + t.Fatalf("malformed taskfile error=%v", err) + } +} + func writeTaskfile(t *testing.T, contents string) string { t.Helper() dir := t.TempDir() diff --git a/internal/template/voiceagent_test.go b/internal/template/voiceagent_test.go index 55bda29..282be13 100644 --- a/internal/template/voiceagent_test.go +++ b/internal/template/voiceagent_test.go @@ -26,3 +26,22 @@ func TestVoiceAgentTemplateAvailableAndDefault(t *testing.T) { t.Fatal("voice-call/web must not be registered") } } + +func TestRegistryReturnsDefensiveCopiesAndAvailableEntries(t *testing.T) { + all := List() + available := Available() + if len(all) == 0 || len(available) == 0 { + t.Fatal("published registry must expose an available template") + } + for _, tmpl := range available { + if !tmpl.Available { + t.Fatalf("Available returned reserved template: %+v", tmpl) + } + } + originalScene := all[0].Scene + all[0].Scene = "mutated" + again := List() + if again[0].Scene != originalScene { + t.Fatalf("List exposed registry backing storage: %+v", again[0]) + } +} diff --git a/internal/topicdocs/client.go b/internal/topicdocs/client.go index 106bd84..e1d2f40 100644 --- a/internal/topicdocs/client.go +++ b/internal/topicdocs/client.go @@ -20,6 +20,7 @@ import ( "github.com/volcengine/VolcEngineRTC_CLI/internal/errs" "github.com/volcengine/VolcEngineRTC_CLI/internal/meta" + "github.com/volcengine/VolcEngineRTC_CLI/internal/telemetry" ) const ( @@ -83,6 +84,10 @@ func newClient(endpoint, version string, httpClient *http.Client, options ...Opt return nil, errs.New("vertc.docs.invalid_argument", errs.TypeValidation, "CLI version is empty").WithParam("version") } + userAgent := meta.UserAgentProduct + "/" + version + if invocationUserAgent, ok := telemetry.GetInvocationUserAgent(); ok { + userAgent = invocationUserAgent + } if httpClient == nil { transport := http.DefaultTransport.(*http.Transport).Clone() httpClient = &http.Client{ @@ -95,7 +100,7 @@ func newClient(endpoint, version string, httpClient *http.Client, options ...Opt c := &Client{ endpoint: endpoint, version: version, - userAgent: meta.UserAgentProduct + "/" + version, + userAgent: userAgent, http: httpClient, nextID: 1, sleep: func(ctx context.Context, d time.Duration) error { diff --git a/internal/topicdocs/client_test.go b/internal/topicdocs/client_test.go index bd39c19..1b4eb0a 100644 --- a/internal/topicdocs/client_test.go +++ b/internal/topicdocs/client_test.go @@ -39,6 +39,8 @@ type mcpFixture struct { mu sync.Mutex requests []observedRequest cleanupCount int + cleanupUA string + cleanupHead http.Header } func standardTools() []toolDescription { @@ -55,6 +57,8 @@ func (f *mcpFixture) serveHTTP(w http.ResponseWriter, r *http.Request) { defer f.mu.Unlock() if r.Method == http.MethodDelete { f.cleanupCount++ + f.cleanupUA = r.UserAgent() + f.cleanupHead = r.Header.Clone() if r.Header.Get(sessionIDHeader) != f.issuedID { http.Error(w, "missing session", http.StatusBadRequest) return @@ -196,6 +200,10 @@ func TestSearchLifecycleAndNormalization(t *testing.T) { if fixture.cleanupCount != 1 { t.Fatalf("cleanup count = %d", fixture.cleanupCount) } + if fixture.cleanupUA != "vertc/9.8.7" { + t.Fatalf("cleanup user agent = %q", fixture.cleanupUA) + } + assertHeadersAllowlisted(t, fixture.cleanupHead) } func TestFetchPreservesBytesAndMapsNotFound(t *testing.T) { diff --git a/package.json b/package.json index 9d82034..387f2d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@volcengine/rtc-cli", - "version": "0.0.5", + "version": "0.0.6", "description": "VolcEngine RTC developer-workflow CLI", "bin": { "vertc": "scripts/run.js" diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..f799e4d --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,69 @@ +#!/bin/sh + +set -eu + +profile=${1:-coverage.out} +threshold=${2:-70.0} + +die() { + echo "coverage-gate: $*" >&2 + exit 2 +} + +case "$threshold" in + ''|*[!0-9.]*) die "threshold must be a percentage from 0 through 100: $threshold" ;; +esac + +if ! awk -v value="$threshold" 'BEGIN { + if (value !~ /^[0-9]+([.][0-9]+)?$/ || value < 0 || value > 100) exit 1 +}' &1); then + echo "$report" >&2 + die "cannot summarize coverage profile: $profile" +fi + +coverage=$(printf '%s\n' "$report" | awk '$1 == "total:" { + value = $3 + sub(/%$/, "", value) + print value +}') + +if ! awk -v value="$coverage" 'BEGIN { + if (value !~ /^[0-9]+([.][0-9]+)?$/ || value < 0 || value > 100) exit 1 +}' = required) }'; then + status=PASS + exit_code=0 +else + status=FAIL + exit_code=1 +fi + +message="coverage-gate: $status actual=$coverage% required=$threshold%" +if [ "$exit_code" -eq 0 ]; then + echo "$message" +else + echo "$message" >&2 +fi + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Go unit coverage" + echo + echo "| Status | Actual | Required |" + echo "| --- | ---: | ---: |" + echo "| $status | $coverage% | $threshold% |" + echo + echo "Scope: \`cmd\` and \`internal\` production packages; subprocess E2E coverage is tracked separately." + } >> "$GITHUB_STEP_SUMMARY" +fi + +exit "$exit_code" diff --git a/scripts/check-coverage_test.sh b/scripts/check-coverage_test.sh new file mode 100755 index 0000000..dd86215 --- /dev/null +++ b/scripts/check-coverage_test.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +checker="$repo_root/scripts/check-coverage.sh" +fixture_dir=$(mktemp -d) +trap 'rm -rf "$fixture_dir"' EXIT HUP INT TERM + +source_file="$fixture_dir/sample.go" +profile="$fixture_dir/coverage.out" +summary="$fixture_dir/summary.md" + +sed 's/^+//' > "$source_file" <<'EOF' ++package sample ++ ++func covered() int { ++ return 1 ++} ++ ++func uncovered() int { ++ return 2 ++} +EOF + +sed "s|SOURCE|$source_file|g" > "$profile" <<'EOF' +mode: atomic +SOURCE:3.20,5.2 7 1 +SOURCE:7.22,9.2 3 0 +EOF + +GITHUB_STEP_SUMMARY="$summary" "$checker" "$profile" 70.0 >/dev/null +grep -Fq '| PASS | 70.0% | 70.0% |' "$summary" + +if "$checker" "$profile" 70.1 >/dev/null 2>&1; then + echo "check-coverage-test: below-threshold profile unexpectedly passed" >&2 + exit 1 +fi + +if "$checker" "$fixture_dir/missing.out" 70.0 >/dev/null 2>&1; then + echo "check-coverage-test: missing profile unexpectedly passed" >&2 + exit 1 +fi + +sed 's/^+//' > "$fixture_dir/malformed.out" <<'EOF' ++not a Go coverage profile +EOF +if "$checker" "$fixture_dir/malformed.out" 70.0 >/dev/null 2>&1; then + echo "check-coverage-test: malformed profile unexpectedly passed" >&2 + exit 1 +fi + +for invalid in -1 100.1 nope 1.2.3; do + if "$checker" "$profile" "$invalid" >/dev/null 2>&1; then + echo "check-coverage-test: invalid threshold unexpectedly passed: $invalid" >&2 + exit 1 + fi +done + +echo "check-coverage-test: passed" diff --git a/skills/byted-interactai-guide/SKILL.md b/skills/byted-interactai-guide/SKILL.md index 4121d93..7784aa6 100644 --- a/skills/byted-interactai-guide/SKILL.md +++ b/skills/byted-interactai-guide/SKILL.md @@ -1,7 +1,7 @@ --- name: byted-interactai-guide description: 解释火山 AI 音视频互动的产品能力、适用边界与最新官方文档;并帮助用户搭建、运行和分阶段排查最小 InteractAI VoiceChat Web Demo。用户询问产品支持情况、能力清单、接入方案或运行故障时使用。 -version: "0.0.5" +version: "0.0.6" --- # InteractAI Guide — 能力、接入与排障薄路由 diff --git a/tests/docs_e2e_test.go b/tests/docs_e2e_test.go index f3567f2..0bc48e5 100644 --- a/tests/docs_e2e_test.go +++ b/tests/docs_e2e_test.go @@ -18,7 +18,7 @@ func TestDocsCommandsAgainstScriptedMCP(t *testing.T) { t.Fatal(err) } - search := run(t, dir, []string{"RTC_APP_KEY=must-not-be-forwarded"}, // public-scan: allow; gitleaks:allow — synthetic test credential + search := run(t, dir, []string{"RTC_APP_KEY=must-not-be-forwarded", "AI_AGENT=e2e", "VE_SKILL_ID=byted-interactai-guide/0.0.4"}, // public-scan: allow; gitleaks:allow — synthetic test credential "docs", "search", "audio", "--limit", "1", "--format", "json") if search.code != 0 || search.stderr != "" { t.Fatalf("search exit=%d stdout=%s stderr=%s", search.code, search.stdout, search.stderr) @@ -33,12 +33,12 @@ func TestDocsCommandsAgainstScriptedMCP(t *testing.T) { t.Fatalf("search result = %#v", first) } - fetch := run(t, dir, nil, "docs", "fetch", "rtc/audio", "--format", "pretty") + fetch := run(t, dir, []string{"AI_AGENT=e2e"}, "docs", "fetch", "rtc/audio", "--format", "pretty") if fetch.code != 0 || fetch.stdout != "# Fixture RTC Document\n\nExact markdown. \n" || fetch.stderr != "" { t.Fatalf("fetch exit=%d stdout=%q stderr=%q", fetch.code, fetch.stdout, fetch.stderr) } - list := run(t, dir, nil, "docs", "list", "--query", "video", "--format", "json") + list := run(t, dir, []string{"AI_AGENT=e2e"}, "docs", "list", "--query", "video", "--format", "json") if list.code != 0 { t.Fatalf("list exit=%d stdout=%s stderr=%s", list.code, list.stdout, list.stderr) } @@ -49,7 +49,7 @@ func TestDocsCommandsAgainstScriptedMCP(t *testing.T) { } func TestDocsToolFailureIsTyped(t *testing.T) { - r := run(t, t.TempDir(), nil, "docs", "search", "force-error", "--format", "json") + r := run(t, t.TempDir(), []string{"AI_AGENT=e2e"}, "docs", "search", "force-error", "--format", "json") if r.code == 0 { t.Fatalf("expected failure: %s", r.stdout) } diff --git a/tests/e2e_test.go b/tests/e2e_test.go index dc33e8d..c9fe138 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -100,7 +100,11 @@ func TestMain(m *testing.M) { } func serveTopicDocsFixture(w http.ResponseWriter, r *http.Request) { - if r.UserAgent() != "vertc/0.0.1-dev" { + allowedUserAgents := map[string]bool{ + "vertc/0.0.1-dev invocation/direct caller/e2e": true, + "vertc/0.0.1-dev invocation/skill caller/e2e skill/byted-interactai-guide#0.0.4": true, + } + if !allowedUserAgents[r.UserAgent()] { http.Error(w, "unexpected user agent", http.StatusForbidden) return }