diff --git a/.github/workflows/ai-behavior-check.yml b/.github/workflows/ai-behavior-check.yml index 304879f3..3fd9c131 100644 --- a/.github/workflows/ai-behavior-check.yml +++ b/.github/workflows/ai-behavior-check.yml @@ -95,6 +95,7 @@ jobs: typeof filename === 'string' && ( filename.startsWith('.github/workflows/') || + filename.startsWith('scripts/ci/') || filename.startsWith('scripts/policy/') || filename.startsWith('scripts/release/') || filename === 'test/fixtures/cli-interface-baseline.txt' || diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8c4e304..633df24a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,11 +130,13 @@ jobs: with: go-version-file: go.mod + - name: Verify Test Package Plan + if: steps.classify.outputs.changelog_only != 'true' + run: make test-plan + - name: Format Check if: steps.classify.outputs.changelog_only != 'true' - run: | - unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)" - test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1) + run: make format-check - name: Go Vet if: steps.classify.outputs.changelog_only != 'true' @@ -167,38 +169,20 @@ jobs: with: go-version-file: go.mod - - name: Install archive tooling - run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Build if: ${{ matrix.shard == 'remaining' }} run: make build - name: Test shard with Race Detection + shell: bash env: + DWS_PACKAGE_VERSION: 0.0.0-test TEST_SHARD: ${{ matrix.shard }} run: | - case "$TEST_SHARD" in - app) - packages=(./internal/app/...) - ;; - generators) - packages=(./internal/generator/...) - ;; - helpers) - packages=(./internal/helpers/...) - ;; - remaining) - mapfile -t packages < <( - go list ./cmd/... ./internal/... | - grep -Ev '/internal/(app|generator|helpers)(/|$)' - ) - ;; - *) - printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2 - exit 1 - ;; - esac + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")" + test -n "$package_output" + mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 go test -v -race -count=1 -timeout=10m "${packages[@]}" @@ -207,7 +191,7 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@v4 @@ -221,7 +205,16 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zip unzip - name: Test release scripts - run: go test -v -count=1 -timeout=5m ./test/scripts + shell: bash + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + package_output="$(./scripts/ci/test-packages.sh list release-scripts)" + test -n "$package_output" + mapfile -t packages <<< "$package_output" + test "${#packages[@]}" -gt 0 + go test -v -count=1 -timeout=10m "${packages[@]}" test-cross-platform: name: Test (cross-platform compile) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7eff27..38b9e684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,8 +36,8 @@ Common repository checks already used here include: ./scripts/policy/check-open-source-assets.sh go test ./... make test +make test-plan make lint -bash test/scripts/run_all_tests.sh --jobs 8 ./scripts/policy/check-generated-drift.sh ./scripts/policy/check-command-surface.sh --strict ./scripts/release/verify-package-managers.sh diff --git a/Makefile b/Makefile index 0fa71080..b5131e7b 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ GO ?= go +DWS_PACKAGE_VERSION ?= 0.0.0-test REMOTE ?= PUBLISH ?= 0 YES ?= 0 DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" +GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -14,8 +16,10 @@ help: @printf "Available targets:\n" @printf " make build - Build the dws CLI binary\n" @printf " make test - Run the Go test suite\n" - @printf " make lint - Run formatting checks and golangci-lint when available\n" - @printf " make fmt - Format Go source files\n" + @printf " make test-plan - Verify every default Go package belongs to one CI test shard\n" + @printf " make lint - Run formatting checks, go vet, and staticcheck\n" + @printf " make format-check - Check all repository Go source files with gofmt\n" + @printf " make fmt - Format all repository Go source files\n" @printf " make policy - Check the built dws plus open-source and Schema policies\n" @printf " make interface-integrity - Check historical commands and help contracts still work\n" @printf " make authoritative-interface-integrity BASE_REF= - Check the Git-owned PR merge-base\n" @@ -45,13 +49,32 @@ rebuild: @./scripts/dev/build.sh test: - @./test/scripts/run_all_tests.sh --timeout 5m + @DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./... + +test-plan: + @./scripts/ci/test-packages.sh verify lint: @./scripts/dev/lint.sh +format-check: + @set -eu; \ + go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \ + trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \ + $(GO_SOURCE_LIST) > "$$go_files"; \ + unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \ + if [ -n "$$unformatted" ]; then \ + printf '%s\n' "$$unformatted"; \ + printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \ + exit 1; \ + fi + fmt: - @find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w + @set -eu; \ + go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \ + trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \ + $(GO_SOURCE_LIST) > "$$go_files"; \ + xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files" policy: @mkdir -p "$(POLICY_GOTMPDIR)" diff --git a/internal/event/consume/integration_test.go b/internal/event/consume/integration_test.go index 5408a259..50b7d206 100644 --- a/internal/event/consume/integration_test.go +++ b/internal/event/consume/integration_test.go @@ -31,6 +31,31 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" ) +type consumeReadyWriter struct { + ready chan struct{} + once sync.Once +} + +func newConsumeReadyWriter() *consumeReadyWriter { + return &consumeReadyWriter{ready: make(chan struct{})} +} + +func (w *consumeReadyWriter) Write(p []byte) (int, error) { + if bytes.Contains(p, []byte("[event] ready ")) { + w.once.Do(func() { close(w.ready) }) + } + return len(p), nil +} + +func waitForConsumerReady(t *testing.T, ready <-chan struct{}) { + t.Helper() + select { + case <-ready: + case <-time.After(3 * time.Second): + t.Fatal("consumer did not report ready") + } +} + // TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time // event_types pushdown contract (plan §4 unsung superpower): a consumer // subscribing to "im.*" must NOT receive "approval.*" events even when @@ -49,40 +74,50 @@ func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) { defer func() { cancel(); <-runDone }() var imBuf, approvalBuf bytes.Buffer - var wg sync.WaitGroup - wg.Add(2) + imReady := newConsumeReadyWriter() + approvalReady := newConsumeReadyWriter() + consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelConsumers() + consumeDone := make(chan error, 2) // Consumer A: im.* only go func() { - defer wg.Done() - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &imBuf, - Stderr: io.Discard, + Stderr: imReady, EventTypes: []string{"im.*"}, MaxEvents: 3, // 3 im events expected }) }() // Consumer B: approval.* only go func() { - defer wg.Done() - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &approvalBuf, - Stderr: io.Discard, + Stderr: approvalReady, EventTypes: []string{"approval.*"}, MaxEvents: 2, // 2 approval events expected }) }() - // Give both consumers time to Hello + register. - time.Sleep(200 * time.Millisecond) + waitForConsumerReady(t, imReady.ready) + waitForConsumerReady(t, approvalReady.ready) close(trigger) - wg.Wait() + for range 2 { + select { + case err := <-consumeDone: + if err != nil { + t.Fatalf("consume failed: %v", err) + } + case <-consumeCtx.Done(): + t.Fatalf("consumers did not finish: %v", consumeCtx.Err()) + } + } // Verify consumer A got exactly the 3 im.* events. imLines := nonEmptyLines(imBuf.String()) @@ -130,23 +165,32 @@ func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) { defer func() { cancel(); <-runDone }() var buf bytes.Buffer - consumeDone := make(chan struct{}) + ready := newConsumeReadyWriter() + consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelConsumer() + consumeDone := make(chan error, 1) go func() { - defer close(consumeDone) - _ = Run(context.Background(), Config{ + consumeDone <- Run(consumeCtx, Config{ WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test", Stdout: &buf, - Stderr: io.Discard, + Stderr: ready, EventTypes: []string{"im.*"}, Filter: `\.at_v1$`, // only at_v1 events MaxEvents: 1, }) }() - time.Sleep(150 * time.Millisecond) + waitForConsumerReady(t, ready.ready) close(trigger) - <-consumeDone + select { + case err := <-consumeDone: + if err != nil { + t.Fatalf("consume failed: %v", err) + } + case <-consumeCtx.Done(): + t.Fatalf("consumer did not finish: %v", consumeCtx.Err()) + } lines := nonEmptyLines(buf.String()) if len(lines) != 1 { diff --git a/pkg/asynctask/asynctask.go b/pkg/asynctask/asynctask.go index 653f2411..1b7e0cc6 100644 --- a/pkg/asynctask/asynctask.go +++ b/pkg/asynctask/asynctask.go @@ -20,8 +20,8 @@ // 2. 渐进式退避(1s/2s/5s/10s/15s/30s/30s...,可自定义) // 3. 默认 5 分钟超时,超时返回 jobId 让用户用 `... get --job-id ` 续等 // 4. 状态机统一:PROCESSING/PENDING → SUCCESS → 拿 downloadUrl -// ↓ -// FAILED → 返回 error +// ↓ +// FAILED → 返回 error // 5. 可选 PUT 下载:传入 OutputPath 时本包负责 HTTP GET 落盘 package asynctask diff --git a/pkg/asynctask/asynctask_test.go b/pkg/asynctask/asynctask_test.go index 4e6e0e10..dd2faff3 100644 --- a/pkg/asynctask/asynctask_test.go +++ b/pkg/asynctask/asynctask_test.go @@ -112,7 +112,9 @@ func TestSubmit_ContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() submitFn := func(ctx context.Context) (string, error) { return "job-x", nil } - queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { return QueryResult{Status: StatusProcessing}, nil } + queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { + return QueryResult{Status: StatusProcessing}, nil + } _, err := Submit(ctx, submitFn, queryFn, Options{Backoff: testBackoff, Timeout: 1 * time.Second}) if !errors.Is(err, context.Canceled) { t.Fatalf("expect ctx.Canceled, got %v", err) diff --git a/scripts/README.md b/scripts/README.md index 179e022b..e0823cbe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -3,9 +3,11 @@ These repo-local entrypoints are the supported shell entrypoints for building, testing, packaging, and policy checks. - `make build`: build the `dws` CLI from `cmd` -- `make test`: run `go test ./...` -- `make lint`: run formatting checks and required `golangci-lint` -- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/` +- `make test`: run every default package returned by `go list ./...` +- `make test-plan`: verify every default Go package belongs to exactly one CI test shard +- `make lint`: run the repository-wide format check, `go vet`, and required `staticcheck` +- `make format-check`: check every tracked or non-ignored untracked repository Go source file with `gofmt` +- `make fmt`: format every tracked or non-ignored untracked repository Go source file - `make policy`: reuse the current `dws` binary and run the complete policy suite (`make build` first) - `make package`: build all release artifacts locally - `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag) @@ -15,6 +17,7 @@ Script groups: - Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh` - Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh` +- CI package plan: `./scripts/ci/test-packages.sh` - Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh` - Policy checks: `./scripts/policy/check-schema-command-registry.sh`, `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref --stable-ref `, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh` - Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace diff --git a/scripts/ci/test-packages.sh b/scripts/ci/test-packages.sh new file mode 100755 index 00000000..041497d1 --- /dev/null +++ b/scripts/ci/test-packages.sh @@ -0,0 +1,121 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +MODULE="$(cd "$ROOT" && go list -m)" + +usage() { + printf '%s\n' \ + "usage: $0 list " \ + " $0 verify" >&2 + exit 2 +} + +list_shard() { + shard="$1" + cd "$ROOT" + + case "$shard" in + app) + go list ./internal/app/... + ;; + generators) + go list ./internal/generator/... + ;; + helpers) + go list ./internal/helpers/... + ;; + release-scripts) + go list ./test/scripts/... + ;; + remaining) + all_packages="$(go list ./...)" + printf '%s\n' "$all_packages" | while IFS= read -r package; do + case "$package" in + "$MODULE/internal/app"|"$MODULE/internal/app/"*) ;; + "$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;; + "$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;; + "$MODULE/test/scripts"|"$MODULE/test/scripts/"*) ;; + *) printf '%s\n' "$package" ;; + esac + done + ;; + *) + printf 'unknown test package shard: %s\n' "$shard" >&2 + exit 2 + ;; + esac +} + +verify_plan() { + workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")" + trap 'rm -rf "$workdir"' EXIT HUP INT TERM + + expected="$workdir/expected" + assigned="$workdir/assigned" + unique="$workdir/unique" + duplicates="$workdir/duplicates" + missing="$workdir/missing" + unexpected="$workdir/unexpected" + all_packages="$workdir/all-packages" + + cd "$ROOT" + go list ./... > "$all_packages" + LC_ALL=C sort -u "$all_packages" > "$expected" + : > "$assigned" + + for shard in app generators helpers remaining release-scripts; do + shard_packages="$workdir/$shard" + unsorted_shard_packages="$workdir/$shard.unsorted" + list_shard "$shard" > "$unsorted_shard_packages" + LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages" + if [ ! -s "$shard_packages" ]; then + printf 'test package shard is empty: %s\n' "$shard" >&2 + exit 1 + fi + cat "$shard_packages" >> "$assigned" + done + + LC_ALL=C sort "$assigned" -o "$assigned" + uniq -d "$assigned" > "$duplicates" + LC_ALL=C sort -u "$assigned" > "$unique" + comm -23 "$expected" "$unique" > "$missing" + comm -13 "$expected" "$unique" > "$unexpected" + + failed=0 + if [ -s "$duplicates" ]; then + printf '%s\n' 'test packages assigned to more than one shard:' >&2 + sed 's/^/ /' "$duplicates" >&2 + failed=1 + fi + if [ -s "$missing" ]; then + printf '%s\n' 'default Go packages missing from the CI test plan:' >&2 + sed 's/^/ /' "$missing" >&2 + failed=1 + fi + if [ -s "$unexpected" ]; then + printf '%s\n' 'CI test plan contains packages outside go list ./...:' >&2 + sed 's/^/ /' "$unexpected" >&2 + failed=1 + fi + if [ "$failed" -ne 0 ]; then + exit 1 + fi + + package_count="$(wc -l < "$expected" | tr -d ' ')" + printf 'test package plan covers %s default packages exactly once\n' "$package_count" +} + +case "${1:-}" in + list) + [ "$#" -eq 2 ] || usage + list_shard "$2" + ;; + verify) + [ "$#" -eq 1 ] || usage + verify_plan + ;; + *) + usage + ;; +esac diff --git a/scripts/dev/ci-local.sh b/scripts/dev/ci-local.sh index 58282c41..e9da065b 100755 --- a/scripts/dev/ci-local.sh +++ b/scripts/dev/ci-local.sh @@ -11,7 +11,7 @@ run() { cd "$ROOT" run ./scripts/dev/lint.sh -run go test ./... +run make test COVERAGE_PROFILE="$(mktemp "${TMPDIR:-/tmp}/dws-coverage.XXXXXX")" run ./scripts/dev/coverage.sh "$COVERAGE_PROFILE" rm -f "$COVERAGE_PROFILE" diff --git a/scripts/dev/lint.sh b/scripts/dev/lint.sh index 22a191e6..ca6e3a43 100755 --- a/scripts/dev/lint.sh +++ b/scripts/dev/lint.sh @@ -1,13 +1,12 @@ #!/bin/sh set -eu +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" + # ── Format check ────────────────────────────────────────── -unformatted="$(find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -l)" -if [ -n "$unformatted" ]; then - echo "$unformatted" - echo "Go files are not formatted. Run 'make fmt'." >&2 - exit 1 -fi +echo "Running gofmt check..." +make format-check # ── go vet (built-in) ──────────────────────────────────── echo "Running go vet..." diff --git a/scripts/dev/run-mock-e2e.sh b/scripts/dev/run-mock-e2e.sh index 4ac42faf..18c83f6a 100755 --- a/scripts/dev/run-mock-e2e.sh +++ b/scripts/dev/run-mock-e2e.sh @@ -4,7 +4,7 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" cd "$ROOT" -go test ./test/mock_mcp/... \ - ./test/integration/discovery/... \ - ./test/integration/extensions/... \ - ./test/integration/recovery/... +DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" \ + go test -count=1 -timeout=5m \ + ./test/mock_mcp/... \ + ./test/integration/... diff --git a/test/cli/docs_compatibility_test.go b/test/cli/docs_compatibility_test.go index c6fda9ad..cf037014 100644 --- a/test/cli/docs_compatibility_test.go +++ b/test/cli/docs_compatibility_test.go @@ -20,8 +20,6 @@ type docsFlagCase struct { } func TestDWSDocsCommandTreeCoverage(t *testing.T) { - t.Parallel() - docPaths, _, err := parseDWSDocsCompatibility() if err != nil { t.Fatalf("parseDWSDocsCompatibility() error = %v", err) @@ -45,8 +43,6 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) { } func TestDWSDocsLocalFlagsCoverage(t *testing.T) { - t.Parallel() - docPaths, docFlags, err := parseDWSDocsCompatibility() if err != nil { t.Fatalf("parseDWSDocsCompatibility() error = %v", err) diff --git a/test/cli/root_command_test.go b/test/cli/root_command_test.go index 44bc4616..fc2db003 100644 --- a/test/cli/root_command_test.go +++ b/test/cli/root_command_test.go @@ -14,8 +14,6 @@ import ( // and their availability depends on the test environment's fixture data. func TestHiddenMCPHelpIsReachable(t *testing.T) { - t.Parallel() - cmd := app.NewRootCommand() var out strings.Builder cmd.SetOut(&out) @@ -33,8 +31,6 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) { } func TestSkillCommandIsRegistered(t *testing.T) { - t.Parallel() - root := app.NewRootCommand() var found bool diff --git a/test/cli/testmain_test.go b/test/cli/testmain_test.go index d5264d50..73b09d78 100644 --- a/test/cli/testmain_test.go +++ b/test/cli/testmain_test.go @@ -11,6 +11,8 @@ import ( func TestMain(m *testing.M) { // Set an empty catalog fixture so that EnvironmentLoader does not // attempt live discovery (which would hang on unreachable MCP endpoints). + // Tests that construct app root commands must remain serial because root + // construction initializes process-wide helper dependencies. absFixture, _ := filepath.Abs("testdata/empty_catalog.json") os.Setenv(cli.CatalogFixtureEnv, absFixture) diff --git a/test/cli/unregistered_modules_command_test.go b/test/cli/unregistered_modules_command_test.go index c9502cad..1f89476c 100644 --- a/test/cli/unregistered_modules_command_test.go +++ b/test/cli/unregistered_modules_command_test.go @@ -8,8 +8,6 @@ import ( ) func TestRootHelpHidesRecoveredModules(t *testing.T) { - t.Parallel() - cmd := app.NewRootCommand() var out strings.Builder cmd.SetOut(&out) diff --git a/test/scripts/ai_behavior_policy_test.go b/test/scripts/ai_behavior_policy_test.go index 0030b3fd..57875c59 100644 --- a/test/scripts/ai_behavior_policy_test.go +++ b/test/scripts/ai_behavior_policy_test.go @@ -24,6 +24,7 @@ func TestAIBehaviorPolicyProtectsEnforcementInputs(t *testing.T) { workflow := string(data) for _, want := range []string{ + "filename.startsWith('scripts/ci/')", "filename.startsWith('scripts/policy/')", "filename === 'test/fixtures/cli-interface-baseline.txt'", "previous_filename", diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 611819f3..df21137b 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -421,6 +421,10 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { `COVERAGE_ENFORCE_OVERALL: "false"`, `COVERAGE_OVERALL_TOLERANCE: "0"`, `COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`, + "run: make test-plan", + "run: make format-check", + "./scripts/ci/test-packages.sh list", + `package_output="$(./scripts/ci/test-packages.sh list`, } { if !strings.Contains(admission, want) { t.Errorf("Code Admission workflow missing contract %q", want) diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go new file mode 100644 index 00000000..6d6fda11 --- /dev/null +++ b/test/scripts/ci_test_package_plan_test.go @@ -0,0 +1,106 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCITestPackagePlanCoversDefaultPackagesExactlyOnce(t *testing.T) { + root := testPackagePlanRoot(t) + output := runTestPackagePlan(t, root, "verify") + if !strings.Contains(output, "default packages exactly once") { + t.Fatalf("verify output = %q, want coverage summary", output) + } +} + +func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) { + root := testPackagePlanRoot(t) + remaining := strings.Fields(runTestPackagePlan(t, root, "list", "remaining")) + releaseScripts := strings.Fields(runTestPackagePlan(t, root, "list", "release-scripts")) + + for _, suffix := range []string{ + "/test/cli", + "/test/contract", + "/test/integration/extensions", + "/test/mock_mcp", + "/test/smoke", + "/test/unit", + } { + if !containsPackageSuffix(remaining, suffix) { + t.Errorf("remaining shard does not contain package ending in %q", suffix) + } + } + if containsPackageSuffix(remaining, "/test/scripts") { + t.Error("remaining shard unexpectedly contains /test/scripts") + } + if !containsPackageSuffix(releaseScripts, "/test/scripts") { + t.Error("release-scripts shard does not contain /test/scripts") + } +} + +func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) { + root := testPackagePlanRoot(t) + fakeBin := t.TempDir() + fakeGo := filepath.Join(fakeBin, "go") + err := os.WriteFile(fakeGo, []byte(`#!/bin/sh +if [ "$1" = "list" ] && [ "$2" = "-m" ]; then + printf '%s\n' 'github.com/DingTalk-Real-AI/dingtalk-workspace-cli' + exit 0 +fi +printf '%s\n' 'injected go list failure' >&2 +exit 42 +`), 0o755) + if err != nil { + t.Fatalf("write fake go: %v", err) + } + + script := filepath.Join(root, "scripts", "ci", "test-packages.sh") + for _, args := range [][]string{{"list", "remaining"}, {"verify"}} { + cmd := exec.Command("sh", append([]string{script}, args...)...) + cmd.Dir = root + cmd.Env = []string{ + "PATH=" + fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"), + "TMPDIR=" + t.TempDir(), + } + output, runErr := cmd.CombinedOutput() + if runErr == nil { + t.Fatalf("%s unexpectedly succeeded with failing go list:\n%s", strings.Join(args, " "), output) + } + if !strings.Contains(string(output), "injected go list failure") { + t.Fatalf("%s failure output = %q, want injected failure", strings.Join(args, " "), output) + } + } +} + +func testPackagePlanRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + return root +} + +func runTestPackagePlan(t *testing.T, root string, args ...string) string { + t.Helper() + script := filepath.Join(root, "scripts", "ci", "test-packages.sh") + cmd := exec.Command("sh", append([]string{script}, args...)...) + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", script, strings.Join(args, " "), err, output) + } + return string(output) +} + +func containsPackageSuffix(packages []string, suffix string) bool { + for _, packagePath := range packages { + if strings.HasSuffix(packagePath, suffix) { + return true + } + } + return false +} diff --git a/test/scripts/run_all_tests.sh b/test/scripts/run_all_tests.sh deleted file mode 100755 index 23743b10..00000000 --- a/test/scripts/run_all_tests.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env bash -# run_all_tests.sh — Run all test suites in parallel and print a summary. -# Usage: bash test/scripts/run_all_tests.sh [--verbose] [--timeout ] [--jobs ] -# -# Options: -# --verbose Print full test output for every suite (default: only on failure) -# --timeout Per-suite go test timeout (default: 120s) -# --jobs Max parallel suites (default: number of CPU cores) - -set -uo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -VERBOSE=false -TIMEOUT="120s" -MAX_JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" - -# ── Parse flags ────────────────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do - case "$1" in - --verbose) VERBOSE=true; shift ;; - --timeout) TIMEOUT="$2"; shift 2 ;; - --jobs) MAX_JOBS="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; - esac -done - -# ── Colour helpers ──────────────────────────────────────────────────────────── -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# ── Test suite definitions ──────────────────────────────────────────────────── -# Each entry: "label|package_path" -declare -a SUITES=( - "cli|./test/cli/..." - "contract|./test/contract/..." - "integration/extensions|./test/integration/extensions/..." - "integration/recovery|./test/integration/recovery/..." - "mock_mcp|./test/mock_mcp/..." - "scripts|./test/scripts/..." -) - -# ── Temporary workspace ─────────────────────────────────────────────────────── -LOG_DIR="$(mktemp -d)" -trap 'rm -rf "$LOG_DIR"' EXIT - -# ── Worker: run one suite, write result files ───────────────────────────────── -# Result files written to $LOG_DIR//{status,output} -run_suite_worker() { - local label="$1" - local pkg="$2" - local safe_label="${label//\//_}" - local suite_dir="$LOG_DIR/$safe_label" - mkdir -p "$suite_dir" - - # Check whether the package directory actually exists - local pkg_dir="${pkg//\.\//}" - pkg_dir="${pkg_dir%/...}" - if [[ ! -d "$REPO_ROOT/$pkg_dir" ]]; then - echo "skip" > "$suite_dir/status" - echo "(directory not found)" > "$suite_dir/output" - return - fi - - local exit_code=0 - ( - cd "$REPO_ROOT" - go test -timeout "$TIMEOUT" -count=1 "$pkg" 2>&1 - ) > "$suite_dir/output" 2>&1 || exit_code=$? - - if [[ $exit_code -eq 0 ]]; then - echo "pass" > "$suite_dir/status" - elif grep -q "matched no packages\|no packages to test\|\[no test files\]" "$suite_dir/output" 2>/dev/null; then - echo "skip" > "$suite_dir/status" - echo "(no test files found)" > "$suite_dir/output" - else - echo "fail" > "$suite_dir/status" - fi -} - -export -f run_suite_worker -export REPO_ROOT TIMEOUT LOG_DIR - -# ── Main ────────────────────────────────────────────────────────────────────── -echo "" -printf "${BOLD}╔══════════════════════════════════════════════════════════════╗${RESET}\n" -printf "${BOLD}║ dws — Full Test Suite Runner ║${RESET}\n" -printf "${BOLD}╚══════════════════════════════════════════════════════════════╝${RESET}\n" -echo "" -printf "Repo root : %s\n" "$REPO_ROOT" -printf "Timeout : %s per suite\n" "$TIMEOUT" -printf "Parallel : %s jobs\n" "$MAX_JOBS" -printf "Verbose : %s\n" "$VERBOSE" -echo "" -printf "${BOLD}Launching %d suites in parallel…${RESET}\n" "${#SUITES[@]}" -echo "" - -START_TIME=$(date +%s) - -# ── Dispatch all suites concurrently with a job-slot semaphore ──────────────── -declare -a PIDS=() -declare -a PID_LABELS=() -active_jobs=0 - -for suite in "${SUITES[@]}"; do - label="${suite%%|*}" - pkg="${suite##*|}" - - printf " ${CYAN}START${RESET} %s\n" "$label" - - run_suite_worker "$label" "$pkg" & - PIDS+=($!) - PID_LABELS+=("$label") - active_jobs=$((active_jobs + 1)) - - # Throttle: wait for one slot to free up when at capacity - if [[ $active_jobs -ge $MAX_JOBS ]]; then - wait "${PIDS[$((${#PIDS[@]} - MAX_JOBS))]}" 2>/dev/null || true - active_jobs=$((active_jobs - 1)) - fi -done - -# Wait for all remaining background jobs -for pid in "${PIDS[@]}"; do - wait "$pid" 2>/dev/null || true -done - -END_TIME=$(date +%s) -ELAPSED=$((END_TIME - START_TIME)) - -# ── Collect results in original suite order ─────────────────────────────────── -TOTAL=0 -PASSED=0 -FAILED=0 -SKIPPED=0 - -declare -a FAILED_SUITES=() -declare -a PASSED_SUITES=() -declare -a SKIPPED_SUITES=() - -echo "" -printf "${BOLD}Results:${RESET}\n" -echo "" - -for suite in "${SUITES[@]}"; do - label="${suite%%|*}" - safe_label="${label//\//_}" - suite_dir="$LOG_DIR/$safe_label" - status_file="$suite_dir/status" - output_file="$suite_dir/output" - - TOTAL=$((TOTAL + 1)) - - if [[ ! -f "$status_file" ]]; then - # Worker never wrote a status — treat as failure - printf " ${RED}FAIL${RESET} %s (worker did not complete)\n" "$label" - FAILED=$((FAILED + 1)) - FAILED_SUITES+=("$label") - continue - fi - - status="$(cat "$status_file")" - - case "$status" in - pass) - PASSED=$((PASSED + 1)) - PASSED_SUITES+=("$label") - printf " ${GREEN}PASS${RESET} %s\n" "$label" - if $VERBOSE; then - sed 's/^/ /' "$output_file" - fi - ;; - skip) - SKIPPED=$((SKIPPED + 1)) - SKIPPED_SUITES+=("$label") - printf " ${YELLOW}SKIP${RESET} %s %s\n" "$label" "$(cat "$output_file")" - ;; - fail) - FAILED=$((FAILED + 1)) - FAILED_SUITES+=("$label") - printf " ${RED}FAIL${RESET} %s\n" "$label" - echo " ── output ──────────────────────────────────────────────────────" - sed 's/^/ /' "$output_file" - echo " ────────────────────────────────────────────────────────────────" - ;; - esac -done - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n" -printf "${BOLD} Summary (elapsed: %ds, parallel: %s jobs)${RESET}\n" "$ELAPSED" "$MAX_JOBS" -printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n" -printf " Total : %d\n" "$TOTAL" -printf " ${GREEN}Passed : %d${RESET}\n" "$PASSED" -printf " ${RED}Failed : %d${RESET}\n" "$FAILED" -printf " ${YELLOW}Skipped : %d${RESET}\n" "$SKIPPED" -echo "" - -if [[ ${#FAILED_SUITES[@]} -gt 0 ]]; then - printf "${RED}${BOLD}Failed suites:${RESET}\n" - for suite in "${FAILED_SUITES[@]}"; do - printf " ${RED}✗${RESET} %s\n" "$suite" - done - echo "" -fi - -if [[ ${#SKIPPED_SUITES[@]} -gt 0 ]]; then - printf "${YELLOW}Skipped suites:${RESET}\n" - for suite in "${SKIPPED_SUITES[@]}"; do - printf " ${YELLOW}–${RESET} %s\n" "$suite" - done - echo "" -fi - -if [[ ${#PASSED_SUITES[@]} -gt 0 ]]; then - printf "${GREEN}Passed suites:${RESET}\n" - for suite in "${PASSED_SUITES[@]}"; do - printf " ${GREEN}✓${RESET} %s\n" "$suite" - done - echo "" -fi - -# ── Exit code ───────────────────────────────────────────────────────────────── -if [[ $FAILED -gt 0 ]]; then - printf "${RED}${BOLD}Result: FAILED${RESET}\n\n" - exit 1 -else - printf "${GREEN}${BOLD}Result: ALL PASSED${RESET}\n\n" - exit 0 -fi