From 6bf78f1783f20fed1b45cd733cd406e9d25b4e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 12 Aug 2026 22:46:05 +0800 Subject: [PATCH] test(ci): isolate app race partitions --- .github/workflows/ci.yml | 19 ++-- internal/app/pat_auth_retry_test.go | 11 ++ internal/app/testmain_test.go | 4 + scripts/ci/run-app-race-tests.sh | 126 ++++++++++++++++++++++ test/scripts/changelog_pr_gate_test.go | 13 ++- test/scripts/ci_test_package_plan_test.go | 19 ++++ 6 files changed, 181 insertions(+), 11 deletions(-) create mode 100755 scripts/ci/run-app-race-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4707eb50..cff2c0dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,7 +501,8 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} runs-on: ubuntu-latest - # cli/smoke shards need headroom beyond go test -timeout for setup + assembly. + # app runs several independently bounded processes; cli/smoke need headroom + # beyond go test -timeout for setup + assembly. timeout-minutes: 20 strategy: fail-fast: false @@ -537,12 +538,18 @@ jobs: test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - # app/cli/smoke own heavy NewRootCommand / Schema assembly under - # -race; give them a dedicated budget so slower hosted runners do not - # hit the package timeout while the shards remain independently bounded. + if [ "$TEST_SHARD" = "app" ]; then + # A single long-lived app test process retains every constructed + # command tree in framework registries. Isolate Schema assembly and + # bounded name ranges so each process releases that state on exit. + test "${#packages[@]}" -eq 1 + ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" + exit 0 + fi + # cli/smoke own heavy NewRootCommand / Schema assembly under -race; + # give them a dedicated package timeout on slower hosted runners. timeout_budget=12m - if [ "$TEST_SHARD" = "app" ] || - [ "$TEST_SHARD" = "cli" ] || + if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then timeout_budget=15m fi diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 11b8eae5..2d803e37 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -30,6 +30,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct" ) @@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) { // /cli/oauth/device/poll?flowId= with the given status sequence. // It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that // GetMCPBaseURL() returns the test server address. +func stubPATPollAccessToken(t *testing.T) { + t.Helper() + testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) { + return "", authpkg.ErrTokenDataNotFound + }) +} + func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) { t.Helper() + stubPATPollAccessToken(t) var callCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) { } func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) { + stubPATPollAccessToken(t) // When server returns 302 (SSO redirect), poll should continue until real response. var callCount int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor // It responds to device poll requests with the given status after the first poll. func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) { t.Helper() + stubPATPollAccessToken(t) var pollCount atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index 5bed65f4..34e1adaa 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -83,6 +83,10 @@ func TestMain(m *testing.M) { // remove a TempDir while the audit lock is still open. setupAuditSink() openBrowserFunc = func(string) error { return nil } + // App tests may run in filtered CI processes. Install the same lazy Schema + // source factory as NewRootCommand without constructing a root so ResolveMeta + // tests never depend on an earlier test having initialized process state. + registerSchemaRuntimeDelivery() code := m.Run() StopAllStdioClients() CloseAuditSink() diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh new file mode 100755 index 00000000..9922e9ab --- /dev/null +++ b/scripts/ci/run-app-race-tests.sh @@ -0,0 +1,126 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" + +usage() { + printf '%s\n' \ + "usage: $0 verify " \ + " $0 run " >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +mode="$1" +app_package="$2" + +case "$mode" in + verify|run) ;; + *) usage ;; +esac + +workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-app-race-tests.XXXXXX")" +trap 'rm -rf "$workdir"' EXIT HUP INT TERM + +tests="$workdir/tests" +duplicates="$workdir/duplicates" +list_output="$workdir/list-output" + +cd "$ROOT" +if ! go test "$app_package" -list '^(Test|Example|Fuzz)' > "$list_output"; then + printf 'app race partition discovery failed for %s\n' "$app_package" >&2 + exit 1 +fi +awk '/^(Test|Example|Fuzz)/ { print $1 }' "$list_output" > "$tests" + +if [ ! -s "$tests" ]; then + printf 'app race partition discovery found no tests in %s\n' "$app_package" >&2 + exit 1 +fi + +LC_ALL=C sort "$tests" | uniq -d > "$duplicates" +if [ -s "$duplicates" ]; then + printf '%s\n' 'app race partition discovery found duplicate top-level tests:' >&2 + sed 's/^/ /' "$duplicates" >&2 + exit 1 +fi + +schema_count=0 +ab_count=0 +c_count=0 +dr_count=0 +sz_count=0 +unmatched_count=0 + +while IFS= read -r test_name; do + case "$test_name" in + Test*Schema*) schema_count=$((schema_count + 1)) ;; + Test[A-B]*) ab_count=$((ab_count + 1)) ;; + TestC*) c_count=$((c_count + 1)) ;; + Test[D-R]*) dr_count=$((dr_count + 1)) ;; + Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;; + *) + printf 'unmatched app race test: %s\n' "$test_name" >&2 + unmatched_count=$((unmatched_count + 1)) + ;; + esac +done < "$tests" + +if [ "$unmatched_count" -ne 0 ]; then + exit 1 +fi + +for partition in \ + "schema:$schema_count" \ + "a-b:$ab_count" \ + "c:$c_count" \ + "d-r:$dr_count" \ + "s-z-example-fuzz:$sz_count" +do + name="${partition%%:*}" + count="${partition#*:}" + if [ "$count" -eq 0 ]; then + printf 'app race partition %s is empty\n' "$name" >&2 + exit 1 + fi +done + +total_count="$(wc -l < "$tests" | tr -d ' ')" +assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count)) +if [ "$assigned_count" -ne "$total_count" ]; then + printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2 + exit 1 +fi + +printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \ + "$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count" + +if [ "$mode" = "verify" ]; then + exit 0 +fi + +run_partition() { + name="$1" + run_pattern="$2" + skip_pattern="${3:-}" + + printf 'running internal/app race partition %s\n' "$name" + if [ -n "$skip_pattern" ]; then + go test -v -race -count=1 -timeout=15m \ + -run "$run_pattern" -skip "$skip_pattern" "$app_package" + else + go test -v -race -count=1 -timeout=15m \ + -run "$run_pattern" "$app_package" + fi +} + +# Schema assembly has the largest transient memory footprint. Run those tests +# in a fresh process, then keep each remaining name range in its own process so +# command trees retained by process-global registries are released between +# partitions. The complementary run/skip patterns preserve the full test set. +schema_pattern='^Test.*Schema' +run_partition schema "$schema_pattern" +run_partition a-b '^Test[A-B]' "$schema_pattern" +run_partition c '^TestC' "$schema_pattern" +run_partition d-r '^Test[D-R]' "$schema_pattern" +run_partition s-z-example-fuzz '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index d01f366e..f8d91288 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1182,13 +1182,16 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Fatal("Code Admission workflow missing race test job boundaries") } raceJob := admission[raceStart:raceEnd] - // Full race shards use a dynamic package-level timeout: default/floor 12m, - // with app/cli/smoke raised to 15m for NewRootCommand / Schema assembly under - // -race on slower hosted runners. + // The app shard uses independently bounded test processes so process-global + // command registries are released before the Schema assembly peak. Other full + // race shards retain the dynamic package timeout: default/floor 12m, with + // cli/smoke raised to 15m on slower hosted runners. for _, want := range []string{ + `if [ "$TEST_SHARD" = "app" ]; then`, + `test "${#packages[@]}" -eq 1`, + `./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`, "timeout_budget=12m", - `if [ "$TEST_SHARD" = "app" ] ||`, - `[ "$TEST_SHARD" = "cli" ] ||`, + `if [ "$TEST_SHARD" = "cli" ] ||`, `[ "$TEST_SHARD" = "smoke" ]; then`, "timeout_budget=15m", `go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"`, diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go index 51a1943a..136ce8ec 100644 --- a/test/scripts/ci_test_package_plan_test.go +++ b/test/scripts/ci_test_package_plan_test.go @@ -47,6 +47,25 @@ func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) { } } +func TestCIAppRacePartitionsCoverTopLevelTestsExactlyOnce(t *testing.T) { + root := testPackagePlanRoot(t) + packages := strings.Fields(runTestPackagePlan(t, root, "list", "app")) + if len(packages) != 1 { + t.Fatalf("app package shard = %v, want exactly one package", packages) + } + + script := filepath.Join(root, "scripts", "ci", "run-app-race-tests.sh") + cmd := exec.Command("sh", script, "verify", packages[0]) + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s verify %s failed: %v\n%s", script, packages[0], err, output) + } + if !strings.Contains(string(output), "top-level tests exactly once") { + t.Fatalf("verify output = %q, want exact coverage summary", output) + } +} + func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) { root := testPackagePlanRoot(t) fakeBin := t.TempDir()