Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*.test
*.prof
*.out
coverage.html

# Node (generated web projects / scaffolds run in-tree)
node_modules/
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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) .
Expand All @@ -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 ./...

Expand Down Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions cmd/coverage_behavior_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading