From 99b41278a59a9a00654c6c81e1c9caca293a83a3 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 01:02:59 +0900 Subject: [PATCH 01/68] chore: Pull requests now fail when Go test coverage drops below the recorded baseline (#3073) --- .github/workflows/build-and-test.yml | 14 + AGENTS.md | 10 + .../cmd/coverage-report/main.go | 29 ++ .../internal/automation/coverage_report.go | 337 ++++++++++++++++++ .../automation/coverage_report_test.go | 313 ++++++++++++++++ coverage-baseline.json | 11 + docs/coverage.md | 45 +++ scripts/check-go-cli-source.sh | 20 +- 8 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 cli/release-automation/cmd/coverage-report/main.go create mode 100644 cli/release-automation/internal/automation/coverage_report.go create mode 100644 cli/release-automation/internal/automation/coverage_report_test.go create mode 100644 coverage-baseline.json create mode 100644 docs/coverage.md diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 001b4f8915..b82159f9dc 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -128,8 +128,22 @@ jobs: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Check native Go CLI + env: + GO_COVERAGE_DIR: ${{ runner.temp }}/go-coverage run: scripts/check-go-cli.sh + # Fails when a module's statement coverage falls below coverage-baseline.json. + # Runs only on Linux: build-tagged files make the figures differ per OS. + - name: Check Go test coverage against the baseline + working-directory: cli/release-automation + env: + GO_COVERAGE_DIR: ${{ runner.temp }}/go-coverage + run: | + go run ./cmd/coverage-report \ + --baseline ../../coverage-baseline.json \ + --mode gate \ + --go-coverage-dir "$GO_COVERAGE_DIR" + - name: Package native Go CLI installers run: scripts/package-go-cli.sh diff --git a/AGENTS.md b/AGENTS.md index cfb1cd09c4..d66777664a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,16 @@ When you touch a reported file, split it before adding behavior. Commands, the exclusion list, and the two places the threshold is declared: `docs/file-length.md`. +## Test Coverage + +Go statement coverage per CLI module may not fall more than 0.1 point below the +figures in `coverage-baseline.json`; the Linux `build-cli` job fails the pull +request otherwise. +When your tests lift a module a point or more, the job summary suggests a new +figure; raise it in the same pull request. Never lower a figure without a reason +in the pull request description. What is measured and how to run it locally: +`docs/coverage.md`. + ## Nested Type Name Forms A nested type has three spellings, and a name that crosses a boundary between them silently diff --git a/cli/release-automation/cmd/coverage-report/main.go b/cli/release-automation/cmd/coverage-report/main.go new file mode 100644 index 0000000000..0cdc7c2192 --- /dev/null +++ b/cli/release-automation/cmd/coverage-report/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + baseline := flag.String("baseline", "coverage-baseline.json", "path to the coverage baseline file") + mode := flag.String("mode", "report", "gate exits 1 when a module falls below its baseline; report only warns") + coverageDir := flag.String("go-coverage-dir", "", "directory of .out go test -coverprofile outputs") + flag.Parse() + + profiles, err := automation.CollectGoCoverProfiles(*coverageDir) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "read Go coverage profiles: %v\n", err) + os.Exit(2) + } + + os.Exit(automation.RunCoverageReport(os.Stdout, os.Stderr, automation.CoverageReportOptions{ + BaselinePath: *baseline, + GoProfiles: profiles, + Mode: *mode, + SummaryPath: os.Getenv("GITHUB_STEP_SUMMARY"), + })) +} diff --git a/cli/release-automation/internal/automation/coverage_report.go b/cli/release-automation/internal/automation/coverage_report.go new file mode 100644 index 0000000000..87f1e6e8eb --- /dev/null +++ b/cli/release-automation/internal/automation/coverage_report.go @@ -0,0 +1,337 @@ +package automation + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +const ( + coverageModeGate = "gate" + coverageModeReport = "report" + + // coverageRoundingTolerance absorbs the one-decimal rounding of stored baselines, so an + // unchanged tree never fails because its exact value sits just under the stored figure. + coverageRoundingTolerance = 0.1 + // coverageRaiseHintMargin is how far above its baseline a module must be before the report + // suggests raising the stored figure. + coverageRaiseHintMargin = 1.0 +) + +// CoverageReportOptions configures one coverage report or gate run. +type CoverageReportOptions struct { + BaselinePath string + // GoProfiles maps a module name in the baseline to its `go test -coverprofile` output. + GoProfiles map[string]string + Mode string + SummaryPath string +} + +// GoCoverageTotals is the statement count of a profile after exclusions. +type GoCoverageTotals struct { + Statements int + Covered int +} + +type coverageBaseline struct { + Go struct { + Exclude []string `json:"exclude"` + // Pointers so a null figure is told apart from 0 and rejected. + Modules map[string]*float64 `json:"modules"` + } `json:"go"` +} + +type coverageModuleResult struct { + Name string + Percent float64 + Baseline float64 +} + +var goCoverModes = map[string]bool{"mode: set": true, "mode: count": true, "mode: atomic": true} + +type goCoverBlock struct { + statements int + covered bool +} + +// RunCoverageReport compares each module's coverage with its stored baseline, prints a Markdown +// table, and in gate mode exits 1 when a module fell below its baseline. +func RunCoverageReport(stdout io.Writer, stderr io.Writer, options CoverageReportOptions) int { + if options.Mode != coverageModeGate && options.Mode != coverageModeReport { + _, _ = fmt.Fprintf(stderr, "unknown mode %q: use %s or %s\n", options.Mode, coverageModeGate, coverageModeReport) + return 2 + } + + baseline, err := readCoverageBaseline(options.BaselinePath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "read coverage baseline: %v\n", err) + return 2 + } + + results, err := measureGoModules(baseline, options.GoProfiles) + if err != nil { + _, _ = fmt.Fprintf(stderr, "measure Go coverage: %v\n", err) + return 2 + } + + table := formatCoverageTable(results) + _, _ = fmt.Fprint(stdout, table) + if err := appendCoverageSummary(options.SummaryPath, table); err != nil { + _, _ = fmt.Fprintf(stderr, "write step summary: %v\n", err) + return 2 + } + + below := modulesBelowBaseline(results) + if len(below) == 0 { + return 0 + } + + if options.Mode == coverageModeReport { + _, _ = fmt.Fprintf(stdout, "::warning::Coverage fell below the baseline in: %s\n", strings.Join(below, ", ")) + return 0 + } + + _, _ = fmt.Fprintf(stderr, "Coverage fell below the baseline in: %s. Add tests, or lower the figure in the baseline file with a reason in the pull request.\n", strings.Join(below, ", ")) + return 1 +} + +// ParseGoCoverProfile totals the statements of a `go test -coverprofile` file, leaving out blocks +// whose file path contains any of the exclude patterns. +func ParseGoCoverProfile(reader io.Reader, exclude []string) (GoCoverageTotals, error) { + scanner := bufio.NewScanner(reader) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return GoCoverageTotals{}, err + } + return GoCoverageTotals{}, errors.New("profile is empty") + } + if !goCoverModes[strings.TrimSpace(scanner.Text())] { + return GoCoverageTotals{}, fmt.Errorf("profile does not start with a go test mode line: %q", scanner.Text()) + } + + blocks := map[string]goCoverBlock{} + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + key, block, err := parseGoCoverLine(line) + if err != nil { + return GoCoverageTotals{}, err + } + if pathMatchesAny(key, exclude) { + continue + } + + // Why merge: a block appears once per test binary that loaded it (for example under + // -coverpkg), and it is covered when any of those runs covered it. + existing := blocks[key] + blocks[key] = goCoverBlock{statements: block.statements, covered: existing.covered || block.covered} + } + if err := scanner.Err(); err != nil { + return GoCoverageTotals{}, err + } + + totals := GoCoverageTotals{} + for _, block := range blocks { + totals.Statements += block.statements + if block.covered { + totals.Covered += block.statements + } + } + return totals, nil +} + +// goCoverLinePattern is the block line go test -coverprofile writes: +// "file:startLine.startCol,endLine.endCol numStmts count", all counts non-negative. +var goCoverLinePattern = regexp.MustCompile(`^(.+:\d+\.\d+,\d+\.\d+) (\d+) (\d+)$`) + +// parseGoCoverLine splits a block line into the block key and its counts, rejecting anything +// go test would not write so a damaged profile cannot inflate the figure the gate compares. +func parseGoCoverLine(line string) (string, goCoverBlock, error) { + match := goCoverLinePattern.FindStringSubmatch(line) + if match == nil { + return "", goCoverBlock{}, fmt.Errorf("malformed profile line %q", line) + } + + statements, err := strconv.Atoi(match[2]) + if err != nil { + return "", goCoverBlock{}, fmt.Errorf("malformed statement count in %q", line) + } + count, err := strconv.Atoi(match[3]) + if err != nil { + return "", goCoverBlock{}, fmt.Errorf("malformed hit count in %q", line) + } + return match[1], goCoverBlock{statements: statements, covered: count > 0}, nil +} + +func pathMatchesAny(blockKey string, patterns []string) bool { + path := blockKey[:strings.LastIndex(blockKey, ":")] + for _, pattern := range patterns { + if strings.Contains(path, pattern) { + return true + } + } + return false +} + +func readCoverageBaseline(path string) (coverageBaseline, error) { + content, err := os.ReadFile(path) + if err != nil { + return coverageBaseline{}, err + } + + var baseline coverageBaseline + if err := json.Unmarshal(content, &baseline); err != nil { + return coverageBaseline{}, err + } + if len(baseline.Go.Modules) == 0 { + return coverageBaseline{}, errors.New("baseline lists no Go modules") + } + for name, figure := range baseline.Go.Modules { + if figure == nil { + return coverageBaseline{}, fmt.Errorf("baseline for module %q has no figure", name) + } + if *figure < 0 || *figure > 100 { + return coverageBaseline{}, fmt.Errorf("baseline for module %q is %v, outside 0 to 100", name, *figure) + } + } + return baseline, nil +} + +// measureGoModules requires the profiles and the baseline to name the same modules, so a module +// whose tests were not wired into the run fails instead of silently dropping out of the gate. +func measureGoModules(baseline coverageBaseline, profiles map[string]string) ([]coverageModuleResult, error) { + for name := range profiles { + if _, ok := baseline.Go.Modules[name]; !ok { + return nil, fmt.Errorf("profile given for module %q, which the baseline does not list", name) + } + } + + names := make([]string, 0, len(baseline.Go.Modules)) + for name := range baseline.Go.Modules { + names = append(names, name) + } + sort.Strings(names) + + results := make([]coverageModuleResult, 0, len(names)) + for _, name := range names { + path, ok := profiles[name] + if !ok { + return nil, fmt.Errorf("no profile given for module %q", name) + } + + totals, err := readGoCoverProfile(path, baseline.Go.Exclude) + if err != nil { + return nil, fmt.Errorf("module %q: %w", name, err) + } + if totals.Statements == 0 { + return nil, fmt.Errorf("module %q has no statements left to measure", name) + } + + results = append(results, coverageModuleResult{ + Name: name, + Percent: float64(totals.Covered) * 100 / float64(totals.Statements), + Baseline: *baseline.Go.Modules[name], + }) + } + return results, nil +} + +// CollectGoCoverProfiles maps each .out file in dir to its module name. Reading the whole +// directory, rather than a list kept elsewhere, makes a module newly wired into the test script +// reach the baseline comparison and fail until the baseline lists it. +func CollectGoCoverProfiles(dir string) (map[string]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + profiles := map[string]string{} + for _, entry := range entries { + name, ok := strings.CutSuffix(entry.Name(), ".out") + if !ok || entry.IsDir() { + continue + } + profiles[name] = filepath.Join(dir, entry.Name()) + } + return profiles, nil +} + +func readGoCoverProfile(path string, exclude []string) (GoCoverageTotals, error) { + file, err := os.Open(path) + if err != nil { + return GoCoverageTotals{}, err + } + defer func() { _ = file.Close() }() + return ParseGoCoverProfile(file, exclude) +} + +func modulesBelowBaseline(results []coverageModuleResult) []string { + below := []string{} + for _, result := range results { + if result.Percent < result.Baseline-coverageRoundingTolerance { + below = append(below, result.Name) + } + } + return below +} + +func formatCoverageTable(results []coverageModuleResult) string { + var builder strings.Builder + builder.WriteString("| Module | Coverage | Baseline | Note |\n|---|---|---|---|\n") + for _, result := range results { + _, _ = fmt.Fprintf( + &builder, + "| %s | %s%% | %s%% | %s |\n", + result.Name, + formatCoveragePercent(floorToOneDecimal(result.Percent)), + formatCoveragePercent(result.Baseline), + coverageNote(result)) + } + return builder.String() +} + +func coverageNote(result coverageModuleResult) string { + if result.Percent < result.Baseline-coverageRoundingTolerance { + return "below baseline" + } + if result.Percent >= result.Baseline+coverageRaiseHintMargin { + return "raise baseline to " + formatCoveragePercent(floorToOneDecimal(result.Percent)) + } + return "" +} + +func formatCoveragePercent(value float64) string { + return strconv.FormatFloat(value, 'f', 1, 64) +} + +// floorToOneDecimal truncates rather than rounds, so a shown or suggested figure never exceeds +// what was measured and a baseline raised to it cannot fail the same tree. The epsilon keeps a +// value such as 72.5 from flooring to 72.4 through float error. +func floorToOneDecimal(value float64) float64 { + return math.Floor(value*10+1e-9) / 10 +} + +func appendCoverageSummary(path string, table string) error { + if path == "" { + return nil + } + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + _, writeErr := io.WriteString(file, "## Go test coverage\n\n"+table+"\n") + return errors.Join(writeErr, file.Close()) +} diff --git a/cli/release-automation/internal/automation/coverage_report_test.go b/cli/release-automation/internal/automation/coverage_report_test.go new file mode 100644 index 0000000000..7c64b58e99 --- /dev/null +++ b/cli/release-automation/internal/automation/coverage_report_test.go @@ -0,0 +1,313 @@ +package automation + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const coverageTestModulePath = "github.com/hatayama/unity-cli-loop/common" + +func TestParseGoCoverProfileCountsCoveredStatementsOutsideExcludedPaths(t *testing.T) { + // Verifies statements are totalled per block and blocks under an excluded path are left out. + profile := "mode: set\n" + + coverageTestModulePath + "/a/a.go:1.1,3.2 4 1\n" + + coverageTestModulePath + "/a/a.go:4.1,6.2 6 0\n" + + coverageTestModulePath + "/cmd/tool/main.go:1.1,9.2 10 0\n" + + totals, err := ParseGoCoverProfile(strings.NewReader(profile), []string{"/cmd/"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if totals.Statements != 10 || totals.Covered != 4 { + t.Fatalf("expected 4 of 10 statements covered, got %d of %d", totals.Covered, totals.Statements) + } +} + +func TestParseGoCoverProfileCountsARepeatedBlockOnceAsCoveredWhenAnyRunCoveredIt(t *testing.T) { + // Verifies a block reported by several test binaries counts once, covered if any run covered it. + profile := "mode: set\n" + + coverageTestModulePath + "/a/a.go:1.1,3.2 5 0\n" + + coverageTestModulePath + "/a/a.go:1.1,3.2 5 1\n" + + totals, err := ParseGoCoverProfile(strings.NewReader(profile), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if totals.Statements != 5 || totals.Covered != 5 { + t.Fatalf("expected 5 of 5 statements covered, got %d of %d", totals.Covered, totals.Statements) + } +} + +func TestParseGoCoverProfileRejectsMalformedInput(t *testing.T) { + // Verifies input go test would not write is an error rather than a figure, including a negative + // statement count that would otherwise push coverage past 100%. + cases := map[string]string{ + "empty": "", + "no mode header": coverageTestModulePath + "/a/a.go:1.1,3.2 5 1\n", + "malformed line": "mode: set\n" + coverageTestModulePath + "/a/a.go 5 1\n", + "unknown mode": "mode: bogus\n" + coverageTestModulePath + "/a/a.go:1.1,3.2 5 1\n", + "bad range": "mode: set\n" + coverageTestModulePath + "/a/a.go:whatever 5 1\n", + "negative count": "mode: set\n" + coverageTestModulePath + "/a/a.go:1.1,3.2 -6 0\n", + } + for name, profile := range cases { + t.Run(name, func(t *testing.T) { + if _, err := ParseGoCoverProfile(strings.NewReader(profile), nil); err == nil { + t.Fatalf("expected an error for %q", profile) + } + }) + } +} + +func TestRunCoverageReportGatePassesAtOrAboveBaseline(t *testing.T) { + // Verifies gate mode exits 0 and prints a table row when every module meets its baseline. + fixture := newCoverageFixture(t, map[string]float64{"common": 40.0}) + fixture.writeProfile("common", 4, 10) + + code, stdout, _ := fixture.run(coverageModeGate) + + if code != 0 { + t.Fatalf("expected exit 0, got %d\n%s", code, stdout) + } + if !strings.Contains(stdout, "| common | 40.0% | 40.0% |") { + t.Fatalf("expected a table row for common:\n%s", stdout) + } +} + +func TestRunCoverageReportGateFailsBelowBaselineButReportDoesNot(t *testing.T) { + // Verifies a module under its baseline fails gate mode and only warns in report mode. + fixture := newCoverageFixture(t, map[string]float64{"common": 50.0}) + fixture.writeProfile("common", 4, 10) + + gateCode, _, gateStderr := fixture.run(coverageModeGate) + reportCode, reportStdout, _ := fixture.run(coverageModeReport) + + if gateCode != 1 || !strings.Contains(gateStderr, "common") { + t.Fatalf("expected gate to fail naming common, got %d: %s", gateCode, gateStderr) + } + if reportCode != 0 || !strings.Contains(reportStdout, "::warning::") { + t.Fatalf("expected report to exit 0 with a warning, got %d:\n%s", reportCode, reportStdout) + } +} + +func TestRunCoverageReportToleratesRoundingJustUnderBaseline(t *testing.T) { + // Verifies a value within 0.1 point under the baseline still passes, so one-digit rounding of + // the stored baseline cannot fail an unchanged tree. + fixture := newCoverageFixture(t, map[string]float64{"common": 33.4}) + fixture.writeProfile("common", 1, 3) + + code, stdout, stderr := fixture.run(coverageModeGate) + + if code != 0 { + t.Fatalf("expected 33.33%% to pass a 33.4%% baseline, got %d\n%s%s", code, stdout, stderr) + } +} + +func TestRunCoverageReportShowsAndSuggestsTheSameTruncatedFigure(t *testing.T) { + // Verifies a measured 66.66% is shown and suggested as 66.6, never rounded up past what was + // measured, so raising the baseline to the shown figure cannot fail the same tree. + fixture := newCoverageFixture(t, map[string]float64{"common": 10.0}) + fixture.writeProfile("common", 2, 3) + + _, stdout, _ := fixture.run(coverageModeReport) + + if !strings.Contains(stdout, "| common | 66.6% | 10.0% | raise baseline to 66.6 |") { + t.Fatalf("expected the truncated figure in both columns:\n%s", stdout) + } +} + +func TestRunCoverageReportGateFailsJustPastTheRoundingTolerance(t *testing.T) { + // Verifies a module 0.2 point under its baseline fails, so the tolerance cannot widen past + // the one-decimal rounding it exists for without a test noticing. + fixture := newCoverageFixture(t, map[string]float64{"common": 40.2}) + fixture.writeProfile("common", 4, 10) + + code, stdout, _ := fixture.run(coverageModeGate) + + if code != 1 { + t.Fatalf("expected 40.0%% to fail a 40.2%% baseline, got %d\n%s", code, stdout) + } +} + +func TestCollectGoCoverProfilesNamesEachProfileAfterItsModule(t *testing.T) { + // Verifies every .out in the directory is collected under its module name, so a + // module whose profile appears without a baseline entry reaches the mismatch check. + dir := t.TempDir() + for _, name := range []string{"common.out", "new-module.out", "notes.txt"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("mode: set\n"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + profiles, err := CollectGoCoverProfiles(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(profiles) != 2 || + profiles["common"] != filepath.Join(dir, "common.out") || + profiles["new-module"] != filepath.Join(dir, "new-module.out") { + t.Fatalf("expected common and new-module profiles, got %v", profiles) + } +} + +func TestCollectGoCoverProfilesRejectsAMissingDirectory(t *testing.T) { + // Verifies a coverage directory that was never written fails instead of yielding no modules. + if _, err := CollectGoCoverProfiles(filepath.Join(t.TempDir(), "absent")); err == nil { + t.Fatal("expected an error for a missing directory") + } +} + +func TestRunCoverageReportHintsWhenTheBaselineCanBeRaised(t *testing.T) { + // Verifies a module a full point above its baseline is reported as ready to raise. + fixture := newCoverageFixture(t, map[string]float64{"common": 30.0}) + fixture.writeProfile("common", 4, 10) + + code, stdout, _ := fixture.run(coverageModeGate) + + if code != 0 || !strings.Contains(stdout, "raise") { + t.Fatalf("expected a raise hint, got %d:\n%s", code, stdout) + } +} + +func TestRunCoverageReportFailsClosedOnMismatchedInputs(t *testing.T) { + // Verifies a missing profile, an unknown module, a profile with no counted statements, and a + // missing profile file each fail both modes instead of silently passing the gate. + cases := map[string]func(fixture *coverageFixture){ + "baseline module without profile": func(fixture *coverageFixture) { + fixture.writeProfile("common", 4, 10) + delete(fixture.profiles, "dispatcher") + }, + "profile for module not in baseline": func(fixture *coverageFixture) { + fixture.writeProfile("common", 4, 10) + fixture.writeProfile("dispatcher", 4, 10) + fixture.writeProfile("unknown", 4, 10) + }, + "no counted statements": func(fixture *coverageFixture) { + fixture.writeProfile("common", 4, 10) + fixture.writeRawProfile("dispatcher", "mode: set\n") + }, + "profile file missing": func(fixture *coverageFixture) { + fixture.writeProfile("common", 4, 10) + fixture.profiles["dispatcher"] = filepath.Join(fixture.dir, "absent.out") + }, + } + for name, arrange := range cases { + for _, mode := range []string{coverageModeGate, coverageModeReport} { + t.Run(name+"/"+mode, func(t *testing.T) { + fixture := newCoverageFixture(t, map[string]float64{"common": 10.0, "dispatcher": 10.0}) + fixture.writeProfile("dispatcher", 4, 10) + arrange(fixture) + + code, stdout, stderr := fixture.run(mode) + + if code == 0 { + t.Fatalf("expected a non-zero exit, got 0\n%s%s", stdout, stderr) + } + }) + } + } +} + +func TestRunCoverageReportRejectsABaselineOutsideZeroToHundred(t *testing.T) { + // Verifies a negative baseline, which no coverage could fall below, fails instead of disabling + // the gate for that module. + fixture := newCoverageFixture(t, map[string]float64{"common": -1}) + fixture.writeProfile("common", 0, 10) + + if code, stdout, stderr := fixture.run(coverageModeGate); code == 0 { + t.Fatalf("expected a non-zero exit, got 0\n%s%s", stdout, stderr) + } +} + +func TestRunCoverageReportRejectsANullBaseline(t *testing.T) { + // Verifies a null figure is not read as 0%, which would pass the gate at any coverage. + fixture := newCoverageFixture(t, map[string]float64{"common": 10}) + fixture.writeProfile("common", 0, 10) + if err := os.WriteFile(fixture.baselinePath, []byte(`{"go":{"modules":{"common":null}}}`), 0o600); err != nil { + t.Fatalf("write baseline: %v", err) + } + + if code, stdout, stderr := fixture.run(coverageModeGate); code == 0 { + t.Fatalf("expected a non-zero exit, got 0\n%s%s", stdout, stderr) + } +} + +func TestRunCoverageReportWritesTheTableToTheStepSummary(t *testing.T) { + // Verifies the same table is appended to the step summary file when one is given. + fixture := newCoverageFixture(t, map[string]float64{"common": 40.0}) + fixture.writeProfile("common", 4, 10) + summaryPath := filepath.Join(fixture.dir, "summary.md") + fixture.summaryPath = summaryPath + + if code, stdout, stderr := fixture.run(coverageModeReport); code != 0 { + t.Fatalf("expected exit 0, got %d\n%s%s", code, stdout, stderr) + } + + summary, err := os.ReadFile(summaryPath) + if err != nil { + t.Fatalf("read summary: %v", err) + } + if !strings.Contains(string(summary), "| common | 40.0% | 40.0% |") { + t.Fatalf("expected the table in the step summary:\n%s", summary) + } +} + +type coverageFixture struct { + t *testing.T + dir string + baselinePath string + profiles map[string]string + summaryPath string +} + +func newCoverageFixture(t *testing.T, modules map[string]float64) *coverageFixture { + t.Helper() + dir := t.TempDir() + content, err := json.Marshal(map[string]any{ + "go": map[string]any{"exclude": []string{"/cmd/"}, "modules": modules}, + }) + if err != nil { + t.Fatalf("marshal baseline: %v", err) + } + baselinePath := filepath.Join(dir, "coverage-baseline.json") + if err := os.WriteFile(baselinePath, content, 0o600); err != nil { + t.Fatalf("write baseline: %v", err) + } + return &coverageFixture{t: t, dir: dir, baselinePath: baselinePath, profiles: map[string]string{}} +} + +// writeProfile writes a profile with one covered block and one uncovered block, plus an excluded +// block that must not change the result. +func (fixture *coverageFixture) writeProfile(module string, covered int, total int) { + fixture.t.Helper() + content := "mode: set\n" + + coverageTestModulePath + "/a/a.go:1.1,3.2 " + strconv.Itoa(covered) + " 1\n" + + coverageTestModulePath + "/a/a.go:4.1,6.2 " + strconv.Itoa(total-covered) + " 0\n" + + coverageTestModulePath + "/cmd/tool/main.go:1.1,9.2 7 0\n" + fixture.writeRawProfile(module, content) +} + +func (fixture *coverageFixture) writeRawProfile(module string, content string) { + fixture.t.Helper() + path := filepath.Join(fixture.dir, module+".out") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + fixture.t.Fatalf("write profile: %v", err) + } + fixture.profiles[module] = path +} + +func (fixture *coverageFixture) run(mode string) (int, string, string) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := RunCoverageReport(&stdout, &stderr, CoverageReportOptions{ + BaselinePath: fixture.baselinePath, + GoProfiles: fixture.profiles, + Mode: mode, + SummaryPath: fixture.summaryPath, + }) + return code, stdout.String(), stderr.String() +} diff --git a/coverage-baseline.json b/coverage-baseline.json new file mode 100644 index 0000000000..a3da00f041 --- /dev/null +++ b/coverage-baseline.json @@ -0,0 +1,11 @@ +{ + "go": { + "exclude": ["/cmd/", "/common/clitest"], + "modules": { + "common": 75.0, + "dispatcher": 79.6, + "project-runner": 84.3, + "release-automation": 77.0 + } + } +} diff --git a/docs/coverage.md b/docs/coverage.md new file mode 100644 index 0000000000..b956dc4dbc --- /dev/null +++ b/docs/coverage.md @@ -0,0 +1,45 @@ +# Test Coverage + +The repository tracks statement coverage of the Go CLI modules and keeps it from falling. + +## What is measured + +- Every Go module under `cli/` (`common`, `dispatcher`, `project-runner`, `release-automation`), + from `go test -coverprofile`. +- Blocks whose file path contains a pattern listed under `go.exclude` in + `coverage-baseline.json` are left out. The defaults exclude `cmd/` entry points, which only + parse flags and call into `internal/`, and `common/clitest`, which is a test helper. +- The figure is covered statements divided by all statements after exclusion, truncated to one + decimal. + +## The pull request gate + +The `build-cli` job in `build-and-test.yml` runs `scripts/check-go-cli.sh` with +`GO_COVERAGE_DIR` set, which writes one profile per module, then runs +`cli/release-automation/cmd/coverage-report --mode gate`. The step fails when a module is more +than 0.1 point below its figure in `coverage-baseline.json`, and also when a module's profile +is missing, a profile names a module the baseline does not list, or a module has no statements +left after exclusion. The report reads every `.out` in the coverage directory, so a +module newly added to `scripts/check-go-cli-source.sh` fails the gate until the baseline lists +it. The gate runs only on Linux, because build-tagged files make the figures +differ per OS. + +## Raising the baseline + +When a module is at least one point above its baseline, the table in the job summary shows +`raise baseline to
`. Copy that figure into `coverage-baseline.json` in the pull request +that added the tests. Lowering a figure needs a reason in the pull request description. + +## Local usage + +```sh +export GO_COVERAGE_DIR="$(mktemp -d)" +scripts/check-go-cli-source.sh +cd cli/release-automation +go run ./cmd/coverage-report --baseline ../../coverage-baseline.json --mode report \ + --go-coverage-dir "$GO_COVERAGE_DIR" +``` + +Use a fresh directory each time: the report reads every `.out` in it, so a profile left +from an earlier run would be compared too. `GO_COVERAGE_DIR` must be absolute, because each module's tests run from that module's +directory. Figures measured on macOS or Windows can differ slightly from the Linux gate. diff --git a/scripts/check-go-cli-source.sh b/scripts/check-go-cli-source.sh index 4be04671a0..0bdb1b4e78 100755 --- a/scripts/check-go-cli-source.sh +++ b/scripts/check-go-cli-source.sh @@ -13,6 +13,20 @@ if ! command -v golangci-lint >/dev/null 2>&1; then exit 1 fi +# GO_COVERAGE_DIR, when set, collects one coverage profile per module as +# .out for cli/release-automation/cmd/coverage-report. It must be +# absolute (POSIX or a Windows drive path) because each module's tests run +# from that module's directory. +if [ -n "${GO_COVERAGE_DIR:-}" ]; then + case "$GO_COVERAGE_DIR" in + /* | [A-Za-z]:[\\/]*) mkdir -p "$GO_COVERAGE_DIR" ;; + *) + echo "GO_COVERAGE_DIR must be an absolute path: $GO_COVERAGE_DIR" >&2 + exit 1 + ;; + esac +fi + run_module_checks() { module_dir="$1" @@ -21,7 +35,11 @@ run_module_checks() { golangci-lint fmt --config "$ROOT_DIR/cli/.golangci.yml" --diff go vet ./... golangci-lint run --config "$ROOT_DIR/cli/.golangci.yml" ./... - go test ./... + if [ -n "${GO_COVERAGE_DIR:-}" ]; then + go test -coverprofile="$GO_COVERAGE_DIR/$(basename "$module_dir").out" ./... + else + go test ./... + fi ) } From 1a6ca1cde775f595d945d08c2996ac7ca6e49672 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 03:55:00 +0900 Subject: [PATCH 02/68] chore: Skill scanning, tool docs and the tool catalog in the common CLI module now have tests for discovery and help rendering (#3074) --- cli/common/skilldocs/discover_test.go | 115 ++++++++++++ cli/common/skillscan/packages_test.go | 186 +++++++++++++++++++ cli/common/skillscan/sources_test.go | 133 +++++++++++++ cli/common/tooldocs/list_cli_options_test.go | 15 ++ cli/common/tooldocs/string_helpers_test.go | 17 ++ cli/common/tooldocs/tool_option_help_test.go | 82 ++++++++ cli/common/tooldocs/tool_options_test.go | 110 +++++++++++ cli/common/tools/catalog_test.go | 64 +++++++ cli/common/tools/types_test.go | 50 +++++ 9 files changed, 772 insertions(+) create mode 100644 cli/common/skilldocs/discover_test.go create mode 100644 cli/common/skillscan/sources_test.go create mode 100644 cli/common/tooldocs/list_cli_options_test.go create mode 100644 cli/common/tooldocs/string_helpers_test.go create mode 100644 cli/common/tooldocs/tool_options_test.go create mode 100644 cli/common/tools/types_test.go diff --git a/cli/common/skilldocs/discover_test.go b/cli/common/skilldocs/discover_test.go new file mode 100644 index 0000000000..0b45f2d0c8 --- /dev/null +++ b/cli/common/skilldocs/discover_test.go @@ -0,0 +1,115 @@ +package skilldocs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const noToolSkill = `--- +name: some-unrelated-skill +description: "Not a uloop tool skill." +--- +` + +// readCLIVibeLogs returns the concatenated CLI vibe log content written under projectRoot. +func readCLIVibeLogs(t *testing.T, projectRoot string) string { + t.Helper() + pattern := filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory, vibelog.CLIVibeLogPrefix+"_*.json") + paths, err := filepath.Glob(pattern) + if err != nil { + t.Fatalf("failed to glob vibe logs: %v", err) + } + var builder strings.Builder + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + builder.Write(content) + } + return builder.String() +} + +// Verifies that an empty project root loads nothing even when the working directory holds an +// installed package that a relative lookup would otherwise find. +func TestLoadWithoutAProjectRootReturnsNil(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"FirstPartyTools/SimulateKeyboard": singleToolSkill}) + t.Chdir(projectRoot) + + if docs := Load(""); docs != nil { + t.Fatalf("expected nil docs, got %v", docs) + } +} + +// Verifies that a project without the uloop package loads nothing and, with debug logging on, records +// why the embedded descriptions were kept. +func TestLoadWithoutAPackageLogsTheFallback(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + + if docs := Load(projectRoot); docs != nil { + t.Fatalf("expected nil docs, got %v", docs) + } + + logs := readCLIVibeLogs(t, projectRoot) + if !strings.Contains(logs, skillDocsLogOperation) || !strings.Contains(logs, "uloop package root not found") { + t.Fatalf("fallback was not logged: %q", logs) + } +} + +// Verifies that unreadable skills and skills documenting no tool are skipped and logged, while the +// readable skills beside them still load and stray files in the container are ignored. +func TestLoadSkipsUnusableSkillsAndKeepsTheRest(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "1") + projectRoot := writeFixtureProject(t, map[string]string{ + "FirstPartyTools/SimulateKeyboard": singleToolSkill, + "FirstPartyTools/Unrelated": noToolSkill, + }) + firstPartyTools := filepath.Join(projectRoot, "Packages", "src", "Editor", "FirstPartyTools") + // A directory named SKILL.md passes the existence check but cannot be read as a file. + if err := os.MkdirAll(filepath.Join(firstPartyTools, "Broken", "Skill", "SKILL.md"), 0o755); err != nil { + t.Fatalf("failed to create the unreadable skill: %v", err) + } + if err := os.MkdirAll(filepath.Join(firstPartyTools, "NoSkill"), 0o755); err != nil { + t.Fatalf("failed to create the skill-less tool folder: %v", err) + } + if err := os.WriteFile(filepath.Join(firstPartyTools, "README.md"), []byte("not a tool"), 0o644); err != nil { + t.Fatalf("failed to write a stray file: %v", err) + } + + docs := Load(projectRoot) + + if len(docs) != 1 { + t.Fatalf("expected only simulate-keyboard, got %v", docs) + } + if _, ok := docs["simulate-keyboard"]; !ok { + t.Fatalf("simulate-keyboard is missing: %v", docs) + } + logs := readCLIVibeLogs(t, projectRoot) + // Exactly one unreadable skill: a tool folder without a SKILL.md must be skipped before reading. + if count := strings.Count(logs, "skill file could not be read"); count != 1 { + t.Errorf("expected one unreadable-skill log entry, got %d: %q", count, logs) + } + if strings.Contains(logs, "NoSkill") { + t.Errorf("a tool folder without a skill must not be logged: %q", logs) + } + if !strings.Contains(logs, "skill file documented no tool") { + t.Errorf("log is missing the tool-less skill entry: %q", logs) + } +} + +// Verifies that with debug logging off the fallback leaves no log files behind. +func TestLoadWithoutDebugLoggingWritesNoLog(t *testing.T) { + t.Setenv(vibelog.CLIVibeLogEnvName, "") + projectRoot := t.TempDir() + + Load(projectRoot) + + if _, err := os.Stat(filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory)); !os.IsNotExist(err) { + t.Fatalf("expected no vibe log directory, stat error: %v", err) + } +} diff --git a/cli/common/skillscan/packages_test.go b/cli/common/skillscan/packages_test.go index b27c8b65be..81094ecd53 100644 --- a/cli/common/skillscan/packages_test.go +++ b/cli/common/skillscan/packages_test.go @@ -311,3 +311,189 @@ func TestEnumeratePackageSearchResultsResolvesRelativeManifestDependency(t *test t.Fatalf("package results mismatch:\nactual: %#v\nexpected: %#v", actual, expected) } } + +func mkdirAll(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", path, err) + } +} + +// Tests that Editor folders are found down to the depth limit, sorted, and never inside excluded or Editor folders. +func TestFindEditorFoldersHonorsDepthAndExclusions(t *testing.T) { + basePath := t.TempDir() + mkdirAll(t, filepath.Join(basePath, "Editor", "Editor")) + mkdirAll(t, filepath.Join(basePath, "B", "Editor")) + mkdirAll(t, filepath.Join(basePath, "A", "One", "Two", "Editor")) + mkdirAll(t, filepath.Join(basePath, "A", "One", "Two", "Three", "Editor")) + // A-B sorts before A/ ('-' < '/'), so a depth-first walk and the sorted result disagree. + mkdirAll(t, filepath.Join(basePath, "A-B", "Editor")) + mkdirAll(t, filepath.Join(basePath, "node_modules", "Editor")) + mkdirAll(t, filepath.Join(basePath, "C")) + if err := os.WriteFile(filepath.Join(basePath, "C", "Editor"), []byte("not a folder"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + actual := FindEditorFolders(basePath, SkillSearchMaxDepth) + + expected := []string{ + filepath.Join(basePath, "A-B", "Editor"), + filepath.Join(basePath, "A", "One", "Two", "Editor"), + filepath.Join(basePath, "B", "Editor"), + filepath.Join(basePath, "Editor"), + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("editor folders mismatch:\nactual: %#v\nexpected: %#v", actual, expected) + } +} + +// Tests that a missing base path yields an empty, non-nil folder list. +func TestFindEditorFoldersReturnsEmptyForMissingBase(t *testing.T) { + actual := FindEditorFolders(filepath.Join(t.TempDir(), "missing"), SkillSearchMaxDepth) + + if actual == nil || len(actual) != 0 { + t.Fatalf("expected an empty folder list, got %#v", actual) + } +} + +// Tests that the package is found in PackageCache by directory name alone when no manifest lists it, +// and that a nested Packages/src inside the cached directory is used as its root. +func TestFindUnityCliLoopPackageFallsBackToPackageCacheDirectoryName(t *testing.T) { + projectRoot := t.TempDir() + cacheDir := filepath.Join(projectRoot, "Library", "PackageCache") + cachedRoot := filepath.Join(cacheDir, packageNameAlias+"@1.0.0", "Packages", "src") + mkdirAll(t, filepath.Join(cachedRoot, "Editor", "FirstPartyTools")) + mkdirAll(t, filepath.Join(cacheDir, "com.example.unrelated@1.0.0")) + cachedFile := filepath.Join(cacheDir, packageName+"@file") + if err := os.WriteFile(cachedFile, []byte("not a folder"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + for _, searchResult := range EnumeratePackageSearchResults(projectRoot) { + if filepath.Clean(searchResult.Root) == filepath.Clean(cachedFile) { + t.Fatalf("a file in PackageCache must not be listed as a package: %#v", searchResult) + } + } + + result, ok := FindUnityCliLoopPackage(projectRoot) + + if !ok { + t.Fatal("cached package should be found") + } + if result.Identity.Name != packageNameAlias || filepath.Clean(result.Root) != filepath.Clean(cachedRoot) { + t.Fatalf("unexpected package result: %#v", result) + } +} + +// Tests that two package candidates with the same priority resolve to the lexicographically smaller +// root, even when the larger root is enumerated first. +func TestFindUnityCliLoopPackageBreaksPriorityTiesByRoot(t *testing.T) { + baseRoot := t.TempDir() + projectRoot := filepath.Join(baseRoot, "z-project") + directRoot := filepath.Join(projectRoot, "Packages", "custom") + writeTestPackageJSON(t, directRoot, packageName) + mkdirAll(t, filepath.Join(directRoot, "Editor", "FirstPartyTools")) + externalRoot := filepath.Join(baseRoot, "a-external") + mkdirAll(t, filepath.Join(externalRoot, "Editor", "FirstPartyTools")) + writeManifest(t, projectRoot, `{"dependencies":{"`+packageName+`":"file:`+filepath.ToSlash(externalRoot)+`"}}`) + + result, ok := FindUnityCliLoopPackage(projectRoot) + + if !ok || filepath.Clean(result.Root) != filepath.Clean(externalRoot) { + t.Fatalf("expected %s, got %#v (ok=%v)", externalRoot, result, ok) + } +} + +// Tests that a manifest entry with no name is dropped and that a package reached both directly and +// through the manifest under the same identity is listed once. +func TestEnumeratePackageSearchResultsDropsNamelessAndDuplicateEntries(t *testing.T) { + projectRoot := t.TempDir() + localRoot := filepath.Join(projectRoot, "Packages", "local") + writeTestPackageJSON(t, localRoot, "com.example.local") + writeManifest(t, projectRoot, `{"dependencies":{"":"file:local","com.example.local":"file:local"}}`) + + actual := packageResultSummaries(EnumeratePackageSearchResults(projectRoot)) + + expected := []string{"com.example.local|" + filepath.Clean(localRoot)} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("package results mismatch:\nactual: %#v\nexpected: %#v", actual, expected) + } +} + +// Tests that manifest entries sharing one local root are ordered by identity name. +func TestEnumeratePackageSearchResultsOrdersSharedRootsByIdentity(t *testing.T) { + projectRoot := t.TempDir() + sharedRoot := filepath.Join(t.TempDir(), "shared") + mkdirAll(t, sharedRoot) + sharedValue := "file:" + filepath.ToSlash(sharedRoot) + writeManifest(t, projectRoot, `{"dependencies":{"com.example.zeta":"`+sharedValue+`","com.example.alpha":"`+sharedValue+`"}}`) + + actual := packageResultSummaries(EnumeratePackageSearchResults(projectRoot)) + + expected := []string{ + "com.example.alpha|" + filepath.Clean(sharedRoot), + "com.example.zeta|" + filepath.Clean(sharedRoot), + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("package results mismatch:\nactual: %#v\nexpected: %#v", actual, expected) + } +} + +// Tests that an unparsable or mistyped manifest, or one without dependencies, yields no dependencies +// instead of a partially decoded set. +func TestReadManifestDependenciesReturnsEmptyForUnusableManifests(t *testing.T) { + for name, content := range map[string]string{ + "invalid json": `{"dependencies":`, + "null dependencies": `{"dependencies":null}`, + "mistyped entry": `{"dependencies":{"com.example.local":"file:local","bad":1}}`, + } { + t.Run(name, func(t *testing.T) { + projectRoot := t.TempDir() + writeManifest(t, projectRoot, content) + + dependencies := readManifestDependencies(projectRoot) + + if dependencies == nil || len(dependencies) != 0 { + t.Fatalf("expected empty dependencies, got %#v", dependencies) + } + }) + } +} + +// Tests that local dependency values that are not file:/path: references or are blank resolve to nothing. +func TestResolveLocalDependencyPathRejectsNonLocalValues(t *testing.T) { + for _, value := range []string{"1.0.0", "file:", "path: "} { + if resolved := resolveLocalDependencyPath(value, t.TempDir()); resolved != "" { + t.Errorf("resolveLocalDependencyPath(%q) = %q, want empty", value, resolved) + } + } +} + +// Tests that the cache directory version suffix is stripped and a name without one is kept. +func TestPackageIdentityNameFromCacheDir(t *testing.T) { + cases := map[string]string{ + packageName + "@1.2.3": packageName, + packageName: packageName, + } + for dirName, expected := range cases { + if actual := packageIdentityNameFromCacheDir(dirName); actual != expected { + t.Errorf("packageIdentityNameFromCacheDir(%q) = %q, want %q", dirName, actual, expected) + } + } +} + +// Tests the package root priority order: Packages/src, the package name, its alias, other locations, then PackageCache. +func TestUnityCliLoopPackagePriority(t *testing.T) { + projectRoot := t.TempDir() + cases := map[string]int{ + filepath.Join(projectRoot, "Packages", "src"): 0, + filepath.Join(projectRoot, "Packages", packageName): 1, + filepath.Join(projectRoot, "Packages", packageNameAlias): 2, + filepath.Join(t.TempDir(), "elsewhere"): 10, + filepath.Join(projectRoot, "Library", "PackageCache", packageName+"@1.0.0"): 20, + } + for packageRoot, expected := range cases { + if actual := unityCliLoopPackagePriority(projectRoot, packageRoot); actual != expected { + t.Errorf("priority(%q) = %d, want %d", packageRoot, actual, expected) + } + } +} diff --git a/cli/common/skillscan/sources_test.go b/cli/common/skillscan/sources_test.go new file mode 100644 index 0000000000..2f39038619 --- /dev/null +++ b/cli/common/skillscan/sources_test.go @@ -0,0 +1,133 @@ +package skillscan + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func writeSkillFile(t *testing.T, skillDirectory string, content string) { + t.Helper() + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create skill directory: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDirectory, SkillFileName), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write skill file: %v", err) + } +} + +func internalSkill(fields string) string { + return "---\ninternal: true\n" + fields + "---\n\n# Body\n" +} + +// Verifies that internal tool names are collected from the CLI-only root and from Editor folders, +// using toolName, a uloop- prefixed name, or the folder name, and that public skills are ignored. +func TestCollectInternalSkillToolNamesReadsInternalSkillsFromEverySource(t *testing.T) { + projectRoot := t.TempDir() + cliOnlyRoot := CliOnlySourceRoot(projectRoot) + writeSkillFile(t, filepath.Join(cliOnlyRoot, "Explicit", "Skill"), internalSkill("toolName: explicit-tool\n")) + writeSkillFile(t, filepath.Join(cliOnlyRoot, "Prefixed", "Skill"), internalSkill("name: uloop-prefixed-tool\n")) + writeSkillFile(t, filepath.Join(cliOnlyRoot, "uloop-folder-tool", "Skill"), internalSkill("")) + writeSkillFile(t, filepath.Join(cliOnlyRoot, "Unprefixed", "Skill"), internalSkill("name: other-tool\n")) + writeSkillFile(t, filepath.Join(cliOnlyRoot, "Public", "Skill"), "---\nname: uloop-public-tool\n---\n") + writeSkillFile(t, filepath.Join(cliOnlyRoot, "uloop-bare-tool"), internalSkill("")) + + assetsRoot := filepath.Join(projectRoot, "Assets") + writeSkillFile(t, filepath.Join(assetsRoot, "Feature", "Editor", "Tool", "Skill"), internalSkill("toolName: assets-editor-tool\n")) + writeSkillFile(t, filepath.Join(assetsRoot, "Feature", "Runtime", "Tool", "Skill"), internalSkill("toolName: assets-runtime-tool\n")) + writeSkillFile(t, filepath.Join(assetsRoot, "Feature", "Editor", "node_modules", "Skill"), internalSkill("toolName: excluded-tool\n")) + writeSkillFile(t, filepath.Join(assetsRoot, "Feature", "Editor", "Tool", "Skill", "Nested", "Skill"), internalSkill("toolName: nested-tool\n")) + + toolNames := CollectInternalSkillToolNames(projectRoot) + + expected := map[string]bool{ + "explicit-tool": true, + "prefixed-tool": true, + "folder-tool": true, + "bare-tool": true, + "assets-editor-tool": true, + } + if !reflect.DeepEqual(toolNames, expected) { + t.Fatalf("internal tool names mismatch:\nactual: %#v\nexpected: %#v", toolNames, expected) + } +} + +// Verifies that a project with no skill sources yields an empty, non-nil tool name set. +func TestCollectInternalSkillToolNamesReturnsEmptySetForEmptyProject(t *testing.T) { + toolNames := CollectInternalSkillToolNames(t.TempDir()) + + if toolNames == nil || len(toolNames) != 0 { + t.Fatalf("expected an empty tool name set, got %#v", toolNames) + } +} + +// Verifies that a skill directory whose SKILL.md cannot be read is skipped instead of reported. +func TestReadInternalSkillToolNameRejectsMissingSkillFile(t *testing.T) { + toolName, ok := readInternalSkillToolName(filepath.Join(t.TempDir(), "Missing", "Skill")) + + if ok || toolName != "" { + t.Fatalf("expected no tool name, got %q (ok=%v)", toolName, ok) + } +} + +// Verifies that a manifest dependency pointing at the Assets folder does not list Assets twice. +func TestEnumerateSourceRootsDeduplicatesRootsReachedTwice(t *testing.T) { + projectRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(projectRoot, "Assets"), 0o755); err != nil { + t.Fatalf("failed to create Assets: %v", err) + } + writeManifest(t, projectRoot, `{"dependencies":{"com.example.assets":"file:../Assets"}}`) + + actualPaths := sourceRootPaths(EnumerateSourceRoots(projectRoot)) + + expectedPaths := cleanPaths([]string{ + CliOnlySourceRoot(projectRoot), + filepath.Join(projectRoot, "Assets"), + }) + if !reflect.DeepEqual(actualPaths, expectedPaths) { + t.Fatalf("source roots mismatch:\nactual: %#v\nexpected: %#v", actualPaths, expectedPaths) + } +} + +// Verifies that the fallback skill name skips a trailing Skill folder and otherwise uses the folder itself. +func TestFallbackSkillName(t *testing.T) { + cases := map[string]string{ + filepath.Join("Tools", "uloop-sample", "Skill"): "uloop-sample", + filepath.Join("Tools", "uloop-direct"): "uloop-direct", + } + for skillDirectory, expected := range cases { + if actual := FallbackSkillName(skillDirectory); actual != expected { + t.Errorf("FallbackSkillName(%q) = %q, want %q", skillDirectory, actual, expected) + } + } +} + +// Verifies that frontmatter parsing requires both delimiters, trims quotes, and ignores lines without a colon. +func TestParseSkillFrontmatter(t *testing.T) { + cases := []struct { + name string + content string + expected map[string]string + }{ + {name: "no leading delimiter", content: "name: uloop-x\n", expected: map[string]string{}}, + {name: "unterminated", content: "---\nname: uloop-x\n", expected: map[string]string{}}, + { + name: "quoted values and plain lines", + content: "---\nname: \"uloop-x\"\ntoolName: 'x-tool'\nnot a pair\ndescription: a: b\n---\nbody: ignored\n", + expected: map[string]string{ + "name": "uloop-x", + "toolName": "x-tool", + "description": "a: b", + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + actual := ParseSkillFrontmatter(testCase.content) + if !reflect.DeepEqual(actual, testCase.expected) { + t.Fatalf("frontmatter mismatch:\nactual: %#v\nexpected: %#v", actual, testCase.expected) + } + }) + } +} diff --git a/cli/common/tooldocs/list_cli_options_test.go b/cli/common/tooldocs/list_cli_options_test.go new file mode 100644 index 0000000000..cd7e0d9cda --- /dev/null +++ b/cli/common/tooldocs/list_cli_options_test.go @@ -0,0 +1,15 @@ +package tooldocs + +import "testing" + +// Verifies that list exposes exactly one CLI-only flag, the boolean --names switch. +func TestListCLIOnlyOptionsDeclaresNamesFlag(t *testing.T) { + options := ListCLIOnlyOptions() + + if len(options) != 1 { + t.Fatalf("expected one list option, got %#v", options) + } + if options[0].FlagName != ListNamesFlagName || options[0].Type != "boolean" || options[0].Description == "" { + t.Fatalf("unexpected list option: %#v", options[0]) + } +} diff --git a/cli/common/tooldocs/string_helpers_test.go b/cli/common/tooldocs/string_helpers_test.go new file mode 100644 index 0000000000..1b2cfab9ee --- /dev/null +++ b/cli/common/tooldocs/string_helpers_test.go @@ -0,0 +1,17 @@ +package tooldocs + +import "testing" + +// Verifies that the first non-blank line is returned trimmed and that a blank description yields nothing. +func TestFirstHelpLine(t *testing.T) { + cases := map[string]string{ + "\n First line \nSecond line": "First line", + "\n \n\t": "", + "": "", + } + for description, expected := range cases { + if actual := FirstHelpLine(description); actual != expected { + t.Errorf("FirstHelpLine(%q) = %q, want %q", description, actual, expected) + } + } +} diff --git a/cli/common/tooldocs/tool_option_help_test.go b/cli/common/tooldocs/tool_option_help_test.go index 100971c742..fd40197c32 100644 --- a/cli/common/tooldocs/tool_option_help_test.go +++ b/cli/common/tooldocs/tool_option_help_test.go @@ -1,6 +1,7 @@ package tooldocs import ( + "reflect" "testing" "github.com/hatayama/unity-cli-loop/common/tools" @@ -88,3 +89,84 @@ func TestOptionSummaryKeepsPlainOptionDescriptions(t *testing.T) { t.Errorf("a plain option's description must be printed as written: %q", summary) } } + +// Verifies the usage placeholder for each schema type and the default/values text in each description, +// and that hidden properties are left out of the listing. +func TestVisibleOptionHelpEntriesForToolRendersUsageAndDefaults(t *testing.T) { + tool := toolWithProperties("my-custom-command", map[string]tools.ToolProperty{ + "Count": {Type: "integer", Description: "How many", Default: 3}, + "Ratio": {Type: "number", Description: "Scale"}, + "Items": {Type: "array", Description: "Entries"}, + "Payload": {Type: "object", Description: "Extra data"}, + "Label": {Type: "string", Description: "Text label", Default: ""}, + "Mode": {Type: "string", Description: "Mode to use", DefaultValue: float64(1), Enum: []string{"Fast", "Slow"}}, + "Verbose": {Type: "boolean", Description: "Print more", Default: false}, + "Secret": {Type: "string", Hidden: true}, + }) + + actual := VisibleOptionHelpEntriesForTool(tool) + + expected := []OptionHelpEntry{ + {Name: "--count", Usage: "--count ", Description: "How many; default: 3"}, + {Name: "--items", Usage: "--items ", Description: "Entries"}, + {Name: "--label", Usage: "--label ", Description: "Text label"}, + {Name: "--mode", Usage: "--mode ", Description: "Mode to use; default: Slow; values: Fast|Slow"}, + {Name: "--payload", Usage: "--payload ", Description: "Extra data"}, + {Name: "--ratio", Usage: "--ratio ", Description: "Scale"}, + {Name: "--verbose", Usage: "--verbose", Description: "Print more; default: disabled"}, + } + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("help entries mismatch:\nactual: %#v\nexpected: %#v", actual, expected) + } +} + +// Verifies that the CLI-only --code-file and --skip-compile help rows are added when the schema lacks +// them and that a schema-declared row is kept instead of being duplicated. +func TestVisibleOptionHelpEntriesForToolAddsCLIOnlyRowsOnce(t *testing.T) { + cases := []struct { + name string + tool tools.ToolDefinition + expected []OptionHelpEntry + }{ + { + name: "code-file added", + tool: toolWithProperties(executeDynamicCodeCommandName, map[string]tools.ToolProperty{}), + expected: []OptionHelpEntry{ + {Name: DynamicCodeFileOptionName, Usage: DynamicCodeFileOptionUsage, Description: DynamicCodeFileOptionDescription}, + }, + }, + { + name: "code-file already declared", + tool: toolWithProperties(executeDynamicCodeCommandName, map[string]tools.ToolProperty{ + "CodeFile": {Type: "string", Description: "Schema code file"}, + }), + expected: []OptionHelpEntry{ + {Name: DynamicCodeFileOptionName, Usage: DynamicCodeFileOptionName + " ", Description: "Schema code file"}, + }, + }, + { + name: "skip-compile added", + tool: toolWithProperties(runTestsCommandName, map[string]tools.ToolProperty{}), + expected: []OptionHelpEntry{ + {Name: RunTestsSkipCompileOptionName, Usage: RunTestsSkipCompileOptionUsage, Description: RunTestsSkipCompileOptionDescription}, + }, + }, + { + name: "skip-compile already declared", + tool: toolWithProperties(runTestsCommandName, map[string]tools.ToolProperty{ + "SkipCompile": {Type: "boolean", Description: "Schema skip compile"}, + }), + expected: []OptionHelpEntry{ + {Name: RunTestsSkipCompileOptionName, Usage: RunTestsSkipCompileOptionName, Description: "Schema skip compile"}, + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + actual := VisibleOptionHelpEntriesForTool(testCase.tool) + if !reflect.DeepEqual(actual, testCase.expected) { + t.Fatalf("help entries mismatch:\nactual: %#v\nexpected: %#v", actual, testCase.expected) + } + }) + } +} diff --git a/cli/common/tooldocs/tool_options_test.go b/cli/common/tooldocs/tool_options_test.go new file mode 100644 index 0000000000..a823e66cfc --- /dev/null +++ b/cli/common/tooldocs/tool_options_test.go @@ -0,0 +1,110 @@ +package tooldocs + +import ( + "reflect" + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +func toolWithProperties(name string, properties map[string]tools.ToolProperty) tools.ToolDefinition { + return tools.ToolDefinition{ + Name: name, + InputSchema: tools.ToolInputSchema{Type: "object", Properties: properties}, + } +} + +// Verifies that FindProperty maps a kebab-case flag back to its schema property, reports negated +// booleans, and reports a miss for an unknown flag. +func TestFindProperty(t *testing.T) { + tool := toolWithProperties("get-hierarchy", map[string]tools.ToolProperty{ + "IncludeComponents": {Type: "boolean", Default: true}, + "MaxDepth": {Type: "integer"}, + }) + + cases := []struct { + flag string + expectedName string + expectedNegated bool + expectedFound bool + }{ + {flag: "no-include-components", expectedName: "IncludeComponents", expectedNegated: true, expectedFound: true}, + {flag: "max-depth", expectedName: "MaxDepth", expectedFound: true}, + {flag: "include-components"}, + } + for _, testCase := range cases { + t.Run(testCase.flag, func(t *testing.T) { + name, property, negated, found := FindProperty(tool, testCase.flag) + if name != testCase.expectedName || negated != testCase.expectedNegated || found != testCase.expectedFound { + t.Fatalf("FindProperty(%q) = (%q, %v, %v), want (%q, %v, %v)", + testCase.flag, name, negated, found, testCase.expectedName, testCase.expectedNegated, testCase.expectedFound) + } + if found && property.Type == "" { + t.Fatalf("FindProperty(%q) returned an empty property", testCase.flag) + } + }) + } +} + +// Verifies that visible option names skip hidden properties, are sorted, and use the +// compile-specific name for the negated ReloadExternalSceneChanges flag. +func TestVisibleOptionNamesForToolSkipsHiddenAndSorts(t *testing.T) { + tool := toolWithProperties(compileCommandName, map[string]tools.ToolProperty{ + "ForceRecompile": {Type: "boolean"}, + ReloadExternalSceneChangesPropertyName: {Type: "boolean", Default: true}, + "InternalOnly": {Type: "string", Hidden: true}, + }) + + actual := VisibleOptionNamesForTool(tool) + + expected := []string{"--force-recompile", "--stop-on-external-scene-changes"} + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("option names mismatch:\nactual: %#v\nexpected: %#v", actual, expected) + } +} + +// Verifies that the CLI-only --code-file and --skip-compile flags are added to their tools exactly once, +// whether or not the tool schema already declares them. +func TestVisibleOptionNamesForToolAddsCLIOnlyFlagsOnce(t *testing.T) { + cases := []struct { + name string + tool tools.ToolDefinition + expected []string + }{ + { + name: "code-file added", + tool: toolWithProperties(executeDynamicCodeCommandName, map[string]tools.ToolProperty{"Code": {Type: "string"}}), + expected: []string{"--code", DynamicCodeFileOptionName}, + }, + { + name: "code-file already declared", + tool: toolWithProperties(executeDynamicCodeCommandName, map[string]tools.ToolProperty{"CodeFile": {Type: "string"}}), + expected: []string{DynamicCodeFileOptionName}, + }, + { + name: "skip-compile added", + tool: toolWithProperties(runTestsCommandName, map[string]tools.ToolProperty{"FilterValue": {Type: "string"}}), + expected: []string{"--filter-value", RunTestsSkipCompileOptionName}, + }, + { + name: "skip-compile already declared", + tool: toolWithProperties(runTestsCommandName, map[string]tools.ToolProperty{"SkipCompile": {Type: "boolean"}}), + expected: []string{RunTestsSkipCompileOptionName}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + actual := VisibleOptionNamesForTool(testCase.tool) + if !reflect.DeepEqual(actual, testCase.expected) { + t.Fatalf("option names mismatch:\nactual: %#v\nexpected: %#v", actual, testCase.expected) + } + }) + } +} + +// Verifies that an empty property name converts to an empty flag name. +func TestPascalToKebabKeepsEmptyValue(t *testing.T) { + if actual := pascalToKebab(""); actual != "" { + t.Fatalf("pascalToKebab(\"\") = %q, want empty", actual) + } +} diff --git a/cli/common/tools/catalog_test.go b/cli/common/tools/catalog_test.go index b7dab9894d..5637df84e1 100644 --- a/cli/common/tools/catalog_test.go +++ b/cli/common/tools/catalog_test.go @@ -60,3 +60,67 @@ func writeToolCache(t *testing.T, projectRoot string, content string) { t.Fatalf("failed to write tool cache: %v", err) } } + +// Tests that without a project cache the embedded catalog is used and internal tools are filtered out of it. +func TestLoadFiltersInternalToolsFromEmbeddedCatalog(t *testing.T) { + projectRoot := t.TempDir() + + cache, err := Load(projectRoot, map[string]bool{"execute-dynamic-code": true}) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if _, ok := Find(cache, "execute-dynamic-code"); ok { + t.Fatal("internal tool should be filtered from the embedded catalog") + } + if _, ok := Find(cache, "compile"); !ok { + t.Fatal("public embedded tool should remain in the catalog") + } +} + +// Tests that an unparsable project cache is ignored so callers fall back to the embedded catalog. +func TestLoadProjectCacheRejectsInvalidJSON(t *testing.T) { + projectRoot := t.TempDir() + writeToolCache(t, projectRoot, `{"tools":`) + + if _, ok := LoadProjectCache(projectRoot, nil); ok { + t.Fatal("invalid project cache should not load") + } + cache, err := Load(projectRoot, nil) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if _, ok := Find(cache, "compile"); !ok { + t.Fatal("Load should fall back to the embedded catalog") + } +} + +// Tests that a project cache drops internal tools and keeps the other tools in order. +func TestLoadProjectCacheFiltersInternalTools(t *testing.T) { + projectRoot := t.TempDir() + writeToolCache(t, projectRoot, `{"serverVersion":"1.2.3","tools":[{"name":"first"},{"name":"hidden-tool"},{"name":"second"}]}`) + + cache, ok := LoadProjectCache(projectRoot, map[string]bool{"hidden-tool": true}) + + if !ok { + t.Fatal("project cache should load") + } + if cache.ServerVersion != "1.2.3" || len(cache.Tools) != 2 || cache.Tools[0].Name != "first" || cache.Tools[1].Name != "second" { + t.Fatalf("unexpected filtered cache: %#v", cache) + } +} + +// Tests that a project lookup without a cache reports a miss for a command the catalog does not define. +func TestFindForCommandReportsUnknownCommand(t *testing.T) { + _, cache, ok, err := FindForCommand(t.TempDir(), "no-such-command", nil, false) + if err != nil { + t.Fatalf("FindForCommand failed: %v", err) + } + + if ok { + t.Fatal("unknown command should not be found") + } + if len(cache.Tools) == 0 { + t.Fatal("the loaded catalog should still be returned") + } +} diff --git a/cli/common/tools/types_test.go b/cli/common/tools/types_test.go new file mode 100644 index 0000000000..b4e60062f1 --- /dev/null +++ b/cli/common/tools/types_test.go @@ -0,0 +1,50 @@ +package tools + +import "testing" + +// Tests that EffectiveDefault prefers Default and falls back to DefaultValue when Default is unset. +func TestToolPropertyEffectiveDefault(t *testing.T) { + cases := []struct { + name string + property ToolProperty + expected any + }{ + {name: "default wins", property: ToolProperty{Default: "a", DefaultValue: "b"}, expected: "a"}, + {name: "defaultValue fallback", property: ToolProperty{DefaultValue: "b"}, expected: "b"}, + {name: "neither", property: ToolProperty{}, expected: nil}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if actual := testCase.property.EffectiveDefault(); actual != testCase.expected { + t.Fatalf("EffectiveDefault() = %#v, want %#v", actual, testCase.expected) + } + }) + } +} + +// Tests that the input schema wins whenever it carries a type, properties, or required names, and +// that the parameter schema is used only when the input schema is empty. +func TestToolDefinitionEffectiveInputSchema(t *testing.T) { + parameterSchema := ToolInputSchema{Type: "parameter"} + cases := []struct { + name string + inputSchema ToolInputSchema + expectsInput bool + }{ + {name: "type only", inputSchema: ToolInputSchema{Type: "object"}, expectsInput: true}, + {name: "properties only", inputSchema: ToolInputSchema{Properties: map[string]ToolProperty{"Name": {}}}, expectsInput: true}, + {name: "required only", inputSchema: ToolInputSchema{Required: []string{"Name"}}, expectsInput: true}, + {name: "empty", inputSchema: ToolInputSchema{}, expectsInput: false}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + tool := ToolDefinition{InputSchema: testCase.inputSchema, ParameterSchema: parameterSchema} + + usedParameterSchema := tool.EffectiveInputSchema().Type == "parameter" + + if usedParameterSchema == testCase.expectsInput { + t.Fatalf("expected input schema=%v, got %#v", testCase.expectsInput, tool.EffectiveInputSchema()) + } + }) + } +} From 005f01bb15be99bad60758dbc4664420c7c1ac6e Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 03:57:13 +0900 Subject: [PATCH 03/68] chore: Dispatcher pin guards in the release-automation CLI now have tests for failure handling and release lookups (#3075) --- .../dispatcher_minimum_version_guard_test.go | 138 ++++++++ .../automation/dispatcher_pin_freshness.go | 15 +- .../dispatcher_pin_freshness_test.go | 176 ++++++++++ .../automation/dispatcher_pin_guard_test.go | 181 +++++++++++ .../automation/dispatcher_pin_push_test.go | 175 ++++++++++ .../automation/dispatcher_pin_stamp.go | 14 +- .../automation/dispatcher_pin_stamp_test.go | 307 ++++++++++++++++++ .../package_pin_consistency_test.go | 72 ++++ 8 files changed, 1068 insertions(+), 10 deletions(-) diff --git a/cli/release-automation/internal/automation/dispatcher_minimum_version_guard_test.go b/cli/release-automation/internal/automation/dispatcher_minimum_version_guard_test.go index 837a203ea9..b9a754940a 100644 --- a/cli/release-automation/internal/automation/dispatcher_minimum_version_guard_test.go +++ b/cli/release-automation/internal/automation/dispatcher_minimum_version_guard_test.go @@ -327,3 +327,141 @@ func assertDispatcherMinimumVersionLogDoesNotContain(t *testing.T, actual string t.Fatalf("expected log not to contain %q, got:\n%s", unexpected, actual) } } + +// Verifies the guard fails with a repository-root message when git cannot be run. +func TestRunDispatcherMinimumVersionCheck_WhenGitIsUnavailable_Fails(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunDispatcherMinimumVersionCheck(context.Background(), &stdout, &stderr, "") + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertDispatcherMinimumVersionLogContains(t, stderr.String(), "failed to resolve git repository root") +} + +// Verifies an older minimum dispatcher whose release tag has no contract at any known path fails and names every path tried. +func TestRunDispatcherMinimumVersionCheck_WhenMinimumReleaseHasNoContract_Fails(t *testing.T) { + result := runDispatcherMinimumVersionCheckCase(t, dispatcherMinimumVersionCase{ + currentProjectRunnerVersion: "3.0.0-beta.40", + currentDispatcherVersion: "1.0.1", + minimumDispatcherVersion: "1.0.0", + }) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + assertDispatcherMinimumVersionLogContains(t, result.stderr, "dispatcher release dispatcher-v1.0.0 does not provide") + for _, path := range dispatcherContractPathChain { + assertDispatcherMinimumVersionLogContains(t, result.stderr, path) + } +} + +// Verifies a minimum dispatcher release whose contract declares another version fails the guard. +func TestRunDispatcherMinimumVersionCheck_WhenMinimumReleaseContractDeclaresAnotherVersion_Fails(t *testing.T) { + result := runDispatcherMinimumVersionCheckCase(t, dispatcherMinimumVersionCase{ + currentProjectRunnerVersion: "3.0.0-beta.40", + currentDispatcherVersion: "1.0.1", + minimumDispatcherVersion: "1.0.0", + releaseContract: `{"dispatcherVersion":"0.9.0"}`, + }) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + assertDispatcherMinimumVersionLogContains(t, result.stderr, `contract declares dispatcherVersion "0.9.0"`) +} + +// Verifies each in-tree file the guard needs is reported by path when it is missing from the working tree. +func TestDispatcherMinimumVersionValuesAtRef_WhenFileIsMissing_ReportsThePath(t *testing.T) { + files := []string{cliContractFile, dispatcherContractFile, unityPackageCliPinFile, unityProjectCliPinFile} + for _, missingFile := range files { + t.Run(missingFile, func(t *testing.T) { + workDir := t.TempDir() + prepareDispatcherMinimumVersionFiles(t, workDir, dispatcherMinimumVersionCase{ + currentProjectRunnerVersion: "3.0.0", + minimumDispatcherVersion: "1.0.0", + }) + if err := os.Remove(filepath.Join(workDir, missingFile)); err != nil { + t.Fatalf("remove %s: %v", missingFile, err) + } + + _, err := dispatcherMinimumVersionValuesAtRef(context.Background(), workDir, "") + + if err == nil || !strings.Contains(err.Error(), "failed to read "+missingFile) { + t.Fatalf("expected a read error for %s, got %v", missingFile, err) + } + }) + } +} + +// Verifies a non-empty ref reads the file through git show instead of the working tree. +func TestDispatcherMinimumVersionFileAtRef_WithRef_ReadsThroughGit(t *testing.T) { + workDir := t.TempDir() + mockBin := filepath.Join(workDir, "bin") + if err := os.MkdirAll(mockBin, 0o755); err != nil { + t.Fatalf("failed to create mock bin: %v", err) + } + writeDispatcherMinimumVersionMockGit(t, filepath.Join(mockBin, "git")) + releaseContractPath := filepath.Join(workDir, "release-contract.json") + writeFile(t, releaseContractPath, `{"dispatcherVersion":"1.0.0"}`) + t.Setenv("PATH", mockBin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("GIT_LOG", filepath.Join(workDir, "git.log")) + t.Setenv("GIT_RELEASE_CONTRACT", releaseContractPath) + + content, err := dispatcherMinimumVersionFileAtRef(context.Background(), workDir, "dispatcher-v1.0.0", dispatcherContractFile) + if err != nil { + t.Fatalf("expected git show to succeed, got %v", err) + } + assertDispatcherMinimumVersionLogContains(t, content, `"dispatcherVersion":"1.0.0"`) +} + +// Verifies malformed or incomplete contracts and pins, and drifting pin versions, are each rejected with the offending file named. +func TestParseDispatcherMinimumVersionValues_RejectsInvalidInputs(t *testing.T) { + validCli := buildDispatcherMinimumVersionCliContract("3.0.0") + validDispatcher := buildDispatcherMinimumVersionContract("1.0.0") + validPin := buildDispatcherMinimumVersionPin("3.0.0", "1.0.0") + cases := []struct { + name string + cli string + dispatcher string + packagePin string + projectPin string + wantErr string + }{ + {"cli contract invalid", "{", validDispatcher, validPin, validPin, cliContractFile + " is invalid JSON"}, + {"cli contract without version", `{}`, validDispatcher, validPin, validPin, cliContractFile + " does not define projectRunnerVersion"}, + {"dispatcher contract invalid", validCli, "{", validPin, validPin, dispatcherContractFile + " is invalid JSON"}, + {"dispatcher contract without version", validCli, `{}`, validPin, validPin, dispatcherContractFile + " does not define dispatcherVersion"}, + {"package pin invalid", validCli, validDispatcher, "{", validPin, unityPackageCliPinFile + " is invalid JSON"}, + {"package pin without runner", validCli, validDispatcher, `{"minimumDispatcherVersion":"1.0.0"}`, validPin, unityPackageCliPinFile + " does not define projectRunnerVersion"}, + {"project pin without minimum", validCli, validDispatcher, validPin, `{"projectRunnerVersion":"3.0.0"}`, unityProjectCliPinFile + " does not define minimumDispatcherVersion"}, + {"package pin runner drift", validCli, validDispatcher, buildDispatcherMinimumVersionPin("2.9.0", "1.0.0"), validPin, unityPackageCliPinFile + ` projectRunnerVersion "2.9.0" does not match ` + cliContractFile}, + {"project pin runner drift", validCli, validDispatcher, validPin, buildDispatcherMinimumVersionPin("2.9.0", "1.0.0"), unityProjectCliPinFile + ` projectRunnerVersion "2.9.0" does not match ` + unityPackageCliPinFile}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := parseDispatcherMinimumVersionValues( + []byte(testCase.cli), []byte(testCase.dispatcher), []byte(testCase.packagePin), []byte(testCase.projectPin)) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// Verifies an unparsable release contract fails, while one without dispatcherVersion passes because only a declared version can contradict the pin. +func TestVerifyMinimumCliReleaseDispatcherContract_HandlesInvalidAndVersionlessContracts(t *testing.T) { + values := dispatcherMinimumVersionValues{PackagePinMinimumDispatcherVersion: "1.0.0"} + + err := verifyMinimumCliReleaseDispatcherContract(values, []byte("{")) + if err == nil || !strings.Contains(err.Error(), "dispatcher release contract is invalid JSON") { + t.Fatalf("expected an invalid JSON error, got %v", err) + } + if err := verifyMinimumCliReleaseDispatcherContract(values, []byte(`{"protocolVersion":2}`)); err != nil { + t.Fatalf("expected a versionless contract to pass, got %v", err) + } +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_freshness.go b/cli/release-automation/internal/automation/dispatcher_pin_freshness.go index f23b8ad660..9c382499af 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_freshness.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_freshness.go @@ -57,8 +57,10 @@ func RunDispatcherPinFreshnessCheck(ctx context.Context, stdout io.Writer, stder func defaultDispatcherPinFreshnessDeps() dispatcherPinFreshnessDeps { return dispatcherPinFreshnessDeps{ - fetchReleases: fetchDispatcherReleases, - readPin: os.ReadFile, + fetchReleases: func(ctx context.Context, repository string) ([]dispatcherRelease, error) { + return fetchDispatcherReleases(ctx, dispatcherPinStampAPIBaseURL, repository) + }, + readPin: os.ReadFile, } } @@ -172,11 +174,13 @@ func newestStableDispatcherRelease(releases []dispatcherRelease) (string, string return newestTag, newestVersion } -func fetchDispatcherReleases(ctx context.Context, repository string) ([]dispatcherRelease, error) { +// fetchDispatcherReleases takes the API base URL from the caller so tests can +// point it at a local server instead of GitHub. +func fetchDispatcherReleases(ctx context.Context, apiBaseURL string, repository string) ([]dispatcherRelease, error) { client := &http.Client{Timeout: dispatcherPinFreshnessRequestTimeout} releases := []dispatcherRelease{} for page := 1; page <= dispatcherPinFreshnessMaxPages; page++ { - pageReleases, err := fetchDispatcherReleasePage(ctx, client, repository, page) + pageReleases, err := fetchDispatcherReleasePage(ctx, client, apiBaseURL, repository, page) if err != nil { return nil, err } @@ -191,12 +195,13 @@ func fetchDispatcherReleases(ctx context.Context, repository string) ([]dispatch func fetchDispatcherReleasePage( ctx context.Context, client *http.Client, + apiBaseURL string, repository string, page int, ) ([]dispatcherRelease, error) { requestURL := fmt.Sprintf( "%s/repos/%s/releases?per_page=%d&page=%d", - dispatcherPinStampAPIBaseURL, + apiBaseURL, repository, dispatcherPinFreshnessPageSize, page) diff --git a/cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go b/cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go index bf9010ef91..e2ae8e2380 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_freshness_test.go @@ -4,6 +4,9 @@ import ( "bytes" "context" "fmt" + "net/http" + "net/http/httptest" + "path/filepath" "strings" "testing" ) @@ -226,3 +229,176 @@ func TestParseDispatcherPinFreshnessFlagsPrefersTheExplicitRepository(t *testing t.Fatalf("expected the flag repository, got %q", config.repository) } } + +func TestRunDispatcherPinFreshnessCheckRejectsUnknownFlags(t *testing.T) { + // Verifies an unknown flag fails the command with exit code 1 and a command-prefixed error. + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunDispatcherPinFreshnessCheck(context.Background(), &stdout, &stderr, []string{"--unknown"}) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), dispatcherPinFreshnessCommandName+": flag provided but not defined") { + t.Fatalf("expected an unknown flag error, got %q", stderr.String()) + } +} + +func TestParseDispatcherPinFreshnessFlagsPointsAtThePackagePin(t *testing.T) { + // Verifies the pin path is the package pin two directories above the module, where the command runs. + config, err := parseDispatcherPinFreshnessFlags([]string{"--repo", "owner/repository"}) + if err != nil { + t.Fatalf("expected flag parsing to succeed, got %v", err) + } + want := filepath.Join("..", "..", "Packages", "src", "project-runner-pin.json") + if config.pinPath != want { + t.Fatalf("pinPath = %q, want %q", config.pinPath, want) + } +} + +func TestResolveDispatcherPinFreshnessRepositoryUsesWorkflowRepository(t *testing.T) { + // Verifies GITHUB_REPOSITORY is used when no --repo flag is given. + t.Setenv("GITHUB_REPOSITORY", "environment/repository") + + if repository := resolveDispatcherPinFreshnessRepository(""); repository != "environment/repository" { + t.Fatalf("expected the workflow repository, got %q", repository) + } +} + +func TestRunDispatcherPinFreshnessCheckFailsWhenPinIsInvalidJSON(t *testing.T) { + // Verifies a malformed pin fails the guard instead of comparing against an empty tag. + result := runDispatcherPinFreshnessCase(t, "{not json", nil, nil) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", result.exitCode) + } + if !strings.Contains(result.stderr, "is invalid JSON") { + t.Fatalf("expected an invalid JSON error, got %q", result.stderr) + } +} + +func TestNewestStableDispatcherReleaseKeepsHighestVersionRegardlessOfOrder(t *testing.T) { + // Verifies an older release listed after a newer one does not replace the newest stable release. + releases := []dispatcherRelease{ + stableDispatcherRelease("dispatcher-v3.2.0"), + stableDispatcherRelease("dispatcher-v3.1.0"), + } + + tag, version := newestStableDispatcherRelease(releases) + + if tag != "dispatcher-v3.2.0" || version != "3.2.0" { + t.Fatalf("expected dispatcher-v3.2.0 / 3.2.0, got %q / %q", tag, version) + } +} + +// fullDispatcherReleasePage renders a release listing page holding exactly the page size. +func fullDispatcherReleasePage() string { + entries := make([]string, dispatcherPinFreshnessPageSize) + for index := range entries { + entries[index] = fmt.Sprintf(`{"tag_name":"v1.0.%d"}`, index) + } + return "[" + strings.Join(entries, ",") + "]" +} + +func TestFetchDispatcherReleasesFollowsPagesUntilAShortPage(t *testing.T) { + // Verifies full pages are followed to the next page and the listing ends at the first short page. + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GH_TOKEN", "test-token") + requestedPages := []string{} + requestedPaths := []string{} + requestedPageSizes := []string{} + authorization := "" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestedPages = append(requestedPages, request.URL.Query().Get("page")) + requestedPaths = append(requestedPaths, request.URL.Path) + requestedPageSizes = append(requestedPageSizes, request.URL.Query().Get("per_page")) + authorization = request.Header.Get("Authorization") + if request.URL.Query().Get("page") == "1" { + _, _ = writer.Write([]byte(fullDispatcherReleasePage())) + return + } + _, _ = writer.Write([]byte(`[{"tag_name":"dispatcher-v3.0.0","prerelease":true}]`)) + })) + defer server.Close() + + releases, err := fetchDispatcherReleases(context.Background(), server.URL, "owner/repository") + if err != nil { + t.Fatalf("fetchDispatcherReleases failed: %v", err) + } + if len(releases) != dispatcherPinFreshnessPageSize+1 { + t.Fatalf("expected %d releases, got %d", dispatcherPinFreshnessPageSize+1, len(releases)) + } + last := releases[len(releases)-1] + if last.TagName != "dispatcher-v3.0.0" || !last.Prerelease { + t.Fatalf("last release = %+v", last) + } + if strings.Join(requestedPages, ",") != "1,2" { + t.Fatalf("requested pages = %v", requestedPages) + } + wantPageSize := fmt.Sprint(dispatcherPinFreshnessPageSize) + for index := range requestedPaths { + if requestedPaths[index] != "/repos/owner/repository/releases" || requestedPageSizes[index] != wantPageSize { + t.Fatalf("request %d path = %q, per_page = %q", index, requestedPaths[index], requestedPageSizes[index]) + } + } + if authorization != "Bearer test-token" { + t.Fatalf("authorization = %q", authorization) + } +} + +func TestFetchDispatcherReleasesFailsWhenListingNeverEnds(t *testing.T) { + // Verifies a listing that keeps returning full pages fails instead of reporting a release from a truncated listing. + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte(fullDispatcherReleasePage())) + })) + defer server.Close() + + releases, err := fetchDispatcherReleases(context.Background(), server.URL, "owner/repository") + + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("exceeded %d pages", dispatcherPinFreshnessMaxPages)) { + t.Fatalf("expected a page limit error, got %d releases and error %v", len(releases), err) + } +} + +func TestFetchDispatcherReleasesReportsUnusablePages(t *testing.T) { + // Verifies an error status and an undecodable page fail the listing. + cases := []struct { + name string + status int + body string + wantErr string + }{ + {"server error", http.StatusInternalServerError, `[]`, "GitHub release list API returned 500"}, + {"invalid body", http.StatusOK, "{", "decode GitHub release list"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(testCase.status) + _, _ = writer.Write([]byte(testCase.body)) + })) + defer server.Close() + + _, err := fetchDispatcherReleases(context.Background(), server.URL, "owner/repository") + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +func TestFetchDispatcherReleasesReportsRequestFailures(t *testing.T) { + // Verifies an unbuildable request URL and an unreachable server both fail the listing. + if _, err := fetchDispatcherReleases(context.Background(), "http://bad host", "owner/repository"); err == nil || !strings.Contains(err.Error(), "build GitHub release list request") { + t.Fatalf("expected a request build error, got %v", err) + } + + server := httptest.NewServer(http.NotFoundHandler()) + closedURL := server.URL + server.Close() + if _, err := fetchDispatcherReleases(context.Background(), closedURL, "owner/repository"); err == nil { + t.Fatal("expected an unreachable server to fail") + } +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go index f1c3a9c286..0548825935 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_guard_test.go @@ -2,9 +2,13 @@ package automation import ( "context" + "crypto/sha256" "errors" + "fmt" "strings" "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" ) func TestValidateDispatcherPinOfflineRejectsManifestWithoutRequiredArchive(t *testing.T) { @@ -159,3 +163,180 @@ func validDispatcherPinGuardDeps() dispatcherPinStampDeps { func validDispatcherPinGuardFixture() []byte { return []byte(`{"projectRunnerVersion":"3.0.0-beta.47","minimumDispatcherVersion":"3.0.1-beta.6","dispatcherReleaseTag":"dispatcher-v3.0.1-beta.6","dispatcherArchiveManifest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa install.sh\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb install.ps1\ncccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc uloop-dispatcher-darwin-amd64.tar.gz\ndddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd uloop-dispatcher-darwin-arm64.tar.gz\neeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee uloop-dispatcher-windows-amd64.zip\nffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff uloop-dispatcher-linux-amd64.tar.gz"}`) } + +func TestValidateDispatcherPinOfflineRejectsMalformedPinFields(t *testing.T) { + // Verifies invalid JSON, a foreign tag prefix, non-semver versions, non-hex digests, and repeated assets are each rejected. + digestA := strings.Repeat("a", 64) + digestB := strings.Repeat("b", 64) + cases := []struct { + name string + pin string + wantErr string + }{ + {"invalid JSON", "{", "is invalid JSON"}, + {"foreign tag prefix", `{"minimumDispatcherVersion":"3.0.0","dispatcherReleaseTag":"v3.0.0"}`, `dispatcherReleaseTag must start with "dispatcher-v"`}, + {"non-semver tag", `{"minimumDispatcherVersion":"3.0.0","dispatcherReleaseTag":"dispatcher-vlatest"}`, "dispatcher versions must be semver"}, + {"non-semver minimum", `{"minimumDispatcherVersion":"next","dispatcherReleaseTag":"dispatcher-v3.0.0"}`, "dispatcher versions must be semver"}, + {"non-hex digest", `{"minimumDispatcherVersion":"3.0.0","dispatcherReleaseTag":"dispatcher-v3.0.0","dispatcherArchiveManifest":"` + strings.Repeat("z", 64) + ` install.sh"}`, "invalid dispatcherArchiveManifest entry"}, + {"repeated asset", `{"minimumDispatcherVersion":"3.0.0","dispatcherReleaseTag":"dispatcher-v3.0.0","dispatcherArchiveManifest":"` + digestA + ` install.sh\n` + digestB + ` install.sh"}`, `repeats asset "install.sh"`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + pin := []byte(testCase.pin) + + err := ValidateDispatcherPinOffline(pin, pin) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +func TestValidateDispatcherPinOfflineAcceptsCompleteSortedManifest(t *testing.T) { + // Verifies a byte-identical pin with a sorted manifest covering every required asset passes. + pin := validDispatcherPinGuardFixture() + + if err := ValidateDispatcherPinOffline(pin, pin); err != nil { + t.Fatalf("expected the valid fixture to pass, got %v", err) + } +} + +func TestVerifyDispatcherPinSubjectsRejectsInvalidPinBeforeFetching(t *testing.T) { + // Verifies the exported verifier fails on an unparsable pin before contacting GitHub. + err := VerifyDispatcherPinSubjects(context.Background(), []byte("{")) + + if err == nil || !strings.Contains(err.Error(), "is invalid JSON") { + t.Fatalf("expected an invalid JSON error, got %v", err) + } +} + +func TestVerifyDispatcherPinSubjectsReportsEachVerificationStepFailure(t *testing.T) { + // Verifies a missing installer, bundle, tag, and attestation failure each fail closed with a step-specific error. + cases := []struct { + name string + mutate func(*dispatcherPinStampDeps) + wantErr string + }{ + {"missing installer", func(deps *dispatcherPinStampDeps) { + deps.fetchReleaseAssets = func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{{Name: "install.ps1", URL: "https://example.invalid/install.ps1"}}, nil + } + }, `missing required asset "install.sh"`}, + {"bundle", func(deps *dispatcherPinStampDeps) { + deps.fetchBundle = func(context.Context, string) ([]byte, error) { return nil, errors.New("no bundle") } + }, "fetch dispatcher installer attestation bundle: no bundle"}, + {"tag commit", func(deps *dispatcherPinStampDeps) { + deps.fetchTagCommitSHA = func(context.Context, string, string) (string, error) { return "", errors.New("no tag") } + }, "resolve dispatcher release tag commit: no tag"}, + {"attestation", func(deps *dispatcherPinStampDeps) { + deps.verifySubjects = func([]byte, string) (map[string]string, error) { return nil, errors.New("bad signature") } + }, "verify dispatcher release attestation: bad signature"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + deps := validDispatcherPinGuardDeps() + testCase.mutate(&deps) + + err := verifyDispatcherPinSubjects(context.Background(), validDispatcherPinGuardFixture(), deps) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +func TestVerifyDispatcherPinSubjectsComparesManifestWithVerifiedSubjects(t *testing.T) { + // Verifies a pin matching the verified subjects passes and a pin with a different digest is rejected. + deps := validDispatcherPinGuardDeps() + if err := verifyDispatcherPinSubjects(context.Background(), validDispatcherPinGuardFixture(), deps); err != nil { + t.Fatalf("expected the matching pin to pass, got %v", err) + } + + alteredPin := []byte(strings.Replace(string(validDispatcherPinGuardFixture()), strings.Repeat("a", 64), strings.Repeat("0", 64), 1)) + err := verifyDispatcherPinSubjects(context.Background(), alteredPin, deps) + + if err == nil || !strings.Contains(err.Error(), "does not exactly match verified release subjects") { + t.Fatalf("expected a manifest mismatch error, got %v", err) + } +} + +func TestDispatcherPinScriptDriftWarningsRejectsUnusableInputs(t *testing.T) { + // Verifies an unparsable pin, a malformed manifest entry, and a missing source installer are errors rather than warnings. + installers := map[string][]byte{"install.sh": []byte("a"), "install.ps1": []byte("b")} + cases := []struct { + name string + pin []byte + scripts map[string][]byte + wantErr string + }{ + {"invalid JSON", []byte("{"), installers, "is invalid JSON"}, + {"malformed entry", []byte(`{"dispatcherArchiveManifest":"no-separator"}`), installers, "invalid dispatcherArchiveManifest entry"}, + {"missing installer", validDispatcherPinGuardFixture(), map[string][]byte{"install.sh": []byte("a")}, `source installer "install.ps1" is unavailable`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := DispatcherPinScriptDriftWarnings(testCase.pin, testCase.scripts) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +func TestDispatcherPinScriptDriftWarningsIsSilentWhenScriptsMatchThePin(t *testing.T) { + // Verifies installers whose digests match the pinned manifest, in any hex case, produce no warnings. + installSh := []byte("echo install") + installPs1 := []byte("Write-Host install") + pin := fmt.Sprintf(`{"dispatcherReleaseTag":"dispatcher-v3.0.0","dispatcherArchiveManifest":"%X install.sh\n%x install.ps1"}`, + sha256.Sum256(installSh), sha256.Sum256(installPs1)) + + warnings, err := DispatcherPinScriptDriftWarnings([]byte(pin), map[string][]byte{"install.sh": installSh, "install.ps1": installPs1}) + if err != nil { + t.Fatalf("DispatcherPinScriptDriftWarnings failed: %v", err) + } + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got %v", warnings) + } +} + +func TestVerifyDispatcherPinSubjectsVerifiesTheReleaseThePinNames(t *testing.T) { + // Verifies the release assets, installer bundle, tag commit, and attestation are all looked up for the tag the pin records. + deps := validDispatcherPinGuardDeps() + baseAssets := deps.fetchReleaseAssets + baseVerify := deps.verifySubjects + var assetTag, bundleURL, commitRepository, commitTag, verifiedCommit string + deps.fetchReleaseAssets = func(ctx context.Context, tag string) ([]dispatcherReleaseAsset, error) { + assetTag = tag + return baseAssets(ctx, tag) + } + deps.fetchBundle = func(_ context.Context, url string) ([]byte, error) { + bundleURL = url + return []byte("bundle"), nil + } + deps.fetchTagCommitSHA = func(_ context.Context, repository string, tag string) (string, error) { + commitRepository = repository + commitTag = tag + return "pinned-commit", nil + } + deps.verifySubjects = func(bundle []byte, commit string) (map[string]string, error) { + verifiedCommit = commit + return baseVerify(bundle, commit) + } + + if err := verifyDispatcherPinSubjects(context.Background(), validDispatcherPinGuardFixture(), deps); err != nil { + t.Fatalf("verifyDispatcherPinSubjects failed: %v", err) + } + + if assetTag != "dispatcher-v3.0.1-beta.6" || commitTag != "dispatcher-v3.0.1-beta.6" { + t.Fatalf("asset tag = %q, commit tag = %q", assetTag, commitTag) + } + if bundleURL != "https://example.invalid/install.sh.sigstore.json" { + t.Fatalf("bundle URL = %q", bundleURL) + } + if commitRepository != attestation.ReleaseRepository || verifiedCommit != "pinned-commit" { + t.Fatalf("commit repository = %q, verified commit = %q", commitRepository, verifiedCommit) + } +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_push_test.go b/cli/release-automation/internal/automation/dispatcher_pin_push_test.go index 86d0ad6533..592d797f50 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_push_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_push_test.go @@ -255,3 +255,178 @@ func assertNoDispatcherPinPushCommand(t *testing.T, recorder *dispatcherPinPushC } } } + +func TestParseDispatcherPinPushFlagsRejectsInvalidInput(t *testing.T) { + // Verifies unknown flags and tags that are not dispatcher release tags are refused by flag parsing itself. + cases := []struct { + name string + args []string + wantErr string + }{ + {"unknown flag", []string{"--unknown"}, "flag provided but not defined"}, + {"not a dispatcher tag", []string{"--tag", "v3.0.1", "--base-branch", "main"}, `release tag "v3.0.1" must start with dispatcher-v`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := parseDispatcherPinPushFlags(testCase.args) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// failingDispatcherPinPushRunner fails the first git invocation whose +// arguments contain failOn, and reports a changed pin for git status. +type failingDispatcherPinPushRunner struct { + failOn string + commands []string +} + +func (runner *failingDispatcherPinPushRunner) run(_ context.Context, name string, args ...string) (string, error) { + runner.commands = append(runner.commands, name+" "+strings.Join(args, " ")) + if containsDispatcherPinPushArg(args, runner.failOn) { + return "", errors.New(runner.failOn + " failed") + } + if containsDispatcherPinPushArg(args, "status") { + return " M Packages/src/project-runner-pin.json\n", nil + } + return "", nil +} + +func TestRunPushDispatcherPinStopsAtTheFirstFailingGitStep(t *testing.T) { + // Verifies each failing git step aborts the command with its error and no later step runs. + cases := []struct { + failOn string + neverAfter string + }{ + {"fetch", "checkout"}, + {"checkout", "status"}, + {"status", "add"}, + {"add", "commit"}, + {"commit", "push"}, + } + for _, testCase := range cases { + t.Run(testCase.failOn, func(t *testing.T) { + repositoryRoot := setupDispatcherPinPushRepository(t, dispatcherPinPushContent("dispatcher-v3.0.0")) + runner := &failingDispatcherPinPushRunner{failOn: testCase.failOn} + deps := dispatcherPinPushTestDeps(repositoryRoot, &dispatcherPinPushCommandRecorder{}, dispatcherPinPushContent(dispatcherPinPushStableTag)) + deps.runOutput = runner.run + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runPushDispatcherPinWithDeps(context.Background(), &stdout, &stderr, dispatcherPinPushTestConfig(), deps) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), testCase.failOn+" failed") { + t.Fatalf("expected the %s failure in stderr, got %q", testCase.failOn, stderr.String()) + } + for _, command := range runner.commands { + if strings.Contains(command, " "+testCase.neverAfter) { + t.Fatalf("expected no %s after a failed %s, got %v", testCase.neverAfter, testCase.failOn, runner.commands) + } + } + }) + } +} + +func TestRunPushDispatcherPinFailsBeforeGitWhenRepositoryRootIsUnknown(t *testing.T) { + // Verifies a repository root lookup failure stops the command before any git command runs. + recorder := &dispatcherPinPushCommandRecorder{} + deps := dispatcherPinPushTestDeps("", recorder, "") + deps.repositoryRoot = func(context.Context) (string, error) { return "", errors.New("not a repository") } + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runPushDispatcherPinWithDeps(context.Background(), &stdout, &stderr, dispatcherPinPushTestConfig(), deps) + + if exitCode != 1 || !strings.Contains(stderr.String(), "not a repository") { + t.Fatalf("expected the repository root failure, got exit %d and %q", exitCode, stderr.String()) + } + if len(recorder.commands) != 0 { + t.Fatalf("expected no git commands, got %v", recorder.commands) + } +} + +func TestStampAndVerifyDispatcherPinReportsStampAndFileFailures(t *testing.T) { + // Verifies stamp failures, a removed stamped pin, and an unwritable mirror each stop before subject verification. + cases := []struct { + name string + prepare func(t *testing.T, repositoryRoot string, deps *dispatcherPinPushDeps) + wantErr string + }{ + {"stamp", func(_ *testing.T, _ string, deps *dispatcherPinPushDeps) { + deps.stampPin = func(context.Context, string, string) error { return errors.New("stamp failed") } + }, "stamp failed"}, + {"stamped pin removed", func(_ *testing.T, _ string, deps *dispatcherPinPushDeps) { + deps.stampPin = func(_ context.Context, pinPath string, _ string) error { return os.Remove(pinPath) } + }, "read stamped pin"}, + {"mirror unwritable", func(t *testing.T, repositoryRoot string, _ *dispatcherPinPushDeps) { + projectPinPath := filepath.Join(repositoryRoot, filepath.FromSlash(unityProjectCliPinFile)) + if err := os.Remove(projectPinPath); err != nil { + t.Fatalf("remove project pin: %v", err) + } + if err := os.Mkdir(projectPinPath, 0o755); err != nil { + t.Fatalf("replace project pin with a directory: %v", err) + } + }, "mirror stamped pin"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + repositoryRoot := setupDispatcherPinPushRepository(t, dispatcherPinPushContent("dispatcher-v3.0.0")) + verified := false + deps := dispatcherPinPushTestDeps(repositoryRoot, &dispatcherPinPushCommandRecorder{}, dispatcherPinPushContent(dispatcherPinPushStableTag)) + deps.verifySubjects = func(context.Context, []byte) error { + verified = true + return nil + } + testCase.prepare(t, repositoryRoot, &deps) + + err := stampAndVerifyDispatcherPin(context.Background(), repositoryRoot, dispatcherPinPushStableTag, deps) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + if verified { + t.Fatal("expected subject verification to be skipped") + } + }) + } +} + +func TestDispatcherPinPushRepositoryRootResolvesTheCheckout(t *testing.T) { + // Verifies the repository root comes from git and contains this module's directory. + root, err := dispatcherPinPushRepositoryRoot(context.Background()) + if err != nil { + t.Fatalf("dispatcherPinPushRepositoryRoot failed: %v", err) + } + info, statErr := os.Stat(filepath.Join(root, "cli", "release-automation", "go.mod")) + if statErr != nil || info.IsDir() { + t.Fatalf("expected %s to contain cli/release-automation/go.mod, stat error %v", root, statErr) + } +} + +func TestParseDispatcherPinPushFlagsAcceptsStableTag(t *testing.T) { + // Verifies a stable dispatcher tag and base branch are returned unchanged in the config. + config, err := parseDispatcherPinPushFlags([]string{"--tag", dispatcherPinPushStableTag, "--base-branch", "main"}) + if err != nil { + t.Fatalf("parseDispatcherPinPushFlags failed: %v", err) + } + if config.tag != dispatcherPinPushStableTag || config.baseBranch != "main" { + t.Fatalf("config = %+v", config) + } +} + +func TestDispatcherPinPushRepositoryRootReportsGitFailure(t *testing.T) { + // Verifies a git binary that cannot run is reported as a repository root failure. + t.Setenv("PATH", t.TempDir()) + + _, err := dispatcherPinPushRepositoryRoot(context.Background()) + + if err == nil || !strings.Contains(err.Error(), "resolve repository root") { + t.Fatalf("expected a repository root error, got %v", err) + } +} diff --git a/cli/release-automation/internal/automation/dispatcher_pin_stamp.go b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go index ae0e617c7c..c1d1bef79c 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_stamp.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp.go @@ -41,9 +41,11 @@ func StampDispatcherPin(ctx context.Context, pinPath string, releaseTag string) func defaultDispatcherPinStampDeps() dispatcherPinStampDeps { return dispatcherPinStampDeps{ - fetchReleaseAssets: fetchDispatcherReleaseAssets, - fetchBundle: attestation.FetchBundle, - fetchTagCommitSHA: attestation.FetchTagCommitSHA, + fetchReleaseAssets: func(ctx context.Context, releaseTag string) ([]dispatcherReleaseAsset, error) { + return fetchDispatcherReleaseAssets(ctx, dispatcherPinStampAPIBaseURL, releaseTag) + }, + fetchBundle: attestation.FetchBundle, + fetchTagCommitSHA: attestation.FetchTagCommitSHA, verifySubjects: func(bundleData []byte, commitSHA string) (map[string]string, error) { trustedMaterial, err := attestation.LoadEmbeddedTrustedMaterial() if err != nil { @@ -197,10 +199,12 @@ func writeDispatcherPinStamp(pinPath string, releaseTag string, manifest string) return nil } -func fetchDispatcherReleaseAssets(ctx context.Context, releaseTag string) ([]dispatcherReleaseAsset, error) { +// fetchDispatcherReleaseAssets takes the API base URL from the caller so tests +// can point it at a local server instead of GitHub. +func fetchDispatcherReleaseAssets(ctx context.Context, apiBaseURL string, releaseTag string) ([]dispatcherReleaseAsset, error) { requestURL := fmt.Sprintf( "%s/repos/%s/releases/tags/%s", - dispatcherPinStampAPIBaseURL, + apiBaseURL, attestation.ReleaseRepository, releaseTag) request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) diff --git a/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go index 40a5bab190..11523cb612 100644 --- a/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go +++ b/cli/release-automation/internal/automation/dispatcher_pin_stamp_test.go @@ -3,9 +3,15 @@ package automation import ( "context" "encoding/json" + "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" ) func TestStampDispatcherPinWritesOnlyVerifiedReleaseSubjects(t *testing.T) { @@ -116,3 +122,304 @@ func readDispatcherPinForStamp(t *testing.T, pinPath string) map[string]string { } return values } + +func TestStampDispatcherPinRejectsEmptyReleaseTag(t *testing.T) { + // Verifies the exported entry point fails before any network call when no release tag is given. + pinPath := writeDispatcherPinForStamp(t, `{"projectRunnerVersion":"3.0.0"}`) + + err := StampDispatcherPin(context.Background(), pinPath, "") + + if err == nil || !strings.Contains(err.Error(), "release tag is required") { + t.Fatalf("expected missing release tag error, got %v", err) + } +} + +func TestStampDispatcherPinReportsEachDependencyFailure(t *testing.T) { + // Verifies every fetch and verification failure aborts the stamp with a step-specific error and leaves the pin untouched. + cases := []struct { + name string + mutate func(*dispatcherPinStampDeps) + wantErr string + }{ + {"release assets", func(deps *dispatcherPinStampDeps) { + deps.fetchReleaseAssets = func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return nil, errors.New("api down") + } + }, "fetch dispatcher release assets: api down"}, + {"missing installer", func(deps *dispatcherPinStampDeps) { + deps.fetchReleaseAssets = func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{{Name: "install.ps1", URL: "https://example.test/install.ps1"}}, nil + } + }, `missing required asset "install.sh"`}, + {"installer without URL", func(deps *dispatcherPinStampDeps) { + deps.fetchReleaseAssets = func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{{Name: "install.sh"}}, nil + } + }, `asset "install.sh" has no download URL`}, + {"missing powershell installer", func(deps *dispatcherPinStampDeps) { + deps.fetchReleaseAssets = func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{{Name: "install.sh", URL: "https://example.test/install.sh"}}, nil + } + }, `missing required asset "install.ps1"`}, + {"bundle", func(deps *dispatcherPinStampDeps) { + deps.fetchBundle = func(context.Context, string) ([]byte, error) { return nil, errors.New("no bundle") } + }, "fetch dispatcher installer attestation bundle: no bundle"}, + {"tag commit", func(deps *dispatcherPinStampDeps) { + deps.fetchTagCommitSHA = func(context.Context, string, string) (string, error) { return "", errors.New("no tag") } + }, "resolve dispatcher release tag commit: no tag"}, + {"verification", func(deps *dispatcherPinStampDeps) { + deps.verifySubjects = func([]byte, string) (map[string]string, error) { return nil, errors.New("bad signature") } + }, "verify dispatcher release attestation: bad signature"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + initialPin := `{"projectRunnerVersion":"3.0.0"}` + pinPath := writeDispatcherPinForStamp(t, initialPin) + deps := validDispatcherPinStampDeps() + testCase.mutate(&deps) + + err := stampDispatcherPin(context.Background(), pinPath, "dispatcher-v3.0.1", deps) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + assertDispatcherPinFileContent(t, pinPath, initialPin) + }) + } +} + +func TestStampDispatcherPinFetchesInstallerBundleAndTagCommit(t *testing.T) { + // Verifies assets, the installer bundle, and the tag commit are fetched for the requested tag in the release repository before verification. + pinPath := writeDispatcherPinForStamp(t, `{}`) + deps := validDispatcherPinStampDeps() + var assetTag, bundleURL, resolvedRepository, resolvedTag, verifiedCommit string + baseAssets := deps.fetchReleaseAssets + deps.fetchReleaseAssets = func(ctx context.Context, tag string) ([]dispatcherReleaseAsset, error) { + assetTag = tag + return baseAssets(ctx, tag) + } + deps.fetchBundle = func(_ context.Context, url string) ([]byte, error) { + bundleURL = url + return []byte("bundle"), nil + } + deps.fetchTagCommitSHA = func(_ context.Context, repository string, tag string) (string, error) { + resolvedRepository = repository + resolvedTag = tag + return "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil + } + baseVerify := deps.verifySubjects + deps.verifySubjects = func(bundle []byte, commit string) (map[string]string, error) { + verifiedCommit = commit + return baseVerify(bundle, commit) + } + + if err := stampDispatcherPin(context.Background(), pinPath, "dispatcher-v3.0.1", deps); err != nil { + t.Fatalf("stampDispatcherPin failed: %v", err) + } + if bundleURL != "https://example.test/install.sh.sigstore.json" { + t.Fatalf("bundle URL = %q", bundleURL) + } + if assetTag != "dispatcher-v3.0.1" || resolvedTag != "dispatcher-v3.0.1" { + t.Fatalf("asset tag = %q, resolved tag = %q", assetTag, resolvedTag) + } + if resolvedRepository != attestation.ReleaseRepository { + t.Fatalf("resolved repository = %q", resolvedRepository) + } + if verifiedCommit != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Fatalf("verified commit = %q", verifiedCommit) + } +} + +func TestBuildDispatcherArchiveManifestRejectsInconsistentReleases(t *testing.T) { + // Verifies the manifest builder refuses invalid names, duplicates, bad digests, empty releases, and extra subjects. + validDigest := strings.Repeat("a", 64) + cases := []struct { + name string + assets []dispatcherReleaseAsset + subjects map[string]string + wantErr string + }{ + {"empty name", []dispatcherReleaseAsset{{Name: ""}}, map[string]string{}, "invalid asset name"}, + {"newline in name", []dispatcherReleaseAsset{{Name: "a\nb"}}, map[string]string{}, "invalid asset name"}, + {"duplicate", []dispatcherReleaseAsset{{Name: "a.zip"}, {Name: "a.zip"}}, map[string]string{"a.zip": validDigest}, `duplicate asset "a.zip"`}, + {"short digest", []dispatcherReleaseAsset{{Name: "a.zip"}}, map[string]string{"a.zip": "abc"}, "invalid attested SHA-256 digest"}, + {"non-hex digest", []dispatcherReleaseAsset{{Name: "a.zip"}}, map[string]string{"a.zip": strings.Repeat("g", 64)}, "invalid attested SHA-256 digest"}, + {"only bundles", []dispatcherReleaseAsset{{Name: "install.sh.sigstore.json"}}, map[string]string{}, "no attested assets"}, + {"extra subject", []dispatcherReleaseAsset{{Name: "a.zip"}}, map[string]string{"a.zip": validDigest, "b.zip": validDigest}, "do not exactly match"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + manifest, err := buildDispatcherArchiveManifest(testCase.assets, testCase.subjects) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got manifest %q and error %v", testCase.wantErr, manifest, err) + } + }) + } +} + +func TestBuildDispatcherArchiveManifestAcceptsUppercaseDigests(t *testing.T) { + // Verifies uppercase hexadecimal digests are accepted and kept verbatim in the manifest. + digest := strings.Repeat("AF09", 16) + + manifest, err := buildDispatcherArchiveManifest( + []dispatcherReleaseAsset{{Name: "a.zip"}}, + map[string]string{"a.zip": digest}) + if err != nil { + t.Fatalf("buildDispatcherArchiveManifest failed: %v", err) + } + if manifest != digest+" a.zip" { + t.Fatalf("manifest = %q", manifest) + } +} + +func TestWriteDispatcherPinStampReportsUnreadableAndInvalidPins(t *testing.T) { + // Verifies a missing pin and a malformed pin each fail with a step-specific error. + directory := t.TempDir() + missingPath := filepath.Join(directory, "missing.json") + if err := writeDispatcherPinStamp(missingPath, "tag", "manifest"); err == nil || !strings.Contains(err.Error(), "read dispatcher pin") { + t.Fatalf("expected read error, got %v", err) + } + + invalidPath := writeDispatcherPinForStamp(t, "{not json") + if err := writeDispatcherPinStamp(invalidPath, "tag", "manifest"); err == nil || !strings.Contains(err.Error(), "parse dispatcher pin") { + t.Fatalf("expected parse error, got %v", err) + } +} + +func TestWriteDispatcherPinStampReportsUnwritablePin(t *testing.T) { + // Verifies a pin that can be read but not written fails with a write error instead of reporting success. + // A read-only file is the only way to make the write fail after a successful read, and root ignores it. + if os.Geteuid() == 0 { + t.Skip("root can write read-only files") + } + readOnlyPath := writeDispatcherPinForStamp(t, `{}`) + if err := os.Chmod(readOnlyPath, 0o444); err != nil { + t.Fatalf("chmod pin: %v", err) + } + + err := writeDispatcherPinStamp(readOnlyPath, "tag", "manifest") + + if err == nil || !strings.Contains(err.Error(), "write stamped dispatcher pin") { + t.Fatalf("expected write error, got %v", err) + } +} + +func TestDispatcherPinStampGitHubTokenPrefersGitHubToken(t *testing.T) { + // Verifies GITHUB_TOKEN wins over GH_TOKEN and GH_TOKEN is the fallback. + t.Setenv("GITHUB_TOKEN", "primary") + t.Setenv("GH_TOKEN", "fallback") + if token := dispatcherPinStampGitHubToken(); token != "primary" { + t.Fatalf("token = %q, want primary", token) + } + + t.Setenv("GITHUB_TOKEN", "") + if token := dispatcherPinStampGitHubToken(); token != "fallback" { + t.Fatalf("token = %q, want fallback", token) + } +} + +func validDispatcherPinStampDeps() dispatcherPinStampDeps { + return dispatcherPinStampDeps{ + fetchReleaseAssets: func(context.Context, string) ([]dispatcherReleaseAsset, error) { + return []dispatcherReleaseAsset{ + {Name: "install.sh", URL: "https://example.test/install.sh"}, + {Name: "install.ps1", URL: "https://example.test/install.ps1"}, + }, nil + }, + fetchBundle: func(context.Context, string) ([]byte, error) { + return []byte("bundle"), nil + }, + fetchTagCommitSHA: func(context.Context, string, string) (string, error) { + return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil + }, + verifySubjects: func([]byte, string) (map[string]string, error) { + return map[string]string{ + "install.sh": strings.Repeat("1", 64), + "install.ps1": strings.Repeat("2", 64), + }, nil + }, + } +} + +func assertDispatcherPinFileContent(t *testing.T, pinPath string, want string) { + t.Helper() + content, err := os.ReadFile(pinPath) + if err != nil { + t.Fatalf("read pin: %v", err) + } + if string(content) != want { + t.Fatalf("pin content = %s, want %s", content, want) + } +} + +func TestFetchDispatcherReleaseAssetsReadsTheReleaseByTag(t *testing.T) { + // Verifies the release is requested by tag with the API headers and bearer token, and its assets are decoded. + t.Setenv("GITHUB_TOKEN", "test-token") + var requestPath, authorization, apiVersion string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestPath = request.URL.Path + authorization = request.Header.Get("Authorization") + apiVersion = request.Header.Get("X-GitHub-Api-Version") + _, _ = writer.Write([]byte(`{"assets":[{"name":"install.sh","browser_download_url":"https://example.test/install.sh"}]}`)) + })) + defer server.Close() + + assets, err := fetchDispatcherReleaseAssets(context.Background(), server.URL, "dispatcher-v3.0.1") + if err != nil { + t.Fatalf("fetchDispatcherReleaseAssets failed: %v", err) + } + if len(assets) != 1 || assets[0].Name != "install.sh" || assets[0].URL != "https://example.test/install.sh" { + t.Fatalf("assets = %+v", assets) + } + if requestPath != "/repos/"+attestation.ReleaseRepository+"/releases/tags/dispatcher-v3.0.1" { + t.Fatalf("request path = %q", requestPath) + } + if authorization != "Bearer test-token" || apiVersion != "2022-11-28" { + t.Fatalf("authorization = %q, api version = %q", authorization, apiVersion) + } +} + +func TestFetchDispatcherReleaseAssetsReportsUnusableResponses(t *testing.T) { + // Verifies an error status and an undecodable body are reported instead of returning an empty asset list. + cases := []struct { + name string + status int + body string + wantErr string + }{ + {"not found", http.StatusNotFound, `{}`, "GitHub release API returned 404"}, + {"invalid body", http.StatusOK, "{", "decode GitHub release assets"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GH_TOKEN", "") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(testCase.status) + _, _ = writer.Write([]byte(testCase.body)) + })) + defer server.Close() + + assets, err := fetchDispatcherReleaseAssets(context.Background(), server.URL, "dispatcher-v3.0.1") + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got assets %+v and error %v", testCase.wantErr, assets, err) + } + }) + } +} + +func TestFetchDispatcherReleaseAssetsReportsRequestFailures(t *testing.T) { + // Verifies an unbuildable request URL and an unreachable server both fail instead of returning assets. + if _, err := fetchDispatcherReleaseAssets(context.Background(), "http://bad host", "tag"); err == nil || !strings.Contains(err.Error(), "build GitHub release request") { + t.Fatalf("expected a request build error, got %v", err) + } + + server := httptest.NewServer(http.NotFoundHandler()) + closedURL := server.URL + server.Close() + if _, err := fetchDispatcherReleaseAssets(context.Background(), closedURL, "tag"); err == nil { + t.Fatal("expected an unreachable server to fail") + } +} diff --git a/cli/release-automation/internal/automation/package_pin_consistency_test.go b/cli/release-automation/internal/automation/package_pin_consistency_test.go index 629b1415df..368330d7a7 100644 --- a/cli/release-automation/internal/automation/package_pin_consistency_test.go +++ b/cli/release-automation/internal/automation/package_pin_consistency_test.go @@ -167,3 +167,75 @@ func TestPackagePinConsistencyRejectsPaddedManifestVersion(t *testing.T) { t.Fatalf("expected the non-canonical version to be named in the failure message, got %q", stderr) } } + +// Verifies unusable flags and an unresolvable repository root fail the check before any file is read. +func TestPackagePinConsistencyRejectsUnusableFlags(t *testing.T) { + cases := []struct { + name string + args []string + noGit bool + wantErr string + }{ + {"unknown flag", []string{"--unknown"}, false, "flag provided but not defined"}, + {"empty ref", []string{"--repo-root", t.TempDir(), "--ref", ""}, false, "--ref must not be empty"}, + {"git unavailable for the default root", []string{"--ref", "HEAD"}, true, "failed to resolve the repository root"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if testCase.noGit { + t.Setenv("PATH", t.TempDir()) + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunPackagePinConsistencyCheck(context.Background(), &stdout, &stderr, testCase.args) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), testCase.wantErr) { + t.Fatalf("expected stderr to contain %q, got %q", testCase.wantErr, stderr.String()) + } + }) + } +} + +// Verifies a missing, malformed, or dispatcher-less release manifest fails the check, because the expected tag cannot be derived. +func TestPackagePinConsistencyFailsWhenManifestIsUnusable(t *testing.T) { + pin := `{"dispatcherReleaseTag":"dispatcher-v3.4.0"}` + cases := []struct { + name string + manifest string + wantErr string + }{ + {"missing", "", releasePleaseManifestRelativePath + " is missing at HEAD"}, + {"invalid JSON", "{", "failed to parse " + releasePleaseManifestRelativePath}, + {"no dispatcher entry", `{"Packages/src":"3.6.0"}`, `has no "cli/dispatcher" version`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + repoRoot := writePackagePinConsistencyRepo(t, testCase.manifest, pin) + + exitCode, _, stderr := runPackagePinConsistencyCheck(t, repoRoot) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr, testCase.wantErr) { + t.Fatalf("expected stderr to contain %q, got %q", testCase.wantErr, stderr) + } + }) + } +} + +// Verifies a git binary that cannot be started is reported as an error instead of an absent file. +func TestPackagePinConsistencyFileReportsGitStartFailure(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + config := packagePinConsistencyConfig{repoRoot: t.TempDir(), ref: "HEAD"} + + _, err := packagePinConsistencyFile(context.Background(), config, releasePleaseManifestRelativePath) + + if err == nil || !strings.Contains(err.Error(), "git show HEAD:"+releasePleaseManifestRelativePath+" failed") { + t.Fatalf("expected a git start failure, got %v", err) + } +} From 9fd0f8a6547ef77672eca996fb15d95b936de43e Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 03:57:15 +0900 Subject: [PATCH 04/68] chore: Command routing and run paths in the project-runner CLI now have tests for routing, argument errors, and Unity failures (#3076) --- .../internal/projectrunner/run_test.go | 326 +++++++++++ .../projectrunner/runner_commands_test.go | 507 ++++++++++++++++++ .../projectrunner/tool_params_test.go | 138 +++++ 3 files changed, 971 insertions(+) create mode 100644 cli/project-runner/internal/projectrunner/runner_commands_test.go diff --git a/cli/project-runner/internal/projectrunner/run_test.go b/cli/project-runner/internal/projectrunner/run_test.go index 29af7b6042..6bcc229e4a 100644 --- a/cli/project-runner/internal/projectrunner/run_test.go +++ b/cli/project-runner/internal/projectrunner/run_test.go @@ -3,11 +3,16 @@ package projectrunner import ( "bytes" "context" + "encoding/json" + "io" "strings" "testing" + "time" "github.com/hatayama/unity-cli-loop/common/clicontract" + "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/clitest" + "github.com/hatayama/unity-cli-loop/common/unityipc" ) func TestRunProjectLocalVersionJSONIncludesProtocolVersion(t *testing.T) { @@ -37,3 +42,324 @@ func TestRunProjectLocalRejectsUnknownGlobalOption(t *testing.T) { t.Fatalf("stderr missing unknown option error:\n%s", stderr.String()) } } + +// Verifies that a malformed global --project-path is rejected before any command runs. +func TestRunProjectLocalRejectsProjectPathWithoutValue(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := RunProjectLocal(context.Background(), []string{"--project-path"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1; stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "--project-path requires a value") { + t.Fatalf("stderr must report the missing value:\n%s", stderr.String()) + } +} + +// Verifies that compile routed through runTool takes the domain-reload wait path, which +// validates --timeout-seconds before contacting Unity. +func TestRunToolRoutesCompileToDomainReloadWait(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runTool( + context.Background(), + unreachableConnection(t.TempDir()), + clicore.CompileCommandName, + map[string]any{compileWaitTimeoutParam: -1}, + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Invalid positive integer value for --timeout-seconds") { + t.Fatalf("stderr must reject the timeout:\n%s", stderr.String()) + } +} + +// Verifies that control-play-mode routed through runTool waits for the requested state +// by polling Unity after the action request. +func TestRunToolRoutesControlPlayModeToStateWait(t *testing.T) { + listener := newLoopbackIpcListener(t) + requests := make(chan map[string]any, 2) + serverErr := make(chan error, 1) + go serveControlPlayModeResponses(listener, requests, serverErr, []string{ + `{"IsPlaying":false,"IsPaused":false,"Message":"Play mode started"}`, + `{"IsPlaying":true,"IsPaused":false,"Message":"Play mode status"}`, + }) + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: listener.Addr().Network(), Address: listener.Addr().String()}, + ProjectRoot: t.TempDir(), + } + var stdout, stderr bytes.Buffer + + code := runTool( + context.Background(), + connection, + controlPlayModeCommandName, + map[string]any{controlPlayModeActionParam: "Play", controlPlayModeTimeoutParam: 1}, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("control-play-mode failed with %d: %s", code, stderr.String()) + } + readControlPlayModeRequest(t, requests) + readControlPlayModeRequest(t, requests) + if !strings.Contains(stdout.String(), `"IsPlaying": true`) { + t.Fatalf("stdout must report the reached play state:\n%s", stdout.String()) + } +} + +// Verifies that PlayMode run-tests honoring Enter Play Mode settings goes through the +// domain-reload wait path and still returns Unity's result. +func TestRunToolExecutionRoutesPlayModeRunTestsToDomainReloadWait(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityResultServer(t, projectRoot, clicore.RunTestsCommandName, `{"Success":true,"TestCount":1}`) + + result := runToolExecution( + context.Background(), + server.connection, + clicore.RunTestsCommandName, + map[string]any{"RespectEnterPlayModeSettings": true, "TestMode": "PlayMode"}, + io.Discard, + ) + + if result.exitCode != 0 { + t.Fatalf("exit code = %d, want 0", result.exitCode) + } + request := server.receivedRequest(t) + if request["RequestId"] == nil || request["RequestId"] == "" { + t.Fatalf("domain-reload wait path must attach a request ID: %#v", request) + } + if !strings.Contains(string(result.result), `"TestCount":1`) { + t.Fatalf("result must carry Unity's payload: %s", result.result) + } +} + +// Verifies that a Unity-side error on the plain tool path exits 1, prints nothing to stdout, +// and reports the Unity message on stderr. +func TestRunToolReportsUnityErrorOnPlainPath(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityServer(t, projectRoot, "get-logs", testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runTool(context.Background(), server.connection, "get-logs", map[string]any{}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + server.receivedRequest(t) + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "tool exploded in Unity") { + t.Fatalf("stderr must carry the Unity error message:\n%s", stderr.String()) + } +} + +// Verifies that execute-dynamic-code with the domain-reload wait strips the internal wait +// control field from the result it prints when Unity reports no reload is pending. +func TestRunToolExecuteDynamicCodeStripsWaitControlField(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityResultServer( + t, projectRoot, clicore.ExecuteDynamicCodeCommandName, + `{"Success":true,"Result":"42","DomainReloadWaitRequired":false}`) + var stdout, stderr bytes.Buffer + + code := runTool( + context.Background(), + server.connection, + clicore.ExecuteDynamicCodeCommandName, + map[string]any{"Code": "return 42;", clicore.DomainReloadWaitParam: true}, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("execute-dynamic-code failed with %d: %s", code, stderr.String()) + } + server.receivedRequest(t) + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + if _, exists := payload["DomainReloadWaitRequired"]; exists { + t.Fatalf("internal wait field must be stripped: %#v", payload) + } + if payload["Result"] != "42" { + t.Fatalf("Unity's result must be kept: %#v", payload) + } +} + +// Verifies that a Unity-side error on the execute-dynamic-code wait path exits 1, reports the +// Unity message, and prints no result instead of continuing with an empty response. +func TestRunExecuteDynamicCodeWithDomainReloadWaitReportsUnityError(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityServer(t, projectRoot, clicore.ExecuteDynamicCodeCommandName, testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runExecuteDynamicCodeWithDomainReloadWait( + context.Background(), + server.connection, + map[string]any{"Code": "return 1;", clicore.DomainReloadWaitParam: true}, + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + server.receivedRequest(t) + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "tool exploded in Unity") { + t.Fatalf("stderr must carry the Unity error message:\n%s", stderr.String()) + } +} + +// Verifies that the fresh compile path rejects an invalid --timeout-seconds before sending anything. +func TestRunFreshCompileRejectsInvalidTimeout(t *testing.T) { + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + t.Fatal("compile status must not be queried") + return compileStatusResponse{}, nil + }) + deps.sendCompile = func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { + t.Fatal("compile must not be sent") + return unityipc.UnitySendOutcome{}, nil + } + var stdout, stderr bytes.Buffer + + code := runFreshCompileWithDomainReloadWaitWithDeps( + context.Background(), + unreachableConnection(t.TempDir()), + map[string]any{compileWaitTimeoutParam: "soon"}, + &stdout, + &stderr, + deps, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "Invalid positive integer value for --timeout-seconds") { + t.Fatalf("stderr must reject the timeout:\n%s", stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } +} + +// Verifies that when the compile connection drops after dispatch, the fresh compile path +// keeps waiting on compile status, and a cancellation during that wait is reported as a failure. +func TestRunFreshCompileReportsCancellationWhileWaitingAfterDisconnect(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + queried := 0 + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + queried++ + cancel() + return compileStatusResponse{IsCompiling: true}, nil + }) + deps.sendCompile = func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { + return unityipc.UnitySendOutcome{RequestDispatched: true}, io.EOF + } + var stderr bytes.Buffer + + result := runFreshCompileWithDomainReloadWaitResultWithDeps( + ctx, + unreachableConnection(t.TempDir()), + map[string]any{}, + &stderr, + deps, + ) + + if result.exitCode != 1 || len(result.result) != 0 { + t.Fatalf("unexpected result: %#v", result) + } + if queried != 1 { + t.Fatalf("compile status queries = %d, want 1", queried) + } + if !strings.Contains(stderr.String(), context.Canceled.Error()) { + t.Fatalf("stderr must report the cancellation:\n%s", stderr.String()) + } +} + +// Verifies that list reports a Unity-side failure instead of printing an empty catalog. +func TestRunListReportsUnityFailure(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityServer(t, projectRoot, "get-tool-details", testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runList(context.Background(), server.connection, nil, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "tool exploded in Unity") { + t.Fatalf("stderr must carry the Unity error message:\n%s", stderr.String()) + } +} + +// Verifies that list --names fails when Unity returns a catalog that cannot be decoded. +func TestRunListNamesRejectsMalformedCatalog(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityResultServer(t, projectRoot, "get-tool-details", `{"tools":"not-a-list"}`) + var stdout, stderr bytes.Buffer + + code := runList(context.Background(), server.connection, []string{"--names"}, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "cannot unmarshal") { + t.Fatalf("stderr must explain the decode failure:\n%s", stderr.String()) + } +} + +// Verifies that plain list prints the live Unity catalog as JSON. +func TestRunListPrintsLiveCatalog(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityResultServer(t, projectRoot, "get-tool-details", `{"tools":[{"name":"sample-live-tool","description":"Live tool"}]}`) + var stdout, stderr bytes.Buffer + + code := runList(context.Background(), server.connection, nil, &stdout, &stderr) + + if code != 0 { + t.Fatalf("list failed with %d: %s", code, stderr.String()) + } + if !json.Valid(stdout.Bytes()) { + t.Fatalf("stdout must be JSON:\n%s", stdout.String()) + } + if !strings.Contains(stdout.String(), "sample-live-tool") { + t.Fatalf("stdout must list the live tool:\n%s", stdout.String()) + } +} + +// Verifies that the post-compile warmup warning is silent when the warmup succeeded. +func TestWritePostCompileWarmupWarningIsSilentWithoutError(t *testing.T) { + var stderr bytes.Buffer + + writePostCompileWarmupWarning(&stderr, nil) + + if stderr.Len() != 0 { + t.Fatalf("stderr must stay empty: %q", stderr.String()) + } +} + +// Verifies that an undecodable compile result never triggers the post-compile readiness wait. +func TestCompileResultReadinessWaitModeIgnoresMalformedResult(t *testing.T) { + if mode := compileResultReadinessWaitMode([]byte("not json")); mode != compileReadinessWaitNone { + t.Fatalf("mode = %v, want compileReadinessWaitNone", mode) + } +} diff --git a/cli/project-runner/internal/projectrunner/runner_commands_test.go b/cli/project-runner/internal/projectrunner/runner_commands_test.go new file mode 100644 index 0000000000..0e3b076e34 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/runner_commands_test.go @@ -0,0 +1,507 @@ +package projectrunner + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +const testUnityRPCFailureResponse = `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"tool exploded in Unity"}}` + +// serveRawIPCResponse answers one request with a complete JSON-RPC response so tests can +// return error envelopes as well as results. The received request is forwarded on requests. +func serveRawIPCResponse( + listener net.Listener, + expectedMethod string, + requests chan<- map[string]any, + serverErr chan<- error, + response string, +) { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer func() { _ = conn.Close() }() + + payload, err := unityipc.Read(bufio.NewReader(conn)) + if err != nil { + serverErr <- err + return + } + request := struct { + Method string `json:"method"` + Params map[string]any `json:"params"` + }{} + if err := json.Unmarshal(payload, &request); err != nil { + serverErr <- err + return + } + if request.Method != expectedMethod { + serverErr <- fmt.Errorf("method mismatch: %s", request.Method) + return + } + requests <- request.Params + if err := unityipc.Write(conn, []byte(response)); err != nil { + serverErr <- err + } +} + +// fakeUnityServer is one loopback listener that answers a single request for a test. +type fakeUnityServer struct { + connection unityipc.Connection + requests chan map[string]any + serverErr chan error +} + +func startFakeUnityServer(t *testing.T, projectRoot string, expectedMethod string, response string) fakeUnityServer { + t.Helper() + listener := newLoopbackIpcListener(t) + server := fakeUnityServer{ + connection: unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: listener.Addr().Network(), + Address: listener.Addr().String(), + }, + ProjectRoot: projectRoot, + }, + requests: make(chan map[string]any, 1), + serverErr: make(chan error, 1), + } + go serveRawIPCResponse(listener, expectedMethod, server.requests, server.serverErr, response) + return server +} + +func startFakeUnityResultServer(t *testing.T, projectRoot string, expectedMethod string, result string) fakeUnityServer { + t.Helper() + return startFakeUnityServer(t, projectRoot, expectedMethod, fmt.Sprintf(`{"jsonrpc":"2.0","result":%s,"id":1}`, result)) +} + +func (server fakeUnityServer) receivedRequest(t *testing.T) map[string]any { + t.Helper() + select { + case request := <-server.requests: + return request + case err := <-server.serverErr: + t.Fatalf("fake Unity server failed: %v", err) + return nil + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the fake Unity server to receive a request") + return nil + } +} + +// unreachableConnection points at a project whose IPC endpoint is never contacted; tests use +// it for paths that must fail before any request is sent. +func unreachableConnection(projectRoot string) unityipc.Connection { + return unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: "tcp", Address: "127.0.0.1:1"}, + ProjectRoot: projectRoot, + } +} + +func writeFakeUnityProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + for _, name := range []string{"Assets", "ProjectSettings"} { + if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", name, err) + } + } + canonical, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatalf("failed to canonicalize project root: %v", err) + } + return canonical +} + +// Verifies that native commands with their own parsers reject an unknown flag themselves, +// naming the command, instead of falling through to the dynamic tool catalog. +// Each input is one only the native parser rejects this way: the dynamic catalog path reports +// await-pause-point and pause-point-status as unknown commands, and enable-pause-point's +// --resume-play as an unknown option instead of requiring --await. +func TestRunResolvedProjectCommandRoutesNativeCommandsToTheirOwnParsers(t *testing.T) { + cases := []struct { + command string + args []string + wantMessage string + }{ + {command: clicore.PausePointAwaitCommandName, args: []string{"--bogus-flag"}, wantMessage: `"Message": "--bogus-flag requires a value"`}, + {command: clicore.PausePointStatusUserCommandName, args: []string{"--bogus-flag"}, wantMessage: `"Message": "--bogus-flag requires a value"`}, + {command: pausePointEnableCommandName, args: []string{"--resume-play"}, wantMessage: "require --await"}, + } + for _, testCase := range cases { + t.Run(testCase.command, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runResolvedProjectCommand( + context.Background(), + unreachableConnection(t.TempDir()), + testCase.command, + testCase.args, + t.TempDir(), + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1; stderr=%s", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty on argument errors: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), testCase.wantMessage) { + t.Fatalf("stderr must carry the native parser's error %q:\n%s", testCase.wantMessage, stderr.String()) + } + }) + } +} + +// Verifies that sync fetches the live tool catalog and stores it verbatim as the project tool cache. +func TestRunResolvedProjectCommandSyncWritesToolCache(t *testing.T) { + projectRoot := t.TempDir() + catalog := `{"tools":[{"name":"get-logs"}]}` + server := startFakeUnityResultServer(t, projectRoot, "get-tool-details", catalog) + var stdout, stderr bytes.Buffer + + code := runResolvedProjectCommand(context.Background(), server.connection, "sync", nil, projectRoot, &stdout, &stderr) + + if code != 0 { + t.Fatalf("sync failed with %d: %s", code, stderr.String()) + } + server.receivedRequest(t) + cachePath := filepath.Join(projectRoot, clicore.CacheDirectoryName, clicore.CacheFileName) + content, err := os.ReadFile(cachePath) + if err != nil { + t.Fatalf("tool cache was not written: %v", err) + } + if string(content) != catalog { + t.Fatalf("cache content mismatch:\nwant: %s\ngot: %s", catalog, content) + } + if stdout.String() != "Tools synced to "+cachePath+"\n" { + t.Fatalf("stdout mismatch: %q", stdout.String()) + } +} + +// Verifies that a Unity-side failure during sync is reported and leaves no cache file behind. +func TestRunSyncReportsUnityFailureWithoutWritingCache(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityServer(t, projectRoot, "get-tool-details", testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runSync(context.Background(), server.connection, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "tool exploded in Unity") { + t.Fatalf("stderr must carry the Unity error message:\n%s", stderr.String()) + } + if _, err := os.Stat(filepath.Join(projectRoot, clicore.CacheDirectoryName)); !os.IsNotExist(err) { + t.Fatalf("cache directory must not be created on failure: %v", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } +} + +// Verifies that sync fails without claiming success when the cache directory or file cannot be written. +func TestRunSyncFailsWhenCacheCannotBeWritten(t *testing.T) { + cases := []struct { + name string + prepare func(t *testing.T, projectRoot string) + wantStderr string + }{ + { + name: "cache directory path is a file", + wantStderr: "mkdir ", + prepare: func(t *testing.T, projectRoot string) { + writeTestFile(t, filepath.Join(projectRoot, clicore.CacheDirectoryName), "not a directory") + }, + }, + { + name: "cache file path is a directory", + wantStderr: "is a directory", + prepare: func(t *testing.T, projectRoot string) { + if err := os.MkdirAll(filepath.Join(projectRoot, clicore.CacheDirectoryName, clicore.CacheFileName), 0o755); err != nil { + t.Fatalf("failed to create blocking directory: %v", err) + } + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := t.TempDir() + testCase.prepare(t, projectRoot) + server := startFakeUnityResultServer(t, projectRoot, "get-tool-details", `{"tools":[]}`) + var stdout, stderr bytes.Buffer + + code := runSync(context.Background(), server.connection, &stdout, &stderr) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must not report a sync: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr must contain %q:\n%s", testCase.wantStderr, stderr.String()) + } + }) + } +} + +// Verifies that a catalog tool is sent to Unity with its CLI flags converted to schema params +// and that Unity's result is written to stdout. +func TestRunResolvedProjectCommandSendsDynamicToolToUnity(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityResultServer(t, projectRoot, "get-logs", `{"Success":true,"TotalCount":0}`) + var stdout, stderr bytes.Buffer + + code := runResolvedProjectCommand( + context.Background(), + server.connection, + "get-logs", + []string{"--max-count", "5", "--include-stack-trace"}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("get-logs failed with %d: %s", code, stderr.String()) + } + request := server.receivedRequest(t) + if request["MaxCount"] != float64(5) || request["IncludeStackTrace"] != true { + t.Fatalf("unexpected params sent to Unity: %#v", request) + } + if !strings.Contains(stdout.String(), `"TotalCount": 0`) { + t.Fatalf("stdout must carry Unity's result:\n%s", stdout.String()) + } +} + +// Verifies that run-tests with --skip-compile goes straight to Unity without the implicit compile. +func TestRunResolvedProjectCommandRunTestsSkipCompileSendsRunTestsOnly(t *testing.T) { + original := runTestsImplicitCompile + runTestsImplicitCompile = func(context.Context, unityipc.Connection, io.Writer) compileExecutionResult { + t.Fatal("implicit compile must not run with --skip-compile") + return compileExecutionResult{} + } + t.Cleanup(func() { runTestsImplicitCompile = original }) + + projectRoot := t.TempDir() + server := startFakeUnityResultServer(t, projectRoot, clicore.RunTestsCommandName, `{"Success":true,"TestCount":2}`) + var stdout, stderr bytes.Buffer + + code := runResolvedProjectCommand( + context.Background(), + server.connection, + clicore.RunTestsCommandName, + []string{"--skip-compile", "--filter-value", "Sample"}, + projectRoot, + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("run-tests failed with %d: %s", code, stderr.String()) + } + if request := server.receivedRequest(t); request["FilterValue"] != "Sample" { + t.Fatalf("unexpected params sent to Unity: %#v", request) + } + if strings.Contains(stdout.String(), runTestsCompileNote) { + t.Fatalf("stdout must not carry the compile note when compile was skipped:\n%s", stdout.String()) + } +} + +// Verifies that a command missing from the tool catalog is reported as unknown without contacting Unity. +func TestRunDynamicProjectToolRejectsUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runDynamicProjectTool( + context.Background(), + unreachableConnection(t.TempDir()), + "no-such-tool", + nil, + t.TempDir(), + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), `"Message": "Unknown command: no-such-tool"`) { + t.Fatalf("stderr must report the unknown command:\n%s", stderr.String()) + } +} + +// Verifies that invalid tool arguments are rejected before any request reaches Unity. +func TestRunDynamicProjectToolRejectsInvalidArgumentsBeforeSending(t *testing.T) { + missingCodeFile := filepath.Join(t.TempDir(), "missing.cs") + existingCodeFile := filepath.Join(t.TempDir(), "snippet.cs") + writeTestFile(t, existingCodeFile, "return 1;") + notAProject := t.TempDir() + + cases := []struct { + name string + command string + args []string + wantStderr string + }{ + {name: "code-file without value", command: clicore.ExecuteDynamicCodeCommandName, args: []string{"--code-file"}, wantStderr: "--code-file requires a value"}, + {name: "code-file that does not exist", command: clicore.ExecuteDynamicCodeCommandName, args: []string{"--code-file", missingCodeFile}, wantStderr: "failed to read --code-file"}, + {name: "code and code-file together", command: clicore.ExecuteDynamicCodeCommandName, args: []string{"--code", "return 2;", "--code-file", existingCodeFile}, wantStderr: "--code and --code-file cannot be combined"}, + {name: "clear-pause-point file without line", command: pausePointClearCommandName, args: []string{"--file", "Assets/Sample.cs"}, wantStderr: "--file requires --line."}, + {name: "unknown tool option", command: "get-logs", args: []string{"--bogus-flag"}, wantStderr: "Unknown option for get-logs: --bogus-flag"}, + {name: "nested project path that is not a Unity project", command: "get-logs", args: []string{"--project-path", notAProject}, wantStderr: "not a Unity project: "}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runDynamicProjectTool( + context.Background(), + unreachableConnection(t.TempDir()), + testCase.command, + testCase.args, + t.TempDir(), + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1; stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr must mention %q:\n%s", testCase.wantStderr, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } + }) + } +} + +// Verifies that a nested --project-path naming a different Unity project than the resolved +// connection is rejected instead of silently sending the request to the wrong Editor. +func TestRunDynamicProjectToolRejectsNestedProjectPathForAnotherProject(t *testing.T) { + otherProject := writeFakeUnityProject(t) + var stdout, stderr bytes.Buffer + + code := runDynamicProjectTool( + context.Background(), + unreachableConnection(writeFakeUnityProject(t)), + "get-logs", + []string{"--project-path", otherProject}, + t.TempDir(), + &stdout, + &stderr, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "--project-path must target the same Unity project") { + t.Fatalf("stderr must explain the project mismatch:\n%s", stderr.String()) + } +} + +// Verifies that a nested --project-path naming the connected project is accepted and stripped +// from the params sent to Unity. +func TestRunDynamicProjectToolAcceptsNestedProjectPathForSameProject(t *testing.T) { + projectRoot := writeFakeUnityProject(t) + server := startFakeUnityResultServer(t, projectRoot, "get-logs", `{"Success":true}`) + var stdout, stderr bytes.Buffer + + code := runDynamicProjectTool( + context.Background(), + server.connection, + "get-logs", + []string{"--project-path", projectRoot, "--max-count", "1"}, + t.TempDir(), + &stdout, + &stderr, + ) + + if code != 0 { + t.Fatalf("get-logs failed with %d: %s", code, stderr.String()) + } + request := server.receivedRequest(t) + if _, exists := request["ProjectPath"]; exists { + t.Fatalf("--project-path must not be forwarded as a tool param: %#v", request) + } + if request["MaxCount"] != float64(1) { + t.Fatalf("unexpected params sent to Unity: %#v", request) + } +} + +// Verifies that when run-tests fails in Unity after an implicit compile, the failure is +// reported on stderr and no compile note is written to stdout. +func TestRunDynamicProjectToolWithCompileNoteSkipsNoteWhenUnityFails(t *testing.T) { + projectRoot := t.TempDir() + server := startFakeUnityServer(t, projectRoot, clicore.RunTestsCommandName, testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runDynamicProjectToolWithCompileNote( + context.Background(), + server.connection, + clicore.RunTestsCommandName, + nil, + projectRoot, + &stdout, + &stderr, + true, + "", + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + server.receivedRequest(t) + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty when Unity fails: %s", stdout.String()) + } + assertSingleErrorEnvelope(t, stderr.Bytes(), "tool exploded in Unity") +} + +// assertSingleErrorEnvelope checks stderr holds exactly one JSON error envelope and that it +// carries wantMessage, so a second failure written after the first cannot go unnoticed. +func assertSingleErrorEnvelope(t *testing.T, stderr []byte, wantMessage string) { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(stderr)) + var envelope struct { + Error struct { + Message string `json:"Message"` + } `json:"Error"` + } + if err := decoder.Decode(&envelope); err != nil { + t.Fatalf("stderr must start with an error envelope: %v\n%s", err, stderr) + } + if !strings.Contains(envelope.Error.Message, wantMessage) { + t.Fatalf("envelope message = %q, want it to contain %q", envelope.Error.Message, wantMessage) + } + var extra json.RawMessage + if err := decoder.Decode(&extra); err != io.EOF { + t.Fatalf("stderr must hold exactly one envelope, found more:\n%s", stderr) + } +} + +func writeTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} diff --git a/cli/project-runner/internal/projectrunner/tool_params_test.go b/cli/project-runner/internal/projectrunner/tool_params_test.go index e7700d9e79..04adc3806d 100644 --- a/cli/project-runner/internal/projectrunner/tool_params_test.go +++ b/cli/project-runner/internal/projectrunner/tool_params_test.go @@ -2,6 +2,7 @@ package projectrunner import ( "errors" + "reflect" "strings" "testing" @@ -546,3 +547,140 @@ func TestBuildToolParamsUnknownOptionTieBreaksToSortedFirstOptionName(t *testing "Run `uloop sample-tool --help` to inspect supported options.", }) } + +func toolParamsConversionTool() clicore.ToolDefinition { + return clicore.ToolDefinition{ + Name: "sample-tool", + InputSchema: clicore.InputSchema{ + Properties: map[string]clicore.ToolProperty{ + "Count": {Type: "integer"}, + "Ratio": {Type: "number"}, + "Tags": {Type: "array"}, + "Options": {Type: "object"}, + "Verbose": {Type: "boolean"}, + "AutoSave": {Type: "boolean", Default: true}, + "Payload": {Type: "custom"}, + }, + }, + } +} + +// Verifies that each schema type converts its CLI value to the JSON shape Unity expects, +// including --flag=value, negated booleans, and --project-path extraction. +func TestBuildToolParamsConvertsValuesBySchemaType(t *testing.T) { + params, projectPath, err := buildToolParams([]string{ + "--count", "3", + "--ratio=0.5", + "--tags", "a, b ,c", + "--options", `{"key":"value"}`, + "--verbose", + "--no-auto-save", + "--payload", "raw", + "--project-path", "nested", + }, toolParamsConversionTool()) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + want := map[string]any{ + "Count": 3, + "Ratio": 0.5, + "Tags": []string{"a", "b", "c"}, + "Options": map[string]any{"key": "value"}, + "Verbose": true, + "AutoSave": false, + "Payload": "raw", + } + if !reflect.DeepEqual(params, want) { + t.Fatalf("params mismatch:\nwant: %#v\ngot: %#v", want, params) + } + if projectPath != "nested" { + t.Fatalf("projectPath = %q, want %q", projectPath, "nested") + } +} + +// Verifies that a JSON array value is decoded as JSON instead of being split on commas. +func TestBuildToolParamsDecodesJSONArrayValue(t *testing.T) { + params, _, err := buildToolParams([]string{"--tags", `["x,y", 2]`}, toolParamsConversionTool()) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + want := []any{"x,y", float64(2)} + if !reflect.DeepEqual(params["Tags"], want) { + t.Fatalf("Tags mismatch:\nwant: %#v\ngot: %#v", want, params["Tags"]) + } +} + +// Verifies that the --project-path=value form is accepted without consuming the next token. +func TestBuildToolParamsAcceptsInlineProjectPath(t *testing.T) { + params, projectPath, err := buildToolParams([]string{"--project-path=nested", "--verbose"}, toolParamsConversionTool()) + if err != nil { + t.Fatalf("buildToolParams failed: %v", err) + } + if projectPath != "nested" || params["Verbose"] != true { + t.Fatalf("unexpected result: projectPath=%q params=%#v", projectPath, params) + } +} + +// Verifies that malformed flags and values are rejected with an argument error naming the +// offending option and the branch-specific message. ExpectedType is checked only where the +// branch derives it from the schema; missing-value errors are not judged on it. +func TestBuildToolParamsRejectsMalformedArguments(t *testing.T) { + cases := []struct { + name string + args []string + wantOption string + wantMessage string + wantExpected string + }{ + {name: "bare double dash", args: []string{"--"}, wantOption: "--", wantMessage: "Invalid option: --"}, + {name: "empty inline value", args: []string{"--count="}, wantOption: "--count", wantMessage: "--count requires a value"}, + {name: "missing trailing value", args: []string{"--count"}, wantOption: "--count", wantMessage: "--count requires a value"}, + {name: "value is next option", args: []string{"--count", "--verbose"}, wantOption: "--count", wantMessage: "--count requires a value"}, + {name: "missing project path value", args: []string{"--project-path"}, wantOption: "--project-path", wantMessage: "--project-path requires a value"}, + {name: "invalid integer", args: []string{"--count", "three"}, wantOption: "--count", wantMessage: "Invalid integer value", wantExpected: "integer"}, + {name: "invalid number", args: []string{"--ratio", "half"}, wantOption: "--ratio", wantMessage: "Invalid number value", wantExpected: "number"}, + {name: "invalid json array", args: []string{"--tags", "[1,"}, wantOption: "--tags", wantMessage: "Invalid array value", wantExpected: "array"}, + {name: "invalid json object", args: []string{"--options", "{"}, wantOption: "--options", wantMessage: "Invalid object value", wantExpected: "object"}, + {name: "null json object", args: []string{"--options", "null"}, wantOption: "--options", wantMessage: "Invalid object value", wantExpected: "object"}, + {name: "boolean with inline value", args: []string{"--verbose=true"}, wantOption: "--verbose", wantMessage: "Boolean option does not accept a value: true", wantExpected: "flag"}, + {name: "boolean with trailing value", args: []string{"--verbose", "true"}, wantOption: "--verbose", wantMessage: "Boolean option does not accept a value: true", wantExpected: "flag"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + params, _, err := buildToolParams(testCase.args, toolParamsConversionTool()) + if params != nil { + t.Fatalf("expected nil params on error, got %#v", params) + } + argumentError := requireArgumentError(t, err) + if argumentError.Option != testCase.wantOption { + t.Fatalf("Option = %q, want %q (message: %s)", argumentError.Option, testCase.wantOption, argumentError.Message) + } + if !strings.Contains(argumentError.Message, testCase.wantMessage) { + t.Fatalf("Message = %q, want it to contain %q", argumentError.Message, testCase.wantMessage) + } + if testCase.wantExpected != "" && argumentError.ExpectedType != testCase.wantExpected { + t.Fatalf("ExpectedType = %q, want %q", argumentError.ExpectedType, testCase.wantExpected) + } + }) + } +} + +// Verifies that boolean values parse case-insensitively and reject anything but true/false. +func TestConvertValueBoolean(t *testing.T) { + property := clicore.ToolProperty{Type: "Boolean"} + for input, want := range map[string]bool{"TRUE": true, "false": false} { + converted, err := convertValue(input, property, "--flag") + if err != nil { + t.Fatalf("convertValue(%q) failed: %v", input, err) + } + if converted != want { + t.Fatalf("convertValue(%q) = %#v, want %v", input, converted, want) + } + } + + _, err := convertValue("yes", property, "--flag") + argumentError := requireArgumentError(t, err) + if argumentError.ExpectedType != "boolean" || argumentError.Received != "yes" { + t.Fatalf("unexpected argument error: %#v", argumentError) + } +} From a886408f422bdcad3416c9935e5a1ae0ca4e5b2f Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:04:34 +0900 Subject: [PATCH 05/68] chore: CLI core, project resolution and CLI errors in the common CLI module now have tests for parsing, readiness and logging (#3077) --- cli/common/clicontract/contract_test.go | 66 ++++ cli/common/clicore/command_errors_test.go | 33 +- cli/common/clicore/focus_test.go | 48 +++ cli/common/clicore/global_args_test.go | 151 ++++++++ cli/common/clicore/output_test.go | 11 + cli/common/clicore/runner_version_test.go | 27 ++ cli/common/clicore/string_helpers_test.go | 26 ++ cli/common/clicore/tool_catalog_test.go | 42 +++ cli/common/clicore/tool_readiness_test.go | 109 ++++++ cli/common/clicore/tool_settings_test.go | 58 +++ cli/common/clicore/tool_spinner_test.go | 34 ++ cli/common/errors/argument_error_test.go | 91 +++++ cli/common/errors/busy_status_test.go | 24 ++ .../errors/error_editor_unresponsive_test.go | 30 ++ cli/common/errors/project_errors_test.go | 56 +++ .../errors/server_not_responding_test.go | 45 +++ cli/common/errors/string_helpers_test.go | 13 + cli/common/project/project_test.go | 329 ++++++++++++++++++ cli/common/ui/spinner_test.go | 145 ++++++++ cli/common/vibelog/cli_vibe_test.go | 233 +++++++++++++ 20 files changed, 1570 insertions(+), 1 deletion(-) create mode 100644 cli/common/clicore/global_args_test.go create mode 100644 cli/common/clicore/runner_version_test.go create mode 100644 cli/common/clicore/string_helpers_test.go create mode 100644 cli/common/clicore/tool_settings_test.go create mode 100644 cli/common/clicore/tool_spinner_test.go create mode 100644 cli/common/errors/argument_error_test.go create mode 100644 cli/common/errors/busy_status_test.go create mode 100644 cli/common/errors/error_editor_unresponsive_test.go create mode 100644 cli/common/errors/project_errors_test.go create mode 100644 cli/common/errors/server_not_responding_test.go create mode 100644 cli/common/errors/string_helpers_test.go diff --git a/cli/common/clicontract/contract_test.go b/cli/common/clicontract/contract_test.go index 32e1e9c6b3..f5032da527 100644 --- a/cli/common/clicontract/contract_test.go +++ b/cli/common/clicontract/contract_test.go @@ -1,6 +1,8 @@ package clicontract import ( + "errors" + "strings" "testing" "github.com/hatayama/unity-cli-loop/common/clitest" @@ -45,3 +47,67 @@ func TestCliContractDoesNotDeclareDispatcherReleaseFields(t *testing.T) { clitest.RequireContractFieldMissing(t, fields, "dispatcherContractVersion") clitest.RequireContractFieldMissing(t, fields, "schemaVersion") } + +func TestParseContractRejectsInvalidContracts(t *testing.T) { + // Verifies malformed JSON, a missing or empty projectRunnerVersion, and a protocolVersion below 1 are each rejected. + cases := []struct { + name string + content string + expectedMessage string + }{ + {name: "invalid JSON", content: "{", expectedMessage: "CLI contract is invalid JSON"}, + {name: "missing runner version", content: `{"protocolVersion":1}`, expectedMessage: "contract field projectRunnerVersion must not be empty"}, + {name: "empty runner version", content: `{"protocolVersion":1,"projectRunnerVersion":""}`, expectedMessage: "contract field projectRunnerVersion must not be empty"}, + {name: "missing protocol version", content: `{"projectRunnerVersion":"1.2.3"}`, expectedMessage: "protocolVersion must be at least 1, got 0"}, + {name: "negative protocol version", content: `{"protocolVersion":-1,"projectRunnerVersion":"1.2.3"}`, expectedMessage: "protocolVersion must be at least 1, got -1"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + contract, err := parseContract([]byte(testCase.content)) + if err == nil { + t.Fatalf("expected error, got contract %#v", contract) + } + if !strings.Contains(err.Error(), testCase.expectedMessage) { + t.Fatalf("error %q should contain %q", err.Error(), testCase.expectedMessage) + } + if contract != (Contract{}) { + t.Fatalf("rejected contract should be zero, got %#v", contract) + } + }) + } +} + +func TestParseContractReturnsDeclaredFields(t *testing.T) { + // Verifies a valid contract is returned with its declared protocol and runner versions. + contract, err := parseContract([]byte(`{"protocolVersion":2,"projectRunnerVersion":"1.2.3"}`)) + if err != nil { + t.Fatalf("parseContract failed: %v", err) + } + if contract.ProtocolVersion != 2 || contract.ProjectRunnerVersion != "1.2.3" { + t.Fatalf("contract mismatch: %#v", contract) + } +} + +func TestMustLoadContractPanicsWithLoadError(t *testing.T) { + // Verifies version accessors fail fast with the load error when the embedded contract could not be loaded. + if _, err := Load(); err != nil { + t.Fatalf("Load failed: %v", err) + } + originalErr := loadContractErr + loadErr := errors.New("contract load failed") + loadContractErr = loadErr + t.Cleanup(func() { + loadContractErr = originalErr + }) + + defer func() { + recovered := recover() + recoveredErr, ok := recovered.(error) + if !ok || !errors.Is(recoveredErr, loadErr) { + t.Fatalf("expected panic with load error, got %#v", recovered) + } + }() + ProtocolVersion() + t.Fatal("ProtocolVersion should panic when the contract failed to load") +} diff --git a/cli/common/clicore/command_errors_test.go b/cli/common/clicore/command_errors_test.go index 964bc672a7..cce1c5e8a2 100644 --- a/cli/common/clicore/command_errors_test.go +++ b/cli/common/clicore/command_errors_test.go @@ -1,6 +1,10 @@ package clicore -import "testing" +import ( + "testing" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" +) func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { // Verifies unknown-command suggestions include visible built-in CLI commands before cached tools. @@ -12,3 +16,30 @@ func TestAvailableCommandNamesIncludesBuiltIns(t *testing.T) { } } } + +// Verifies that the unknown-command error names the command and suggests a close cached tool name, +// and that a cached tool sharing a built-in name is not listed twice. +func TestUnknownCommandErrorSuggestsCachedTools(t *testing.T) { + cache := ToolsCache{Tools: []ToolDefinition{{Name: "launch"}, {Name: "get-hierarchy"}}} + + names := availableCommandNames(cache) + launchCount := 0 + for _, name := range names { + if name == "launch" { + launchCount++ + } + } + if launchCount != 1 || names[len(names)-1] != "get-hierarchy" { + t.Fatalf("unexpected available command names: %#v", names) + } + + cliError := UnknownCommandError("get-hierarchi", cache, clierrors.ErrorContext{ProjectRoot: "project"}) + + if cliError.Command != "get-hierarchi" || cliError.ProjectRoot != "project" { + t.Fatalf("unexpected error identity: %#v", cliError) + } + suggestions, ok := cliError.Details["SuggestedCommands"].([]string) + if !ok || len(suggestions) == 0 || suggestions[0] != "get-hierarchy" { + t.Fatalf("expected get-hierarchy suggestion, got %#v", cliError.Details["SuggestedCommands"]) + } +} diff --git a/cli/common/clicore/focus_test.go b/cli/common/clicore/focus_test.go index 85633f63cf..d5d56bf831 100644 --- a/cli/common/clicore/focus_test.go +++ b/cli/common/clicore/focus_test.go @@ -3,6 +3,7 @@ package clicore import ( "bytes" "context" + "encoding/json" "fmt" "strings" "testing" @@ -78,3 +79,50 @@ func TestRunFocusWindowWritesFocusFailureVibeLog(t *testing.T) { } } } + +// Verifies that focus-window fails with a JSON error on stderr when the process lookup fails or finds +// no Unity process, and never attempts to focus. +func TestRunFocusWindowReportsLookupFailures(t *testing.T) { + cases := []struct { + name string + process *UnityProcess + lookupError error + expectedMessage string + }{ + {name: "lookup error", lookupError: fmt.Errorf("ps failed"), expectedMessage: "ps failed"}, + {name: "no process", expectedMessage: "No running Unity process found for this project"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + deps := focusWindowDeps{ + findRunningUnityProcess: func(context.Context, string) (*UnityProcess, error) { + return testCase.process, testCase.lookupError + }, + focusUnityProcess: func(context.Context, int) error { + t.Fatal("focus must not be attempted") + return nil + }, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runFocusWindow(context.Background(), t.TempDir(), &stdout, &stderr, deps) + + assertFocusFailure(t, code, stdout.String(), stderr.Bytes(), testCase.expectedMessage) + }) + } +} + +func assertFocusFailure(t *testing.T, code int, stdout string, stderr []byte, expectedMessage string) { + t.Helper() + if code != 1 || stdout != "" { + t.Fatalf("expected exit 1 with no stdout, got %d and %q", code, stdout) + } + var response focusResponse + if err := json.Unmarshal(stderr, &response); err != nil { + t.Fatalf("stderr is not a JSON response: %v (%q)", err, string(stderr)) + } + if response.Success || response.Message != expectedMessage { + t.Fatalf("unexpected focus response: %#v", response) + } +} diff --git a/cli/common/clicore/global_args_test.go b/cli/common/clicore/global_args_test.go new file mode 100644 index 0000000000..e3e9adf5dd --- /dev/null +++ b/cli/common/clicore/global_args_test.go @@ -0,0 +1,151 @@ +package clicore + +import ( + "errors" + "reflect" + "testing" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" +) + +// Verifies that --project-path= is consumed in place and a trailing --project-path without a +// value is rejected as a missing-value argument error. +func TestParseGlobalProjectPathHandlesInlineAndMissingValues(t *testing.T) { + remaining, projectPath, err := ParseGlobalProjectPath([]string{"compile", "--project-path=project-dir", "--force-recompile"}) + if err != nil { + t.Fatalf("ParseGlobalProjectPath failed: %v", err) + } + if projectPath != "project-dir" || !reflect.DeepEqual(remaining, []string{"compile", "--force-recompile"}) { + t.Fatalf("unexpected parse result: path=%q remaining=%#v", projectPath, remaining) + } + + _, _, err = ParseGlobalProjectPath([]string{"compile", "--project-path"}) + var argumentError *clierrors.ArgumentError + if !errors.As(err, &argumentError) || argumentError.Option != "--project-path" { + t.Fatalf("expected a missing-value error for --project-path, got %v", err) + } +} + +// Verifies how ParseFlagValue splits inline values, consumes the next token, and rejects missing values. +func TestParseFlagValue(t *testing.T) { + cases := []struct { + name string + args []string + expectedName string + expectedValue string + expectedConsumed bool + expectedErrorOpt string + expectedErrorMsg string + }{ + {name: "bare double dash", args: []string{"--"}, expectedErrorOpt: "--", expectedErrorMsg: "Invalid option: --"}, + {name: "inline value", args: []string{"--count=3"}, expectedName: "count", expectedValue: "3"}, + {name: "inline empty value", args: []string{"--count="}, expectedErrorOpt: "--count"}, + {name: "next token value", args: []string{"--count", "3"}, expectedName: "count", expectedValue: "3", expectedConsumed: true}, + {name: "negative number value", args: []string{"--offset", "-1.5"}, expectedName: "offset", expectedValue: "-1.5", expectedConsumed: true}, + {name: "last argument", args: []string{"--count"}, expectedErrorOpt: "--count"}, + {name: "followed by option", args: []string{"--count", "--verbose"}, expectedErrorOpt: "--count"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + name, value, consumed, err := ParseFlagValue(testCase.args[0], testCase.args, 0) + assertParseFlagValueError(t, err, testCase.expectedErrorOpt, testCase.expectedErrorMsg) + if name != testCase.expectedName || value != testCase.expectedValue || consumed != testCase.expectedConsumed { + t.Fatalf("ParseFlagValue = (%q, %q, %v), want (%q, %q, %v)", + name, value, consumed, testCase.expectedName, testCase.expectedValue, testCase.expectedConsumed) + } + }) + } +} + +func assertParseFlagValueError(t *testing.T, err error, expectedOption string, expectedMessage string) { + t.Helper() + if expectedOption == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + var argumentError *clierrors.ArgumentError + if !errors.As(err, &argumentError) || argumentError.Option != expectedOption { + t.Fatalf("expected an argument error for %q, got %v", expectedOption, err) + } + if expectedMessage == "" { + expectedMessage = expectedOption + " requires a value" + } + if argumentError.Message != expectedMessage { + t.Fatalf("argument error message = %q, want %q", argumentError.Message, expectedMessage) + } +} + +// Verifies that only dash-prefixed tokens that are not numbers count as the next option. +func TestIsNextOptionToken(t *testing.T) { + cases := map[string]bool{ + "value": false, + "-1": false, + "-0.5": false, + "-v": true, + "--verbose": true, + } + for value, expected := range cases { + if actual := IsNextOptionToken(value); actual != expected { + t.Errorf("IsNextOptionToken(%q) = %v, want %v", value, actual, expected) + } + } +} + +// Verifies recognition of leading options, version, version JSON, and help requests. +func TestRequestShapePredicates(t *testing.T) { + cases := []struct { + name string + predicate func([]string) bool + accepted [][]string + rejected [][]string + }{ + { + name: "version", + predicate: IsVersionRequest, + accepted: [][]string{{"--version"}, {"-v"}}, + rejected: [][]string{{"--version", "--json"}, {"version"}, {}}, + }, + { + name: "version json", + predicate: IsVersionJSONRequest, + accepted: [][]string{{"--version", "--json"}, {"-v", "--json"}}, + rejected: [][]string{{"--version"}, {"--json", "--version"}, {"--version", "--other"}}, + }, + { + name: "help", + predicate: IsHelpRequest, + accepted: [][]string{{"--help"}, {"-h"}}, + rejected: [][]string{{"compile", "--help"}, {"help"}, {"--help", "compile"}}, + }, + { + name: "contains help", + predicate: ContainsHelpRequest, + accepted: [][]string{{"compile", "--help"}, {"-h"}}, + rejected: [][]string{{"compile"}, {}}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + assertPredicate(t, testCase.predicate, testCase.accepted, true) + assertPredicate(t, testCase.predicate, testCase.rejected, false) + }) + } +} + +func assertPredicate(t *testing.T, predicate func([]string) bool, inputs [][]string, expected bool) { + t.Helper() + for _, input := range inputs { + if actual := predicate(input); actual != expected { + t.Errorf("predicate(%#v) = %v, want %v", input, actual, expected) + } + } +} + +// Verifies that a command token starting with a dash is treated as an unknown leading option. +func TestIsUnknownLeadingOption(t *testing.T) { + if !IsUnknownLeadingOption("--bogus") || !IsUnknownLeadingOption("-x") || IsUnknownLeadingOption("compile") { + t.Fatal("leading option detection mismatch") + } +} diff --git a/cli/common/clicore/output_test.go b/cli/common/clicore/output_test.go index e2a91d5cc0..38c4c65b6e 100644 --- a/cli/common/clicore/output_test.go +++ b/cli/common/clicore/output_test.go @@ -46,3 +46,14 @@ func TestWriteJSONFallsBackToRawOutputForInvalidJSON(t *testing.T) { t.Fatalf("WriteJSON fallback mismatch: %q", stdout.String()) } } + +// Verifies that WriteFormat writes the formatted text without adding a newline. +func TestWriteFormatWritesFormattedText(t *testing.T) { + var output bytes.Buffer + + WriteFormat(&output, "%s=%d", "count", 3) + + if output.String() != "count=3" { + t.Fatalf("unexpected output: %q", output.String()) + } +} diff --git a/cli/common/clicore/runner_version_test.go b/cli/common/clicore/runner_version_test.go new file mode 100644 index 0000000000..f1f5453d91 --- /dev/null +++ b/cli/common/clicore/runner_version_test.go @@ -0,0 +1,27 @@ +package clicore + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicontract" +) + +// Verifies that the version JSON reports the embedded project runner and protocol versions. +func TestWriteVersionJSONReportsContractVersions(t *testing.T) { + var stdout bytes.Buffer + + WriteVersionJSON(&stdout) + + var payload struct { + ProjectRunnerVersion string + ProtocolVersion int + } + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("version output is not JSON: %v (%q)", err, stdout.String()) + } + if payload.ProjectRunnerVersion != clicontract.ProjectRunnerVersion() || payload.ProtocolVersion != clicontract.ProtocolVersion() { + t.Fatalf("unexpected version payload: %#v", payload) + } +} diff --git a/cli/common/clicore/string_helpers_test.go b/cli/common/clicore/string_helpers_test.go new file mode 100644 index 0000000000..86bdecf2c3 --- /dev/null +++ b/cli/common/clicore/string_helpers_test.go @@ -0,0 +1,26 @@ +package clicore + +import ( + "errors" + "testing" +) + +// Verifies that FirstNonEmpty returns the first non-empty value, or empty when every value is empty. +func TestFirstNonEmpty(t *testing.T) { + if actual := FirstNonEmpty("", "second", "third"); actual != "second" { + t.Fatalf("FirstNonEmpty = %q, want second", actual) + } + if actual := FirstNonEmpty("", ""); actual != "" { + t.Fatalf("FirstNonEmpty = %q, want empty", actual) + } +} + +// Verifies that ErrorMessage renders a nil error as empty text and otherwise uses Error(). +func TestErrorMessage(t *testing.T) { + if actual := ErrorMessage(nil); actual != "" { + t.Fatalf("ErrorMessage(nil) = %q, want empty", actual) + } + if actual := ErrorMessage(errors.New("boom")); actual != "boom" { + t.Fatalf("ErrorMessage = %q, want boom", actual) + } +} diff --git a/cli/common/clicore/tool_catalog_test.go b/cli/common/clicore/tool_catalog_test.go index 58cfd81047..e5cde932c8 100644 --- a/cli/common/clicore/tool_catalog_test.go +++ b/cli/common/clicore/tool_catalog_test.go @@ -401,3 +401,45 @@ func writeTestSkill(t *testing.T, projectRoot string, relativeDir string, conten t.Helper() clitest.WriteSkillFile(t, projectRoot, relativeDir, skillscan.SkillFileName, content) } + +// Verifies that the project tool cache drops internal skill tools and that a project without a cache +// reports no cache instead of falling back. +func TestLoadProjectToolCacheFiltersInternalToolsAndReportsMissingCache(t *testing.T) { + if _, ok := LoadProjectToolCache(t.TempDir()); ok { + t.Fatal("a project without a tool cache should report no cache") + } + + projectRoot := t.TempDir() + writeTestSkill(t, projectRoot, "Assets/Editor/InternalTool/Skill", "---\nname: uloop-internal-tool\ninternal: true\n---\n") + writeToolCache(t, projectRoot, `{"tools":[{"name":"internal-tool"},{"name":"public-tool"}]}`) + + cache, ok := LoadProjectToolCache(projectRoot) + + if !ok { + t.Fatal("project tool cache should load") + } + if len(cache.Tools) != 1 || cache.Tools[0].Name != "public-tool" { + t.Fatalf("unexpected cached tools: %#v", cache.Tools) + } +} + +// Verifies that a cached tool with no description takes the embedded catalog's description. +func TestApplyEmbeddedDescriptionFallbackFillsMissingToolDescription(t *testing.T) { + embeddedCompile, ok := FindDefaultTool("compile") + if !ok || embeddedCompile.Description == "" { + t.Fatalf("embedded compile tool should have a description: %#v", embeddedCompile) + } + + cache := ApplyEmbeddedDescriptionFallback(ToolsCache{Tools: []ToolDefinition{{Name: "compile"}}}) + + if cache.Tools[0].Description != embeddedCompile.Description { + t.Fatalf("description was not filled: %q", cache.Tools[0].Description) + } +} + +// Verifies that FindDefaultTool reports a miss for a command the embedded catalog does not define. +func TestFindDefaultToolReportsUnknownTool(t *testing.T) { + if _, ok := FindDefaultTool("no-such-tool"); ok { + t.Fatal("unknown tool should not be found") + } +} diff --git a/cli/common/clicore/tool_readiness_test.go b/cli/common/clicore/tool_readiness_test.go index f078ad5c1a..ee4f1a10fe 100644 --- a/cli/common/clicore/tool_readiness_test.go +++ b/cli/common/clicore/tool_readiness_test.go @@ -6,11 +6,14 @@ import ( "errors" "net" "os" + "path/filepath" + "strings" "syscall" "testing" "time" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/project" "github.com/hatayama/unity-cli-loop/common/unityipc" "github.com/hatayama/unity-cli-loop/common/unityprocess" ) @@ -155,3 +158,109 @@ func TestExecuteDynamicCodeReadinessProbeParamsUseForegroundWarmup(t *testing.T) t.Fatalf("readiness probe should not wait for its own reload check: %#v", params[DomainReloadWaitParam]) } } + +// Verifies that a readiness wait whose probes keep failing ends at the timeout with the last probe +// error wrapped, when no Unity process is running. +func TestWaitForToolReadinessTimesOutWithLastProbeError(t *testing.T) { + probeErr := errors.New("probe failed") + probeCount := 0 + deps := toolReadinessDeps{ + probeToolReadinessSequence: func(context.Context, string) error { + probeCount++ + return probeErr + }, + findRunningUnityProcess: func(context.Context, string) (*UnityProcess, error) { + return nil, nil + }, + } + + err := waitForToolReadinessWithDeps(context.Background(), t.TempDir(), ToolReadinessPoll/100, deps) + + if !errors.Is(err, probeErr) || !strings.HasPrefix(err.Error(), "timed out waiting for Unity tool readiness: ") || probeCount == 0 { + t.Fatalf("expected the timeout to wrap the probe error after probing, got %v (probes=%d)", err, probeCount) + } +} + +// Verifies that the public readiness wait returns the caller's cancellation for a directory that is +// not a Unity project, rather than a timeout or the probe error. +func TestWaitForToolReadinessReturnsCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := WaitForToolReadiness(ctx, t.TempDir()) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v", err) + } +} + +// Verifies that a probe sequence against a Unity project with no running server fails on both the +// execute-dynamic-code probe and the get-version probe used when that tool is not in the catalog. +func TestProbeToolReadinessSequenceFailsWithoutAServer(t *testing.T) { + cases := map[string]string{ + "execute-dynamic-code probe": "", + "get-version probe": `{"tools":[{"name":"compile"}]}`, + } + for name, toolCache := range cases { + t.Run(name, func(t *testing.T) { + projectRoot := createReadinessUnityProject(t) + if toolCache != "" { + writeToolCache(t, projectRoot, toolCache) + } + if available := isExecuteDynamicCodeAvailable(projectRoot); available != (toolCache == "") { + t.Fatalf("execute-dynamic-code availability mismatch: %v", available) + } + + ctx, cancel := context.WithTimeout(context.Background(), ToolReadinessProbeTimeout) + defer cancel() + err := ProbeToolReadinessSequence(ctx, projectRoot) + + assertConnectionAttemptToProject(t, err, projectRoot) + }) + } +} + +// Verifies that the server-not-responding error carries the project's IPC endpoint when the +// directory is a Unity project. +func TestToolReadinessDoneErrorIncludesProjectEndpoint(t *testing.T) { + projectRoot := createReadinessUnityProject(t) + deps := toolReadinessDeps{ + findRunningUnityProcess: func(context.Context, string) (*UnityProcess, error) { + return &UnityProcess{Pid: 123}, nil + }, + } + + err := toolReadinessDoneErrorWithDeps(context.Background(), projectRoot, errors.New("probe failed"), deps) + + var notRespondingErr clierrors.UnityServerNotRespondingError + if !errors.As(err, ¬RespondingErr) || !strings.Contains(notRespondingErr.Endpoint, "UnityCliLoop-") { + t.Fatalf("expected an endpoint in the not-responding error, got %#v", err) + } +} + +func createReadinessUnityProject(t *testing.T) string { + t.Helper() + projectRoot := t.TempDir() + for _, directory := range []string{"Assets", "ProjectSettings"} { + if err := os.MkdirAll(filepath.Join(projectRoot, directory), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", directory, err) + } + } + return projectRoot +} + +// assertConnectionAttemptToProject checks that err is a failed connection to projectRoot's own endpoint. +func assertConnectionAttemptToProject(t *testing.T, err error, projectRoot string) { + t.Helper() + canonicalRoot, evalErr := filepath.EvalSymlinks(projectRoot) + if evalErr != nil { + t.Fatalf("failed to resolve project root: %v", evalErr) + } + var attemptErr *unityipc.ConnectionAttemptError + if !errors.As(err, &attemptErr) { + t.Fatalf("expected a connection attempt error, got %v", err) + } + if expected := project.CreateEndpoint(canonicalRoot).Address; attemptErr.Endpoint != expected { + t.Fatalf("connection endpoint = %q, want %q", attemptErr.Endpoint, expected) + } +} diff --git a/cli/common/clicore/tool_settings_test.go b/cli/common/clicore/tool_settings_test.go new file mode 100644 index 0000000000..f21254702c --- /dev/null +++ b/cli/common/clicore/tool_settings_test.go @@ -0,0 +1,58 @@ +package clicore + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clitest" +) + +// Verifies that disabled tools are read from .uloop/settings.tools.json and that a missing, blank, +// unparsable, mistyped, or field-less settings file yields an empty list rather than a partial one. +func TestLoadDisabledTools(t *testing.T) { + cases := []struct { + name string + content *string + expected []string + }{ + {name: "missing file", expected: []string{}}, + {name: "blank file", content: stringPointer(" \n"), expected: []string{}}, + {name: "invalid json", content: stringPointer("{"), expected: []string{}}, + {name: "mistyped entry", content: stringPointer(`{"disabledTools":["compile",1]}`), expected: []string{}}, + {name: "no disabled tools", content: stringPointer(`{"other":true}`), expected: []string{}}, + {name: "disabled tools", content: stringPointer(`{"disabledTools":["compile","get-logs"]}`), expected: []string{"compile", "get-logs"}}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := t.TempDir() + if testCase.content != nil { + clitest.WriteProjectFile(t, projectRoot, filepath.Join(uloopSettingsDir, toolSettingsFile), *testCase.content) + } + + actual := LoadDisabledTools(projectRoot) + + if !reflect.DeepEqual(actual, testCase.expected) { + t.Fatalf("disabled tools mismatch: actual=%#v expected=%#v", actual, testCase.expected) + } + }) + } +} + +func stringPointer(value string) *string { + return &value +} + +// Verifies that a tool counts as disabled only when its exact name is listed. +func TestIsToolDisabledByToolSettings(t *testing.T) { + disabledTools := []string{"compile", "get-logs"} + if !IsToolDisabledByToolSettings("get-logs", disabledTools) { + t.Fatal("listed tool should be disabled") + } + if IsToolDisabledByToolSettings("get-log", disabledTools) { + t.Fatal("unlisted tool should not be disabled") + } + if IsToolDisabledByToolSettings("compile", nil) { + t.Fatal("no settings should disable nothing") + } +} diff --git a/cli/common/clicore/tool_spinner_test.go b/cli/common/clicore/tool_spinner_test.go new file mode 100644 index 0000000000..61ce61c1b3 --- /dev/null +++ b/cli/common/clicore/tool_spinner_test.go @@ -0,0 +1,34 @@ +package clicore + +import ( + "bytes" + "os" + "testing" +) + +// Verifies that a terminal writer gets an animated spinner for regular tools but not for +// execute-dynamic-code, and that a non-terminal writer never gets one. +func TestNewToolSpinner(t *testing.T) { + // The null device reports itself as a character device, which is how a terminal is detected. + terminal, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("failed to open null device: %v", err) + } + t.Cleanup(func() { _ = terminal.Close() }) + + compileSpinner := NewToolSpinner(terminal, "compile") + compileSpinner.Stop() + if !compileSpinner.Enabled { + t.Fatal("a regular tool should show a spinner on a terminal") + } + if NewToolSpinner(terminal, ExecuteDynamicCodeCommandName).Enabled { + t.Fatal("execute-dynamic-code should not show a spinner") + } + + var stderr bytes.Buffer + spinner := NewToolSpinner(&stderr, "compile") + + if spinner.Enabled || stderr.Len() != 0 { + t.Fatalf("spinner should stay disabled for a non-terminal writer: enabled=%v output=%q", spinner.Enabled, stderr.String()) + } +} diff --git a/cli/common/errors/argument_error_test.go b/cli/common/errors/argument_error_test.go new file mode 100644 index 0000000000..fa45b2a60f --- /dev/null +++ b/cli/common/errors/argument_error_test.go @@ -0,0 +1,91 @@ +package clierrors + +import ( + "reflect" + "testing" +) + +// Verifies an ArgumentError reports its message as the error text and maps every +// populated field into the CLI error envelope, keeping its own command and actions. +func TestArgumentErrorToCLIErrorCarriesPopulatedFields(t *testing.T) { + err := &ArgumentError{ + Message: "bad value", + Option: "--count", + Received: "abc", + ExpectedType: "integer", + Command: "run-tests", + NextActions: []string{"Pass a number."}, + } + + cliErr := err.ToCLIError(ErrorContext{ProjectRoot: "", Command: "compile"}) + + if err.Error() != "bad value" { + t.Fatalf("unexpected Error(): %q", err.Error()) + } + want := CLIError{ + ErrorCode: ErrorCodeInvalidArgument, + Phase: ErrorPhaseArgumentParsing, + Message: "bad value", + ProjectRoot: "", + Command: "run-tests", + NextActions: []string{"Pass a number."}, + Details: map[string]any{ + "Option": "--count", + "Received": "abc", + "ExpectedType": "integer", + }, + } + if !reflect.DeepEqual(cliErr, want) { + t.Fatalf("unexpected CLI error:\n got: %#v\nwant: %#v", cliErr, want) + } +} + +// Verifies an ArgumentError without optional fields falls back to the context command, +// the default retry guidance, and empty details. +func TestArgumentErrorToCLIErrorFallsBackWhenFieldsAreEmpty(t *testing.T) { + err := &ArgumentError{Message: "unknown flag"} + + cliErr := err.ToCLIError(ErrorContext{Command: "compile"}) + + if cliErr.Command != "compile" { + t.Fatalf("expected context command fallback, got %q", cliErr.Command) + } + if !reflect.DeepEqual(cliErr.NextActions, []string{"Correct the command arguments and retry."}) { + t.Fatalf("unexpected default next actions: %#v", cliErr.NextActions) + } + if len(cliErr.Details) != 0 { + t.Fatalf("expected no details for empty fields, got %#v", cliErr.Details) + } + if cliErr.Retryable || cliErr.SafeToRetry { + t.Fatalf("argument errors must not be retryable: %#v", cliErr) + } +} + +// Verifies the missing-value constructor names the option and explains both value syntaxes. +func TestMissingValueArgumentErrorDescribesOption(t *testing.T) { + err := MissingValueArgumentError("--filter") + + if err.Message != "--filter requires a value" || err.Option != "--filter" { + t.Fatalf("unexpected message or option: %#v", err) + } + expectedActions := []string{"Pass a value after `--filter` or use `--filter=`."} + if !reflect.DeepEqual(err.NextActions, expectedActions) { + t.Fatalf("unexpected next actions: %#v", err.NextActions) + } +} + +// Verifies the invalid-value constructor reports the received value and expected type. +func TestInvalidValueArgumentErrorDescribesReceivedValue(t *testing.T) { + err := InvalidValueArgumentError("--count", "abc", "integer") + + want := &ArgumentError{ + Message: "Invalid integer value for --count: abc", + Option: "--count", + Received: "abc", + ExpectedType: "integer", + NextActions: []string{"Pass a valid integer value for `--count`."}, + } + if !reflect.DeepEqual(err, want) { + t.Fatalf("unexpected error:\n got: %#v\nwant: %#v", err, want) + } +} diff --git a/cli/common/errors/busy_status_test.go b/cli/common/errors/busy_status_test.go new file mode 100644 index 0000000000..44ed57e248 --- /dev/null +++ b/cli/common/errors/busy_status_test.go @@ -0,0 +1,24 @@ +package clierrors + +import "testing" + +// Verifies the busy message falls back to the server text when either tool name is unknown. +func TestUnityServerBusyMessageFallsBackWithoutToolNames(t *testing.T) { + cases := []struct { + name string + data serverBusyErrorData + requestedCommand string + }{ + {name: "running tool unknown", data: serverBusyErrorData{RequestedToolName: "compile"}}, + {name: "requested tool unknown", data: serverBusyErrorData{RunningToolName: "run-tests"}}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got := unityServerBusyMessage("server busy", testCase.data, testCase.requestedCommand) + if got != "server busy" { + t.Fatalf("expected fallback message, got %q", got) + } + }) + } +} diff --git a/cli/common/errors/error_editor_unresponsive_test.go b/cli/common/errors/error_editor_unresponsive_test.go new file mode 100644 index 0000000000..589d3ced2c --- /dev/null +++ b/cli/common/errors/error_editor_unresponsive_test.go @@ -0,0 +1,30 @@ +package clierrors + +import ( + "errors" + "testing" + + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +// Verifies the connection-attempt cause text is empty for a nil error or a missing +// cause, and is the cause message otherwise. +func TestConnectionAttemptCause(t *testing.T) { + cases := []struct { + name string + err *unityipc.ConnectionAttemptError + want string + }{ + {name: "nil error", err: nil, want: ""}, + {name: "nil cause", err: &unityipc.ConnectionAttemptError{}, want: ""}, + {name: "with cause", err: &unityipc.ConnectionAttemptError{Cause: errors.New("connection refused")}, want: "connection refused"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := connectionAttemptCause(testCase.err); got != testCase.want { + t.Fatalf("expected %q, got %q", testCase.want, got) + } + }) + } +} diff --git a/cli/common/errors/project_errors_test.go b/cli/common/errors/project_errors_test.go new file mode 100644 index 0000000000..fa9452292b --- /dev/null +++ b/cli/common/errors/project_errors_test.go @@ -0,0 +1,56 @@ +package clierrors + +import "testing" + +// Verifies each project resolution error renders its message and maps to the +// project-not-found envelope with the context command. +func TestProjectResolveErrorsMapToProjectNotFound(t *testing.T) { + cases := []struct { + name string + err interface { + error + ToCLIError(ErrorContext) CLIError + } + message string + }{ + { + name: "project not found", + err: ProjectNotFoundError{}, + message: "unity project not found. Use --project-path option to specify the target", + }, + { + name: "multiple projects", + err: MultipleProjectsFoundError{SearchRoot: ""}, + message: "multiple Unity projects found under ; use --project-path to choose one", + }, + { + name: "not a Unity project", + err: NotUnityProjectError{ProjectRoot: ""}, + message: "not a Unity project: ", + }, + { + name: "not a Unity project with suggestion", + err: NotUnityProjectError{ProjectRoot: "/c/", Suggestion: "C:\\"}, + message: "not a Unity project: /c/. This looks like a WSL or Git Bash path. Did you mean: C:\\", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + cliErr := testCase.err.ToCLIError(ErrorContext{Command: "compile"}) + + if testCase.err.Error() != testCase.message { + t.Fatalf("unexpected Error(): %q", testCase.err.Error()) + } + if cliErr.ErrorCode != errorCodeProjectNotFound || cliErr.Phase != ErrorPhaseProjectResolve { + t.Fatalf("unexpected classification: %#v", cliErr) + } + if cliErr.Message != testCase.message || cliErr.Command != "compile" { + t.Fatalf("unexpected message or command: %#v", cliErr) + } + if len(cliErr.NextActions) != 2 { + t.Fatalf("expected two recovery actions, got %#v", cliErr.NextActions) + } + }) + } +} diff --git a/cli/common/errors/server_not_responding_test.go b/cli/common/errors/server_not_responding_test.go new file mode 100644 index 0000000000..ee3bc58ce9 --- /dev/null +++ b/cli/common/errors/server_not_responding_test.go @@ -0,0 +1,45 @@ +package clierrors + +import ( + "errors" + "testing" +) + +// Verifies the not-responding error appends its cause to the message and exposes +// the cause text only when a cause exists. +func TestUnityServerNotRespondingErrorDescribesCause(t *testing.T) { + cases := []struct { + name string + cause error + message string + causeText string + }{ + { + name: "without cause", + message: "Unity is running but the Unity CLI Loop server is not responding", + causeText: "", + }, + { + name: "with cause", + cause: errors.New("i/o timeout"), + message: "Unity is running but the Unity CLI Loop server is not responding: i/o timeout", + causeText: "i/o timeout", + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := UnityServerNotRespondingError{Cause: testCase.cause} + + if err.Error() != testCase.message { + t.Fatalf("unexpected Error(): %q", err.Error()) + } + if err.causeText() != testCase.causeText { + t.Fatalf("unexpected cause text: %q", err.causeText()) + } + if err.Unwrap() != testCase.cause { + t.Fatalf("expected Unwrap to return the cause, got %v", err.Unwrap()) + } + }) + } +} diff --git a/cli/common/errors/string_helpers_test.go b/cli/common/errors/string_helpers_test.go new file mode 100644 index 0000000000..69cd20fa6a --- /dev/null +++ b/cli/common/errors/string_helpers_test.go @@ -0,0 +1,13 @@ +package clierrors + +import "testing" + +// Verifies firstNonEmpty returns the first non-empty value, or empty when all are empty. +func TestFirstNonEmpty(t *testing.T) { + if got := firstNonEmpty("", "b", "c"); got != "b" { + t.Fatalf("expected first non-empty value, got %q", got) + } + if got := firstNonEmpty("", ""); got != "" { + t.Fatalf("expected empty result when every value is empty, got %q", got) + } +} diff --git a/cli/common/project/project_test.go b/cli/common/project/project_test.go index b60844067f..ae96258ccd 100644 --- a/cli/common/project/project_test.go +++ b/cli/common/project/project_test.go @@ -392,3 +392,332 @@ func assertProjectConnection(t *testing.T, connection unityipc.Connection, proje t.Fatalf("endpoint mismatch: %#v", connection.Endpoint) } } + +func TestResolveConnection_WhenExplicitPathIsUnityProject_ShouldUseThatProject(t *testing.T) { + // Verifies the public entry point resolves an explicit project path instead of searching from the start path. + startPath := createGitBoundedDir(t) + projectRoot := filepath.Join(t.TempDir(), "Game") + createUnityProject(t, projectRoot) + + connection, err := ResolveConnection(startPath, projectRoot) + if err != nil { + t.Fatalf("ResolveConnection failed: %v", err) + } + assertProjectConnection(t, connection, projectRoot) +} + +func TestResolveConnection_WhenExplicitPathIsNotUnityProject_ShouldReturnNotUnityProjectError(t *testing.T) { + // Verifies an explicit non-Unity path is rejected with a typed error naming the absolute path. + notProjectRoot := t.TempDir() + + _, err := ResolveConnection(notProjectRoot, notProjectRoot) + + var notUnityErr clierrors.NotUnityProjectError + if !stderrors.As(err, ¬UnityErr) { + t.Fatalf("expected NotUnityProjectError, got %T: %v", err, err) + } + if notUnityErr.ProjectRoot != notProjectRoot { + t.Fatalf("project root mismatch: %s", notUnityErr.ProjectRoot) + } +} + +func TestResolveConnection_WhenNoProjectEnclosesStartPath_ShouldReturnProjectNotFoundError(t *testing.T) { + // Verifies implicit resolution fails with ProjectNotFoundError when no Unity project is found before the git root. + startPath := filepath.Join(createGitBoundedDir(t), "sub") + mkdirAll(t, startPath) + + _, err := ResolveConnection(startPath, "") + + assertProjectNotFound(t, err) +} + +func TestFindProjectRoot_WhenStartedInsideProject_ShouldReturnEnclosingProject(t *testing.T) { + // Verifies the upward search returns the nearest enclosing Unity project root. + projectRoot := filepath.Join(createGitBoundedDir(t), "Game") + createUnityProject(t, projectRoot) + startPath := filepath.Join(projectRoot, "Assets", "Scripts") + mkdirAll(t, startPath) + + resolved, err := FindProjectRoot(startPath) + if err != nil { + t.Fatalf("FindProjectRoot failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: %s", resolved) + } +} + +func TestFindProjectRoot_WhenGitRootIsReachedFirst_ShouldStopSearching(t *testing.T) { + // Verifies the upward search stops at a .git boundary even when a Unity project exists above it. + outerProject := t.TempDir() + createUnityProject(t, outerProject) + repositoryRoot := filepath.Join(outerProject, "repo") + mkdirAll(t, filepath.Join(repositoryRoot, ".git")) + startPath := filepath.Join(repositoryRoot, "src") + mkdirAll(t, startPath) + + _, err := FindProjectRoot(startPath) + + assertProjectNotFound(t, err) +} + +func TestFindProjectRoot_WhenFilesystemRootIsReached_ShouldReturnProjectNotFoundError(t *testing.T) { + // Verifies the upward search terminates at the filesystem root instead of looping forever. + root := filesystemRootWithoutProject(t) + + _, err := FindProjectRoot(root) + + assertProjectNotFound(t, err) +} + +func TestFindUnityProjectRoot_WhenStartedInsideProject_ShouldReturnEnclosingProject(t *testing.T) { + // Verifies parent-only resolution finds the enclosing Unity project from a nested directory. + projectRoot := filepath.Join(createGitBoundedDir(t), "Game") + createUnityProject(t, projectRoot) + startPath := filepath.Join(projectRoot, "Packages") + mkdirAll(t, startPath) + + resolved, err := FindUnityProjectRoot(startPath) + if err != nil { + t.Fatalf("FindUnityProjectRoot failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: %s", resolved) + } +} + +func TestFindUnityProjectRoot_WhenOnlyChildProjectExists_ShouldNotSearchChildren(t *testing.T) { + // Verifies parent-only resolution ignores Unity projects below the start path and stops at the git root. + startPath := createGitBoundedDir(t) + createUnityProject(t, filepath.Join(startPath, "Game")) + + _, err := FindUnityProjectRoot(startPath) + + assertProjectNotFound(t, err) +} + +func TestFindUnityProjectRoot_WhenFilesystemRootIsReached_ShouldReturnProjectNotFoundError(t *testing.T) { + // Verifies parent-only resolution terminates at the filesystem root. + root := filesystemRootWithoutProject(t) + + _, err := FindUnityProjectRoot(root) + + assertProjectNotFound(t, err) +} + +func TestFindUnityProjectRootWithin_WhenStartIsProject_ShouldReturnStartPath(t *testing.T) { + // Verifies the start directory wins over nested Unity-shaped children when it is itself a project. + projectRoot := t.TempDir() + createUnityProject(t, projectRoot) + createUnityProject(t, filepath.Join(projectRoot, "nested", "Game")) + + resolved, err := FindUnityProjectRootWithin(projectRoot, 3) + if err != nil { + t.Fatalf("FindUnityProjectRootWithin failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: %s", resolved) + } +} + +func TestFindUnityProjectRootWithin_WhenNoChildProject_ShouldFallBackToParents(t *testing.T) { + // Verifies the child search falls back to the enclosing project when no child project exists. + projectRoot := filepath.Join(createGitBoundedDir(t), "Game") + createUnityProject(t, projectRoot) + startPath := filepath.Join(projectRoot, "Assets", "Scripts") + mkdirAll(t, startPath) + + resolved, err := FindUnityProjectRootWithin(startPath, 3) + if err != nil { + t.Fatalf("FindUnityProjectRootWithin failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: %s", resolved) + } +} + +func TestFindUnityProjectRootWithin_WhenChildDirectoryIsUnreadable_ShouldSkipIt(t *testing.T) { + // Verifies an unreadable child directory is skipped and the remaining readable child project is still found. + workspaceRoot := createGitBoundedDir(t) + projectRoot := filepath.Join(workspaceRoot, "readable", "Game") + createUnityProject(t, projectRoot) + // Named to be scanned before "readable", so stopping the scan at an unreadable child would fail. + unreadableDir := filepath.Join(workspaceRoot, "a-unreadable") + mkdirAll(t, unreadableDir) + if err := os.Chmod(unreadableDir, 0o000); err != nil { + t.Fatalf("failed to make directory unreadable: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(unreadableDir, 0o755) + }) + // Root, and Windows where Chmod only toggles the read-only attribute, can still read the directory. + if _, err := os.ReadDir(unreadableDir); err == nil { + t.Skip("the directory is still readable, so the unreadable branch is not reached") + } + + resolved, err := FindUnityProjectRootWithin(workspaceRoot, 3) + if err != nil { + t.Fatalf("FindUnityProjectRootWithin failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: %s", resolved) + } +} + +func TestFindUnityProjectRootPreferringParents_WhenNoProjectAnywhere_ShouldReturnParentError(t *testing.T) { + // Verifies the parents-first search reports ProjectNotFoundError when neither ancestors nor children are projects. + startPath := createGitBoundedDir(t) + mkdirAll(t, filepath.Join(startPath, "docs")) + + _, err := FindUnityProjectRootPreferringParents(startPath, 3) + + assertProjectNotFound(t, err) +} + +func TestResolveExplicitProjectRoot_WhenPathIsUnityProject_ShouldReturnAbsolutePath(t *testing.T) { + // Verifies a valid explicit project path resolves to its absolute form. + projectRoot := t.TempDir() + createUnityProject(t, projectRoot) + + resolved, err := ResolveExplicitProjectRoot(projectRoot) + if err != nil { + t.Fatalf("ResolveExplicitProjectRoot failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: %s", resolved) + } +} + +func TestResolveExplicitProjectRoot_WhenPathIsNotUnityProject_ShouldReturnNotUnityProjectError(t *testing.T) { + // Verifies an explicit directory without Assets and ProjectSettings is rejected with a typed error. + notProjectRoot := t.TempDir() + mkdirAll(t, filepath.Join(notProjectRoot, "Assets")) + + _, err := ResolveExplicitProjectRoot(notProjectRoot) + + var notUnityErr clierrors.NotUnityProjectError + if !stderrors.As(err, ¬UnityErr) { + t.Fatalf("expected NotUnityProjectError, got %T: %v", err, err) + } + if notUnityErr.ProjectRoot != notProjectRoot { + t.Fatalf("project root mismatch: %s", notUnityErr.ProjectRoot) + } +} + +func TestWindowsPosixProjectPathCandidate_ShouldConvertOnlyDriveShapedPaths(t *testing.T) { + // Verifies Git Bash and WSL drive paths convert to Win32 paths while other shapes are rejected. + cases := []struct { + input string + expected string + converted bool + }{ + {input: "", expected: "", converted: false}, + {input: "relative/Game", expected: "", converted: false}, + {input: "/c", expected: `C:\`, converted: true}, + {input: "/c/x", expected: `C:\x`, converted: true}, + {input: "/C/x/y", expected: `C:\x\y`, converted: true}, + {input: `/c\x`, expected: `C:\x`, converted: true}, + {input: "/mnt/d", expected: `D:\`, converted: true}, + {input: "/mnt/d/x", expected: `D:\x`, converted: true}, + {input: "/MNT/d/x", expected: `D:\x`, converted: true}, + {input: "/mnt/dx", expected: "", converted: false}, + {input: "/mnt/1/x", expected: "", converted: false}, + {input: "/1/x", expected: "", converted: false}, + } + + for _, testCase := range cases { + t.Run(testCase.input, func(t *testing.T) { + candidate, converted := windowsPosixProjectPathCandidate(testCase.input) + if converted != testCase.converted || candidate != testCase.expected { + t.Fatalf("got (%q, %v), want (%q, %v)", candidate, converted, testCase.expected, testCase.converted) + } + }) + } +} + +func TestWindowsDrivePath_ShouldUppercaseDriveAndUseBackslashes(t *testing.T) { + // Verifies drive letters are uppercased and forward slashes in the rest become backslashes. + cases := []struct { + driveLetter byte + rest string + expected string + }{ + {driveLetter: 'e', rest: "", expected: `E:\`}, + {driveLetter: 'E', rest: "a/b", expected: `E:\a\b`}, + } + + for _, testCase := range cases { + actual := windowsDrivePath(testCase.driveLetter, testCase.rest) + if actual != testCase.expected { + t.Fatalf("windowsDrivePath(%q, %q) = %q, want %q", testCase.driveLetter, testCase.rest, actual, testCase.expected) + } + } +} + +func TestToUpperASCIILetter_ShouldUppercaseOnlyLowercaseLetters(t *testing.T) { + // Verifies lowercase letters are uppercased while uppercase letters and digits are returned unchanged. + cases := map[byte]byte{'a': 'A', 'z': 'Z', 'A': 'A', '1': '1'} + + for input, expected := range cases { + if actual := toUpperASCIILetter(input); actual != expected { + t.Fatalf("toUpperASCIILetter(%q) = %q, want %q", input, actual, expected) + } + } +} + +func TestTrimTrailingSeparators_ShouldKeepRootAndRemoveTrailingSeparators(t *testing.T) { + // Verifies the POSIX root survives trimming while other paths lose only their trailing separators. + cases := map[string]string{ + "/": "/", + "///": "/", + "": "", + "a/": "a", + "abc": "abc", + "/tmp/Game/": "/tmp/Game", + } + + for input, expected := range cases { + if actual := trimTrailingSeparators(input); actual != expected { + t.Fatalf("trimTrailingSeparators(%q) = %q, want %q", input, actual, expected) + } + } +} + +// createGitBoundedDir returns a temp directory containing .git so upward searches stop there. +func createGitBoundedDir(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + mkdirAll(t, filepath.Join(dir, ".git")) + return dir +} + +func mkdirAll(t *testing.T, path string) { + t.Helper() + + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", path, err) + } +} + +// filesystemRootWithoutProject returns the filesystem root of the temp directory, skipping when +// that root itself would end the search early (a Unity project or .git at the root). +func filesystemRootWithoutProject(t *testing.T) string { + t.Helper() + + tempDir := t.TempDir() + root := filepath.VolumeName(tempDir) + string(filepath.Separator) + if IsUnityProject(root) || exists(filepath.Join(root, ".git")) { + t.Skip("filesystem root is a Unity project or git repository") + } + return root +} + +func assertProjectNotFound(t *testing.T, err error) { + t.Helper() + + var projectNotFoundErr clierrors.ProjectNotFoundError + if !stderrors.As(err, &projectNotFoundErr) { + t.Fatalf("expected ProjectNotFoundError, got %T: %v", err, err) + } +} diff --git a/cli/common/ui/spinner_test.go b/cli/common/ui/spinner_test.go index face7ac28d..793d141dda 100644 --- a/cli/common/ui/spinner_test.go +++ b/cli/common/ui/spinner_test.go @@ -2,6 +2,8 @@ package ui import ( "bytes" + "io" + "os" "strings" "testing" @@ -118,3 +120,146 @@ func TestNewToolSpinnerRespectsFeedbackFlag(t *testing.T) { t.Fatalf("disabled tool spinner wrote output: %q", stderr.String()) } } + +// Opens a temporary regular file that is not a terminal and closes it at test end. +func openRegularFile(t *testing.T) *os.File { + t.Helper() + file, err := os.CreateTemp(t.TempDir(), "spinner-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + return file +} + +// Opens the null device, which reports itself as a character device like a terminal. +func openCharacterDevice(t *testing.T) *os.File { + t.Helper() + file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("failed to open null device: %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + return file +} + +func TestIsTerminalWriterDetectsCharacterDevices(t *testing.T) { + // Verifies only character-device files count as terminals; buffers, regular + // files, and files that can no longer be stat'ed do not. + closedFile := openRegularFile(t) + if err := closedFile.Close(); err != nil { + t.Fatalf("failed to close temp file: %v", err) + } + + cases := []struct { + name string + writer io.Writer + want bool + }{ + {name: "buffer", writer: &bytes.Buffer{}, want: false}, + {name: "regular file", writer: openRegularFile(t), want: false}, + {name: "closed file", writer: closedFile, want: false}, + {name: "character device", writer: openCharacterDevice(t), want: true}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := isTerminalWriter(testCase.writer); got != testCase.want { + t.Fatalf("expected %v, got %v", testCase.want, got) + } + }) + } +} + +func TestNewLaunchSpinnerStaysSilentWithoutTerminal(t *testing.T) { + // Verifies a launch spinner is disabled and writes nothing when neither stream is a terminal. + stdout := openRegularFile(t) + stderr := openRegularFile(t) + + spinner := NewLaunchSpinner(stdout, stderr) + spinner.Update("Still waiting...") + spinner.Stop() + + if spinner.Enabled { + t.Fatalf("expected launch spinner to be disabled without a terminal") + } + for _, file := range []*os.File{stdout, stderr} { + info, err := file.Stat() + if err != nil { + t.Fatalf("failed to stat output file: %v", err) + } + if info.Size() != 0 { + t.Fatalf("disabled launch spinner wrote %d bytes to %s", info.Size(), file.Name()) + } + } +} + +func TestNewLaunchSpinnerPrefersTerminalStream(t *testing.T) { + // Verifies a launch spinner renders on stdout when it is a terminal, and falls + // back to stderr only when stdout is not. + cases := []struct { + name string + stdoutTerminal bool + stderrTerminal bool + wantStdout bool + }{ + {name: "stdout terminal", stdoutTerminal: true, stderrTerminal: true, wantStdout: true}, + {name: "stderr terminal only", stdoutTerminal: false, stderrTerminal: true, wantStdout: false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + stdout := pickSpinnerStream(t, testCase.stdoutTerminal) + stderr := pickSpinnerStream(t, testCase.stderrTerminal) + + spinner := NewLaunchSpinner(stdout, stderr) + spinner.Stop() + + want := stderr + if testCase.wantStdout { + want = stdout + } + if !spinner.Enabled || spinner.writer != want { + t.Fatalf("expected enabled spinner writing to %s, got enabled=%v writer=%v", want.Name(), spinner.Enabled, spinner.writer) + } + }) + } +} + +// Returns a character-device stream when terminal is true, otherwise a regular file. +func pickSpinnerStream(t *testing.T, terminal bool) *os.File { + t.Helper() + if terminal { + return openCharacterDevice(t) + } + return openRegularFile(t) +} + +func TestDisabledSpinnerUpdateWritesNothing(t *testing.T) { + // Verifies Update on a disabled spinner neither renders nor changes the stored message. + var stderr bytes.Buffer + spinner := newSpinner(&stderr, false, "Connecting to Unity...") + + spinner.Update("Executing compile...") + + if stderr.Len() != 0 { + t.Fatalf("disabled spinner Update wrote output: %q", stderr.String()) + } + if spinner.message != "Connecting to Unity..." { + t.Fatalf("disabled spinner Update changed the message to %q", spinner.message) + } +} + +func TestSpinnerRenderAfterStopWritesNothing(t *testing.T) { + // Verifies a frame rendered after Stop (as a late ticker tick would) leaves the cleared line intact. + var stderr bytes.Buffer + spinner := newSpinner(&stderr, true, "Executing compile...") + spinner.Stop() + afterStop := stderr.String() + + spinner.render() + + if stderr.String() != afterStop { + t.Fatalf("render after Stop wrote output: %q", stderr.String()[len(afterStop):]) + } +} diff --git a/cli/common/vibelog/cli_vibe_test.go b/cli/common/vibelog/cli_vibe_test.go index 9d4e52af52..c637f532e1 100644 --- a/cli/common/vibelog/cli_vibe_test.go +++ b/cli/common/vibelog/cli_vibe_test.go @@ -1,8 +1,19 @@ package vibelog import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" "testing" + "time" ) // Verifies CLI Vibe logs are skipped unless ULOOP_DEBUG is enabled. @@ -26,3 +37,225 @@ func TestWriteCLIVibeLogSkipsWhenDebugDisabled(t *testing.T) { t.Fatalf("expected no CLI Vibe logs, got %d: %#v", len(logFiles), logFiles) } } + +// Reads every CLI Vibe log line written under projectRoot and decodes it. +func readCLIVibeLogEntries(t *testing.T, projectRoot string) []CLIVibeLogEntry { + t.Helper() + logFiles, err := filepath.Glob(filepath.Join(projectRoot, CLIVibeLogDirectory, CLIVibeLogPrefix+"_*.json")) + if err != nil { + t.Fatalf("failed to glob CLI Vibe logs: %v", err) + } + if len(logFiles) != 1 { + t.Fatalf("expected one CLI Vibe log file, got %#v", logFiles) + } + if !cliVibeLogFileNamePattern.MatchString(filepath.Base(logFiles[0])) { + t.Fatalf("unexpected CLI Vibe log file name: %s", filepath.Base(logFiles[0])) + } + content, err := os.ReadFile(logFiles[0]) + if err != nil { + t.Fatalf("failed to read CLI Vibe log: %v", err) + } + lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + entries := make([]CLIVibeLogEntry, 0, len(lines)) + for _, line := range lines { + var entry CLIVibeLogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("CLI Vibe log line is not JSON: %q: %v", line, err) + } + entries = append(entries, entry) + } + return entries +} + +var ( + cliVibeLogFileNamePattern = regexp.MustCompile(`^cli_vibe_\d{8}\.json$`) + cliVibeCorrelationIDFormat = regexp.MustCompile(`^cli_\d+_(\d+)$`) +) + +// Verifies a correlation ID embeds a nanosecond timestamp and the current process ID. +func TestNewCLIVibeCorrelationIDIncludesProcessID(t *testing.T) { + id := NewCLIVibeCorrelationID() + + match := cliVibeCorrelationIDFormat.FindStringSubmatch(id) + if match == nil { + t.Fatalf("unexpected correlation ID format: %q", id) + } + if match[1] != strconv.Itoa(os.Getpid()) { + t.Fatalf("correlation ID %q does not end with pid %d", id, os.Getpid()) + } +} + +// Verifies an enabled log with no project root is a silent no-op. +func TestWriteCLIVibeLogSkipsWithoutProjectRoot(t *testing.T) { + t.Setenv(CLIVibeLogEnvName, "1") + // An empty root would otherwise resolve against the working directory, so isolate it. + workingDirectory := t.TempDir() + t.Chdir(workingDirectory) + + if err := WriteCLIVibeLog("", CLIVibeLogEntry{Operation: "test_operation"}); err != nil { + t.Fatalf("WriteCLIVibeLog without project root should skip without error: %v", err) + } + if _, err := os.Stat(filepath.Join(workingDirectory, CLIVibeLogDirectory)); !os.IsNotExist(err) { + t.Fatalf("no log directory should be created without a project root, stat error: %v", err) + } +} + +// Verifies an enabled log fills in timestamp, correlation ID, and source defaults +// and appends one JSON line per entry to the dated log file. +func TestWriteCLIVibeLogAppendsEntriesWithDefaults(t *testing.T) { + t.Setenv(CLIVibeLogEnvName, "true") + projectRoot := t.TempDir() + + first := CLIVibeLogEntry{Level: "INFO", Operation: "first_operation", Message: "first"} + second := CLIVibeLogEntry{ + Timestamp: "2026-01-02T03:04:05.000+00:00", + Level: "WARNING", + Operation: "second_operation", + Message: "second", + CorrelationID: "fixed_id", + Source: "Runner", + Context: map[string]any{"command": "compile"}, + } + for _, entry := range []CLIVibeLogEntry{first, second} { + if err := WriteCLIVibeLog(projectRoot, entry); err != nil { + t.Fatalf("WriteCLIVibeLog failed: %v", err) + } + } + + entries := readCLIVibeLogEntries(t, projectRoot) + if len(entries) != 2 { + t.Fatalf("expected two appended entries, got %#v", entries) + } + assertDefaultedCLIVibeEntry(t, entries[0]) + if !reflect.DeepEqual(entries[1], second) { + t.Fatalf("explicit fields were not preserved:\n got: %#v\nwant: %#v", entries[1], second) + } +} + +// Asserts the defaulted fields of an entry written without timestamp, correlation ID, or source. +func assertDefaultedCLIVibeEntry(t *testing.T, entry CLIVibeLogEntry) { + t.Helper() + if entry.Operation != "first_operation" || entry.Message != "first" || entry.Level != "INFO" { + t.Fatalf("unexpected entry fields: %#v", entry) + } + if _, err := time.Parse("2006-01-02T15:04:05.000-07:00", entry.Timestamp); err != nil { + t.Fatalf("default timestamp %q does not match the log format: %v", entry.Timestamp, err) + } + if !cliVibeCorrelationIDFormat.MatchString(entry.CorrelationID) { + t.Fatalf("default correlation ID has unexpected format: %q", entry.CorrelationID) + } + if entry.Source != "CLI" { + t.Fatalf("expected default source CLI, got %q", entry.Source) + } +} + +// Verifies a log directory that cannot be created is reported as an error. +func TestWriteCLIVibeLogReportsDirectoryCreationFailure(t *testing.T) { + t.Setenv(CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + blocker := filepath.Join(projectRoot, ".uloop") + if err := os.WriteFile(blocker, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("failed to create blocking file: %v", err) + } + + err := WriteCLIVibeLog(projectRoot, CLIVibeLogEntry{Operation: "test_operation"}) + + assertPathErrorOp(t, err, "mkdir") +} + +// Verifies a log path occupied by a directory is reported as an open error. +func TestWriteCLIVibeLogReportsOpenFailure(t *testing.T) { + t.Setenv(CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + logPath := filepath.Join(projectRoot, CLIVibeLogDirectory, fmt.Sprintf("%s_%s.json", CLIVibeLogPrefix, time.Now().UTC().Format("20060102"))) + if err := os.MkdirAll(logPath, 0o755); err != nil { + t.Fatalf("failed to create blocking directory: %v", err) + } + + err := WriteCLIVibeLog(projectRoot, CLIVibeLogEntry{Operation: "test_operation"}) + + assertPathErrorOp(t, err, "open") +} + +// Verifies an entry whose context cannot be encoded as JSON is reported as an error. +func TestWriteCLIVibeLogReportsMarshalFailure(t *testing.T) { + t.Setenv(CLIVibeLogEnvName, "1") + projectRoot := t.TempDir() + + err := WriteCLIVibeLog(projectRoot, CLIVibeLogEntry{Context: map[string]any{"bad": make(chan int)}}) + + var unsupportedTypeErr *json.UnsupportedTypeError + if !errors.As(err, &unsupportedTypeErr) { + t.Fatalf("expected a JSON unsupported type error, got %v", err) + } +} + +// Verifies ULOOP_DEBUG disables logging for empty, zero, and any-case "false" values +// and enables it for anything else. +func TestIsCLIVibeLogEnabled(t *testing.T) { + cases := []struct { + value string + want bool + }{ + {value: "", want: false}, + {value: " ", want: false}, + {value: "0", want: false}, + {value: "false", want: false}, + {value: "FALSE", want: false}, + {value: " False ", want: false}, + {value: "1", want: true}, + {value: "true", want: true}, + } + + for _, testCase := range cases { + t.Run(fmt.Sprintf("%q", testCase.value), func(t *testing.T) { + t.Setenv(CLIVibeLogEnvName, testCase.value) + if got := IsCLIVibeLogEnabled(); got != testCase.want { + t.Fatalf("expected %v for %q, got %v", testCase.want, testCase.value, got) + } + }) + } +} + +// Verifies project identity is empty without a root, hashes the canonical path so +// symlinked roots share an identity, and falls back to the raw path when it does not exist. +func TestProjectIdentity(t *testing.T) { + if got := ProjectIdentity(""); got != "" { + t.Fatalf("expected empty identity for empty root, got %q", got) + } + + realRoot, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("failed to resolve temp dir: %v", err) + } + linkRoot := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(realRoot, linkRoot); err != nil { + t.Skipf("symlinks are not available: %v", err) + } + + identity := ProjectIdentity(realRoot) + if identity != expectedProjectIdentity(realRoot) { + t.Fatalf("unexpected identity %q for %q", identity, realRoot) + } + if got := ProjectIdentity(linkRoot); got != identity { + t.Fatalf("symlinked root identity %q differs from canonical %q", got, identity) + } + missingRoot := filepath.Join(realRoot, "missing") + if got := ProjectIdentity(missingRoot); got != expectedProjectIdentity(missingRoot) { + t.Fatalf("missing root should hash its raw path, got %q", got) + } +} + +// Computes the identity independently: "project_" plus the first 16 hex chars of SHA-256. +func expectedProjectIdentity(path string) string { + sum := sha256.Sum256([]byte(path)) + return "project_" + hex.EncodeToString(sum[:])[:16] +} + +func assertPathErrorOp(t *testing.T, err error, expectedOp string) { + t.Helper() + var pathErr *os.PathError + if !errors.As(err, &pathErr) || pathErr.Op != expectedOp { + t.Fatalf("expected a %s path error, got %v", expectedOp, err) + } +} From 82d5cf108e105aa4843ac1ebce3c0f19ae75dd7f Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:08:04 +0900 Subject: [PATCH 06/68] chore: Pause point paths in the project-runner CLI now have tests for Release recovery, await failures, and Unity IPC (#3080) --- .../pause_point_clear_file_line_test.go | 41 ++ .../projectrunner/pause_point_enable_test.go | 247 ++++++++++++ .../projectrunner/pause_point_ipc_test.go | 48 +++ .../projectrunner/pause_point_logs_test.go | 91 +++++ .../pause_point_release_recovery_test.go | 364 ++++++++++++++++++ .../pause_point_resume_play_test.go | 97 +++++ .../pause_point_status_list_test.go | 21 + 7 files changed, 909 insertions(+) create mode 100644 cli/project-runner/internal/projectrunner/pause_point_ipc_test.go create mode 100644 cli/project-runner/internal/projectrunner/pause_point_logs_test.go diff --git a/cli/project-runner/internal/projectrunner/pause_point_clear_file_line_test.go b/cli/project-runner/internal/projectrunner/pause_point_clear_file_line_test.go index ad26f1eb28..e917db4503 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_clear_file_line_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_clear_file_line_test.go @@ -101,3 +101,44 @@ func TestPrepareDynamicToolParamsRejectsClearPausePointCombinedIDAndFile(t *test t.Fatalf("stderr missing combination error: %s", stderr.String()) } } + +// Verifies clear-pause-point args without --file/--line pass through untouched with no composed id. +func TestExtractPausePointClearFileLineFlagsPassesThroughWithoutFileLine(t *testing.T) { + args := []string{"--all", "--other", "value"} + + remaining, queryID, err := extractPausePointClearFileLineFlags(pausePointClearCommandName, args) + + if err != nil || queryID != "" { + t.Fatalf("unexpected result: queryID=%q err=%v", queryID, err) + } + if strings.Join(remaining, " ") != strings.Join(args, " ") { + t.Fatalf("remaining = %#v, want %#v", remaining, args) + } +} + +// Verifies --file or --id with no value is rejected as a missing value. +func TestExtractPausePointClearFileLineFlagsRejectsMissingValues(t *testing.T) { + for args, wantMessage := range map[string]string{ + "--file": "--file requires a value", + "--file Assets/A.cs --line 3 --id": "--id requires a value", + } { + _, _, err := extractPausePointClearFileLineFlags(pausePointClearCommandName, strings.Fields(args)) + if message := requireArgumentError(t, err).Message; message != wantMessage { + t.Fatalf("args %q: Message = %q, want %q", args, message, wantMessage) + } + } +} + +// Verifies a composed file:line id never overwrites an explicit Id param. +func TestApplyPausePointClearFileLineIDRejectsExplicitID(t *testing.T) { + params := map[string]any{pausePointClearIdPropertyName: "named"} + + err := applyPausePointClearFileLineID(params, "Assets/A.cs:3") + + if argumentError := requireArgumentError(t, err); argumentError.Message != "--id cannot be combined with --file or --line." { + t.Fatalf("Message = %q", argumentError.Message) + } + if params[pausePointClearIdPropertyName] != "named" { + t.Fatalf("explicit Id must be kept: %#v", params) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_enable_test.go b/cli/project-runner/internal/projectrunner/pause_point_enable_test.go index f63229e27c..a3d77bb3b4 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_enable_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_enable_test.go @@ -5,12 +5,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net" + "os" + "path/filepath" "strings" "testing" "time" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -1249,3 +1254,245 @@ func readIPCRequest(t *testing.T, requests <-chan map[string]any) map[string]any return nil } } + +// Verifies invalid values for the CLI-only enable flags are rejected while parsing, before any request. +func TestExtractPausePointEnableAwaitFlagsRejectsInvalidValues(t *testing.T) { + cases := map[string]struct { + args []string + wantMessage string + }{ + "resume-play with non-true value": {args: []string{"--await", "--resume-play=yes"}, wantMessage: "Invalid boolean flag (pass with no value, or =true) value for --resume-play: yes"}, + "trigger without value": {args: []string{"--await", "--trigger"}, wantMessage: "--trigger requires a value"}, + "trigger with blank command": {args: []string{"--await", "--trigger", " "}, wantMessage: "--trigger requires a value"}, + "captured-variables unknown mode": {args: []string{"--await", "--captured-variables", "everything"}, wantMessage: "Invalid full or names value for --captured-variables: everything"}, + "expect without name": {args: []string{"--await", "--expect", "=5"}, wantMessage: "Invalid --expect value: =5"}, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + remaining, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags(testCase.args) + if argumentError := requireArgumentError(t, err); argumentError.Message != testCase.wantMessage { + t.Fatalf("Message = %q, want %q", argumentError.Message, testCase.wantMessage) + } + if remaining != nil { + t.Fatalf("remaining args must be nil on error: %#v", remaining) + } + }) + } +} + +// Verifies --captured-variable-names alone without --await is named as the offending option. +func TestExtractPausePointEnableAwaitFlagsNamesCapturedVariableNamesWithoutAwait(t *testing.T) { + _, _, _, _, _, _, _, _, err := extractPausePointEnableAwaitFlags([]string{"--captured-variable-names", "speed"}) + + if argumentError := requireArgumentError(t, err); argumentError.Option != "--captured-variable-names" { + t.Fatalf("Option = %q, want --captured-variable-names", argumentError.Option) + } +} + +// Verifies enable-pause-point argument and catalog failures on the --await path exit 1 without +// sending the enable request. +func TestRunEnablePausePointCommandRejectsBeforeSending(t *testing.T) { + cacheWithoutEnable := t.TempDir() + if err := os.MkdirAll(filepath.Join(cacheWithoutEnable, ".uloop"), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + writeTestFile(t, filepath.Join(cacheWithoutEnable, ".uloop", "tools.json"), `{"tools":[]}`) + otherProject := writeFakeUnityProject(t) + + cases := []struct { + name string + projectRoot string + args []string + wantStderr string + }{ + {name: "invalid CLI-only flag", projectRoot: t.TempDir(), args: []string{"--await", "--resume-play=no"}, wantStderr: "boolean flag (pass with no value, or =true)"}, + {name: "tool missing from project cache", projectRoot: cacheWithoutEnable, args: []string{"--await", "--id", "jump"}, wantStderr: `"ErrorCode": "UNKNOWN_COMMAND"`}, + {name: "unknown schema option", projectRoot: t.TempDir(), args: []string{"--await", "--bogus-flag"}, wantStderr: "Unknown option for enable-pause-point: --bogus-flag"}, + {name: "nested project path for another project", projectRoot: writeFakeUnityProject(t), args: []string{"--await", "--project-path", otherProject}, wantStderr: "--project-path must target the same Unity project"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := runEnablePausePointCommand( + context.Background(), + unreachableConnection(testCase.projectRoot), + testCase.args, + t.TempDir(), + &stdout, + &stderr, + ) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr must mention %q:\n%s", testCase.wantStderr, stderr.String()) + } + }) + } +} + +// stubEnablePausePointSends makes each enable send return the next scripted outcome or error. +func stubEnablePausePointSends(t *testing.T, results []string, errs []error) *int { + t.Helper() + original := sendEnablePausePointIPC + t.Cleanup(func() { sendEnablePausePointIPC = original }) + sends := 0 + sendEnablePausePointIPC = func(context.Context, unityipc.Connection, map[string]any, io.Writer) (unityipc.UnitySendOutcome, error) { + sends++ + if errs[sends-1] != nil { + return unityipc.UnitySendOutcome{}, errs[sends-1] + } + return unityipc.UnitySendOutcome{Result: json.RawMessage(results[sends-1])}, nil + } + return &sends +} + +// Verifies each failure on the --await enable path (send failure, failed Release recovery, failed +// resend after recovery) exits 1 without starting the wait. +func TestRunEnablePausePointAndAwaitStopsOnEnableFailures(t *testing.T) { + sendFailure := errors.New("enable send failed") + + t.Run("send failure", func(t *testing.T) { + sends := stubEnablePausePointSends(t, []string{""}, []error{sendFailure}) + code, stderr := runEnablePausePointAndAwaitForTest(t) + if code != 1 || *sends != 1 || !strings.Contains(stderr, "enable send failed") { + t.Fatalf("code=%d sends=%d stderr=%s", code, *sends, stderr) + } + }) + t.Run("Release recovery fails", func(t *testing.T) { + sends := stubEnablePausePointSends(t, []string{releaseCodeOptimizationEnableFailureJSON}, []error{nil}) + originalSwitch := sendSetCodeOptimizationDebug + t.Cleanup(func() { sendSetCodeOptimizationDebug = originalSwitch }) + sendSetCodeOptimizationDebug = func(context.Context, unityipc.Connection) error { + return errors.New("switch refused") + } + code, stderr := runEnablePausePointAndAwaitForTest(t) + if code != 1 || *sends != 1 || !strings.Contains(stderr, "switch refused") { + t.Fatalf("code=%d sends=%d stderr=%s", code, *sends, stderr) + } + }) + t.Run("resend after recovery fails", func(t *testing.T) { + stubPausePointRecoverySwitchAndCompile(t) + sends := stubEnablePausePointSends(t, []string{releaseCodeOptimizationEnableFailureJSON, ""}, []error{nil, sendFailure}) + code, stderr := runEnablePausePointAndAwaitForTest(t) + if code != 1 || *sends != 2 || !strings.Contains(stderr, "enable send failed") { + t.Fatalf("code=%d sends=%d stderr=%s", code, *sends, stderr) + } + }) +} + +func runEnablePausePointAndAwaitForTest(t *testing.T) (int, string) { + t.Helper() + original := queryPausePointStatus + t.Cleanup(func() { queryPausePointStatus = original }) + queryPausePointStatus = func(context.Context, unityipc.Connection, string) (pausePointStatusResponse, error) { + t.Fatal("the wait must not start after a failed enable") + return pausePointStatusResponse{}, nil + } + var stdout, stderr bytes.Buffer + code := runEnablePausePointAndAwait( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + map[string]any{"Id": "jump"}, + pausePointCapturedVariablesModeFull, + nil, nil, "", nil, false, + t.TempDir(), + &stdout, + &stderr, + ) + if stdout.Len() != 0 { + t.Fatalf("stdout must stay empty: %s", stdout.String()) + } + return code, stderr.String() +} + +// stubPausePointWaitAlwaysEnabled keeps the marker armed and unhit for every status poll, with +// a 1ms poll so a short wait times out quickly. +func stubPausePointWaitAlwaysEnabled(t *testing.T) { + t.Helper() + originalQuery := queryPausePointStatus + originalPoll := pausePointStatusPoll + originalClear := clearPausePointStatus + originalResume := resumePlayModeForPausePoint + originalLogs := fetchMatchingLogs + t.Cleanup(func() { + queryPausePointStatus = originalQuery + pausePointStatusPoll = originalPoll + clearPausePointStatus = originalClear + resumePlayModeForPausePoint = originalResume + fetchMatchingLogs = originalLogs + }) + pausePointStatusPoll = time.Millisecond + queryPausePointStatus = func(_ context.Context, _ unityipc.Connection, id string) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Success: true, Id: id, Status: pausePointStatusEnabled, IsEnabled: true, Mode: "continuous"}, nil + } + clearPausePointStatus = func(_ context.Context, _ unityipc.Connection, id string) (pausePointStatusResponse, error) { + return pausePointStatusResponse{Success: true, Id: id, Status: pausePointStatusCleared}, nil + } + resumePlayModeForPausePoint = func(context.Context, unityipc.Connection) pausePointResumePlayResult { + return pausePointResumePlayResult{WasPaused: true, Resumed: true} + } +} + +// Verifies an --await timeout after --resume-play reports the resume result and the matching +// logs with their single-fire warning in the error details. +func TestRunPausePointWaitAfterEnableTimeoutReportsResumeAndMatchingLogs(t *testing.T) { + stubPausePointWaitAlwaysEnabled(t) + fetchMatchingLogs = func(context.Context, unityipc.Connection, string, int) (pausePointMatchingLogsResult, error) { + return pausePointMatchingLogsResult{TotalCount: 2, Logs: []pausePointMatchingLog{{Message: "a"}, {Message: "b"}}}, nil + } + var stdout, stderr bytes.Buffer + + code := runPausePointWaitAfterEnable( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + waitForPausePointOptions{id: "jump", timeoutSeconds: 1, timeout: 20 * time.Millisecond, resumePlay: true, markerJustEnabled: true}, + enablePausePointPropagatedFields{}, + &stdout, + &stderr, + ) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%s", code, stdout.String(), stderr.String()) + } + envelope := clierrors.CLIErrorEnvelope{} + if err := json.Unmarshal(stderr.Bytes(), &envelope); err != nil { + t.Fatalf("stderr is not an error envelope: %v\n%s", err, stderr.String()) + } + details := envelope.Error.Details + if resume, _ := details["ResumePlayResult"].(map[string]any); resume["Resumed"] != true { + t.Fatalf("ResumePlayResult missing: %#v", details) + } + if logs, _ := details["MatchingLogs"].([]any); len(logs) != 2 { + t.Fatalf("MatchingLogs missing: %#v", details) + } + if warning, _ := details["Warning"].(string); !strings.Contains(warning, "Multiple matching logs") { + t.Fatalf("Warning missing: %#v", details) + } +} + +// Verifies a wait that ends with an error (here a cancelled context) is reported on stderr with exit 1. +func TestRunPausePointWaitAfterEnableReportsWaitError(t *testing.T) { + stubPausePointWaitAlwaysEnabled(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var stdout, stderr bytes.Buffer + + code := runPausePointWaitAfterEnable( + ctx, + unityipc.Connection{ProjectRoot: t.TempDir()}, + waitForPausePointOptions{id: "jump", timeoutSeconds: 1, timeout: time.Second, markerJustEnabled: true}, + enablePausePointPropagatedFields{}, + &stdout, + &stderr, + ) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q", code, stdout.String()) + } + if !strings.Contains(stderr.String(), "canceled") { + t.Fatalf("stderr must report the cancellation:\n%s", stderr.String()) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_ipc_test.go b/cli/project-runner/internal/projectrunner/pause_point_ipc_test.go new file mode 100644 index 0000000000..07bd4c0ece --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_ipc_test.go @@ -0,0 +1,48 @@ +package projectrunner + +import ( + "context" + "strings" + "testing" +) + +// Verifies the Debug switch posts set-code-optimization-debug to Unity and reports Unity's refusal. +func TestSendSetCodeOptimizationDebugFromUnity(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), setCodeOptimizationDebugCommandName, `{"Success":true}`) + if err := sendSetCodeOptimizationDebugFromUnity(context.Background(), server.connection); err != nil { + t.Fatalf("switch failed: %v", err) + } + if request := server.receivedRequest(t); len(request) != 0 { + t.Fatalf("switch must send no params: %#v", request) + } + }) + t.Run("Unity error", func(t *testing.T) { + server := startFakeUnityServer(t, t.TempDir(), setCodeOptimizationDebugCommandName, testUnityRPCFailureResponse) + if err := sendSetCodeOptimizationDebugFromUnity(context.Background(), server.connection); err == nil || !strings.Contains(err.Error(), "tool exploded in Unity") { + t.Fatalf("expected the Unity error, got %v", err) + } + }) +} + +// Verifies status queries surface Unity errors and undecodable results instead of an empty status. +func TestPausePointStatusQueriesReportFailures(t *testing.T) { + t.Run("status undecodable", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointStatusCommandName, `[1]`) + if _, err := queryPausePointStatusFromUnity(context.Background(), server.connection, "jump"); err == nil || !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected a decode error, got %v", err) + } + }) + t.Run("list Unity error", func(t *testing.T) { + server := startFakeUnityServer(t, t.TempDir(), pausePointStatusCommandName, testUnityRPCFailureResponse) + if _, err := queryPausePointStatusListFromUnity(context.Background(), server.connection); err == nil || !strings.Contains(err.Error(), "tool exploded in Unity") { + t.Fatalf("expected the Unity error, got %v", err) + } + }) + t.Run("list undecodable", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointStatusCommandName, `"text"`) + if _, err := queryPausePointStatusListFromUnity(context.Background(), server.connection); err == nil || !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected a decode error, got %v", err) + } + }) +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_logs_test.go b/cli/project-runner/internal/projectrunner/pause_point_logs_test.go new file mode 100644 index 0000000000..ab30406d67 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_logs_test.go @@ -0,0 +1,91 @@ +package projectrunner + +import ( + "context" + "reflect" + "strings" + "testing" +) + +// Verifies the matching-log query asks Unity for the marker text and fills counts Unity left at +// zero from the logs it actually returned. +func TestFetchMatchingLogsFromUnityFillsMissingCounts(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointGetLogsCommandName, + `{"Success":true,"Logs":[{"Type":"Log","Message":"hit a"},{"Type":"Log","Message":"hit b"}]}`) + + result, err := fetchMatchingLogsFromUnity(context.Background(), server.connection, "marker-1", 20) + if err != nil { + t.Fatalf("fetch failed: %v", err) + } + request := server.receivedRequest(t) + if request["SearchText"] != "marker-1" || request["MaxCount"] != float64(20) { + t.Fatalf("unexpected request: %#v", request) + } + want := pausePointMatchingLogsResult{ + SearchText: "marker-1", + TotalCount: 2, + DisplayedCount: 2, + MaxCount: 20, + Logs: []pausePointMatchingLog{ + {Type: "Log", Message: "hit a"}, + {Type: "Log", Message: "hit b"}, + }, + } + if !reflect.DeepEqual(result, want) { + t.Fatalf("result mismatch:\nwant: %#v\ngot: %#v", want, result) + } +} + +// Verifies counts and search text Unity did report are kept, and a missing log list becomes empty. +func TestFetchMatchingLogsFromUnityKeepsReportedValues(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointGetLogsCommandName, + `{"Success":true,"SearchText":"echoed","TotalCount":9,"DisplayedCount":0,"MaxCount":5,"LogType":"All","IncludeStackTrace":true}`) + + result, err := fetchMatchingLogsFromUnity(context.Background(), server.connection, "marker-2", 20) + if err != nil { + t.Fatalf("fetch failed: %v", err) + } + server.receivedRequest(t) + want := pausePointMatchingLogsResult{ + SearchText: "echoed", + TotalCount: 9, + MaxCount: 5, + LogType: "All", + IncludeStackTrace: true, + Logs: []pausePointMatchingLog{}, + } + if !reflect.DeepEqual(result, want) { + t.Fatalf("result mismatch:\nwant: %#v\ngot: %#v", want, result) + } +} + +// Verifies Unity errors and undecodable results are returned as errors. +func TestFetchMatchingLogsFromUnityReportsFailures(t *testing.T) { + t.Run("Unity error", func(t *testing.T) { + server := startFakeUnityServer(t, t.TempDir(), pausePointGetLogsCommandName, testUnityRPCFailureResponse) + if _, err := fetchMatchingLogsFromUnity(context.Background(), server.connection, "m", 1); err == nil || !strings.Contains(err.Error(), "tool exploded in Unity") { + t.Fatalf("expected the Unity error, got %v", err) + } + }) + t.Run("undecodable result", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointGetLogsCommandName, `[1]`) + if _, err := fetchMatchingLogsFromUnity(context.Background(), server.connection, "m", 1); err == nil || !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected a decode error, got %v", err) + } + }) +} + +// Verifies each independent doubt about single-fire evidence becomes its own warning entry. +func TestBuildPausePointLogWarningsListsEachDoubt(t *testing.T) { + logs := pausePointMatchingLogsResult{TotalCount: 1, Logs: []pausePointMatchingLog{{Message: "a"}, {Message: "b"}}} + + warnings := buildPausePointLogWarnings(logs, 2) + + want := []string{ + "Multiple matching logs were observed for this pause point id; inspect MatchingLogs before treating the scenario as single-fire evidence.", + "The pause point reports multiple hits; inspect the paused state before treating the scenario as single-fire evidence.", + } + if !reflect.DeepEqual(warnings, want) { + t.Fatalf("warnings mismatch:\nwant: %#v\ngot: %#v", want, warnings) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go index ada1862927..96cb6a1f17 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_release_recovery_test.go @@ -702,3 +702,367 @@ func assertPausePointRecoveryWarningsAgree(t *testing.T, payload map[string]any, t.Fatalf("Warning must be the joined form of Warnings: %q vs %#v", warning, entries) } } + +// Verifies the success probe treats enable output that fails to decode as a failure even when +// Success was already decoded as true, and nil or failed responses never gain the switch warning. +func TestPausePointRecoveryProbesIgnoreUnusableResponses(t *testing.T) { + // Why this input: the decoder assigns Success=true before it hits the mistyped ErrorCode, so + // only the decode-error branch keeps the probe from reporting success. + if isSuccessfulEnableResponse([]byte(`{"Success":true,"ErrorCode":5}`)) { + t.Fatal("output that fails to decode must not count as a successful enable") + } + + applyPausePointRecoverySwitchWarning(nil) + appendPausePointWarningToBothForms(nil, "ignored") + failed := pausePointStatusResponse{Success: false} + applyPausePointRecoverySwitchWarning(&failed) + if failed.Warning != "" || len(failed.Warnings) != 0 { + t.Fatalf("failed response must not gain warnings: %#v", failed) + } +} + +// Verifies a response carrying only the joined Warning string keeps that text as its own entry +// ahead of the switch note. +func TestInjectPausePointRecoveryWarningKeepsWarningOnlyResponse(t *testing.T) { + rewritten, err := injectPausePointRecoveryWarning([]byte(`{"Success":true,"Warning":"physics dispatch warning.","Extra":7}`)) + if err != nil { + t.Fatalf("inject failed: %v", err) + } + payload := decodePausePointRecoveryPayload(t, string(rewritten)) + assertPausePointRecoveryWarningsAgree(t, payload, 2) + if warnings, _ := payload["Warnings"].([]any); warnings[0] != "physics dispatch warning." { + t.Fatalf("existing warning must stay first: %#v", payload["Warnings"]) + } + if payload["Extra"] != float64(7) { + t.Fatalf("unrelated keys must survive: %#v", payload) + } +} + +// Verifies malformed enable responses are rejected instead of being rewritten with a guessed shape. +func TestInjectPausePointRecoveryWarningRejectsMalformedFields(t *testing.T) { + cases := []struct { + name string + raw string + wantErr string + }{ + {name: "not an object", raw: `[1,2]`, wantErr: "cannot unmarshal array into Go value of type map"}, + {name: "warnings not a list", raw: `{"Success":true,"Warnings":"one"}`, wantErr: "cannot unmarshal string into Go value of type []string"}, + {name: "warning not a string", raw: `{"Success":true,"Warning":5}`, wantErr: "cannot unmarshal number into Go value of type string"}, + {name: "message not a string", raw: `{"Success":true,"Message":["a"]}`, wantErr: "cannot unmarshal array into Go value of type string"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + rewritten, err := injectPausePointRecoveryWarning([]byte(testCase.raw)) + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got rewritten=%s err=%v", testCase.wantErr, rewritten, err) + } + }) + } +} + +// Verifies the retry wait returns nil once the duration elapses and the context error when cancelled. +func TestWaitContextDuration(t *testing.T) { + if err := waitContextDuration(context.Background(), time.Millisecond); err != nil { + t.Fatalf("elapsed wait must succeed: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitContextDuration(ctx, time.Hour); err != context.Canceled { + t.Fatalf("cancelled wait must return context.Canceled, got %v", err) + } +} + +const serverBusyRPCErrorJSON = `{"code":-32603,"message":"busy","data":{"type":"server_busy"}}` + +func serverBusyRPCError(t *testing.T) error { + t.Helper() + rpcErr := &unityipc.RPCError{} + if err := json.Unmarshal([]byte(serverBusyRPCErrorJSON), rpcErr); err != nil { + t.Fatalf("failed to build busy error: %v", err) + } + if !isUnityServerBusyRPCError(rpcErr) { + t.Fatal("fixture must be recognized as server_busy") + } + return rpcErr +} + +// stubFreshCompileSends replaces the compile sender with one that returns the given errors in +// order, then succeeds, and records the retry waits requested in between. +func stubFreshCompileSends(t *testing.T, errs []error, waitErr error) (*int, *[]time.Duration) { + t.Helper() + originalSend := sendFreshCompileRequest + originalWait := waitPausePointRecoveryBusyRetry + t.Cleanup(func() { + sendFreshCompileRequest = originalSend + waitPausePointRecoveryBusyRetry = originalWait + }) + sends := 0 + waits := []time.Duration{} + sendFreshCompileRequest = func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { + sends++ + if sends <= len(errs) { + return unityipc.UnitySendOutcome{}, errs[sends-1] + } + return unityipc.UnitySendOutcome{RequestDispatched: true, Result: json.RawMessage(`{"Success":true}`)}, nil + } + waitPausePointRecoveryBusyRetry = func(_ context.Context, duration time.Duration) error { + waits = append(waits, duration) + return waitErr + } + return &sends, &waits +} + +// Verifies busy compile sends are retried within the budget, with each wait capped by the time left. +func TestSendCompileWithBusyRetryRetriesBusyUntilSuccess(t *testing.T) { + sends, waits := stubFreshCompileSends(t, []error{serverBusyRPCError(t)}, nil) + + outcome, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, time.Second) + + if err != nil || string(outcome.Result) != `{"Success":true}` { + t.Fatalf("unexpected result: outcome=%#v err=%v", outcome, err) + } + if *sends != 2 { + t.Fatalf("sends = %d, want 2", *sends) + } + if len(*waits) != 1 || (*waits)[0] > time.Second || (*waits)[0] <= 0 { + t.Fatalf("retry wait must be capped by the remaining budget: %v", *waits) + } +} + +// Verifies a busy send with no budget left, a non-busy error, and a cancelled retry wait all stop retrying. +func TestSendCompileWithBusyRetryStopsRetrying(t *testing.T) { + t.Run("budget exhausted", func(t *testing.T) { + busy := serverBusyRPCError(t) + sends, waits := stubFreshCompileSends(t, []error{busy, busy}, nil) + _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, 0) + if err != busy || *sends != 1 || len(*waits) != 0 { + t.Fatalf("err=%v sends=%d waits=%v", err, *sends, *waits) + } + }) + t.Run("non-busy error", func(t *testing.T) { + failure := io.ErrUnexpectedEOF + sends, _ := stubFreshCompileSends(t, []error{failure}, nil) + _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, time.Minute) + if err != failure || *sends != 1 { + t.Fatalf("err=%v sends=%d", err, *sends) + } + }) + t.Run("retry wait cancelled", func(t *testing.T) { + sends, _ := stubFreshCompileSends(t, []error{serverBusyRPCError(t)}, context.Canceled) + _, err := sendCompileWithBusyRetry(context.Background(), unityipc.Connection{}, "compile", map[string]any{}, nil, 0, time.Minute) + if err != context.Canceled || *sends != 1 { + t.Fatalf("err=%v sends=%d", err, *sends) + } + }) +} + +// Verifies the default recovery compile sends through the busy-retry sender with its budget: a +// busy answer is retried after one wait capped by that budget, and the following undispatched +// send failure is reported without waiting on compile status. +func TestRunOneFreshCompileForPausePointRecoveryUsesBusyRetrySender(t *testing.T) { + sends, waits := stubFreshCompileSends(t, []error{serverBusyRPCError(t), io.ErrUnexpectedEOF}, nil) + var stdout, stderr bytes.Buffer + budget := 500 * time.Millisecond + + code := runOneFreshCompileForPausePointRecoveryDefault( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + map[string]any{}, + &stdout, + &stderr, + budget, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if *sends != 2 { + t.Fatalf("sends = %d, want 2", *sends) + } + if len(*waits) != 1 || (*waits)[0] > budget { + t.Fatalf("waits = %v, want one wait no longer than %v", *waits, budget) + } + if !strings.Contains(stderr.String(), "unexpected EOF") { + t.Fatalf("stderr must report the send failure:\n%s", stderr.String()) + } +} + +// Verifies the recovery compile loop rejects an invalid timeout before compiling. +func TestRunFreshCompileWithBusyRetryRejectsInvalidTimeout(t *testing.T) { + originalAttempt := runOneFreshCompileForPausePointRecovery + t.Cleanup(func() { runOneFreshCompileForPausePointRecovery = originalAttempt }) + runOneFreshCompileForPausePointRecovery = func(context.Context, unityipc.Connection, map[string]any, io.Writer, io.Writer, time.Duration) int { + t.Fatal("compile must not run") + return 0 + } + var stdout, stderr bytes.Buffer + + code := runFreshCompileWithBusyRetryForPausePointRecovery( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + map[string]any{compileWaitTimeoutParam: 0}, + &stdout, + &stderr, + ) + + if code != 1 || !strings.Contains(stderr.String(), "Invalid positive integer value for --timeout-seconds") { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } +} + +// stubRecoveryCompileAttempts makes each recovery compile attempt write the next result and +// return its code, and records retry waits. +func stubRecoveryCompileAttempts(t *testing.T, results []string, codes []int) (*int, *[]time.Duration) { + t.Helper() + originalAttempt := runOneFreshCompileForPausePointRecovery + originalWait := waitPausePointRecoveryBusyRetry + t.Cleanup(func() { + runOneFreshCompileForPausePointRecovery = originalAttempt + waitPausePointRecoveryBusyRetry = originalWait + }) + attempts := 0 + waits := []time.Duration{} + runOneFreshCompileForPausePointRecovery = func(_ context.Context, _ unityipc.Connection, _ map[string]any, stdout io.Writer, _ io.Writer, _ time.Duration) int { + attempts++ + _, _ = stdout.Write([]byte(results[attempts-1])) + return codes[attempts-1] + } + waitPausePointRecoveryBusyRetry = func(_ context.Context, duration time.Duration) error { + waits = append(waits, duration) + return nil + } + return &attempts, &waits +} + +// Verifies a compile result meaning Unity is still updating is retried, with the wait capped by +// the time left, and a later success returns 0 without writing the busy result. +func TestRunFreshCompileWithBusyRetryRetriesEditorUpdating(t *testing.T) { + attempts, waits := stubRecoveryCompileAttempts(t, + []string{`{"Success":false,"ErrorCode":"COMPILE_EDITOR_UPDATING"}`, `{"Success":true}`}, + []int{1, 0}) + var stdout bytes.Buffer + + code := runFreshCompileWithBusyRetryForPausePointRecovery( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + map[string]any{compileWaitTimeoutParam: 1}, + &stdout, + io.Discard, + ) + + if code != 0 || *attempts != 2 { + t.Fatalf("code=%d attempts=%d", code, *attempts) + } + if len(*waits) != 1 || (*waits)[0] > time.Second { + t.Fatalf("retry wait must be capped by the 1s budget: %v", *waits) + } + if stdout.Len() != 0 { + t.Fatalf("busy result must not be written: %s", stdout.String()) + } +} + +// Verifies a non-retryable compile failure is written to stdout once and returned without retrying. +func TestRunFreshCompileWithBusyRetryReturnsNonRetryableFailure(t *testing.T) { + failure := `{"Success":false,"ErrorCount":3}` + attempts, waits := stubRecoveryCompileAttempts(t, []string{failure}, []int{1}) + var stdout bytes.Buffer + + code := runFreshCompileWithBusyRetryForPausePointRecovery( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + map[string]any{}, + &stdout, + io.Discard, + ) + + if code != 1 || *attempts != 1 || len(*waits) != 0 { + t.Fatalf("code=%d attempts=%d waits=%v", code, *attempts, *waits) + } + if stdout.String() != failure { + t.Fatalf("stdout = %q, want %q", stdout.String(), failure) + } +} + +// Verifies a failed Debug switch stops recovery before any compile runs. +func TestRecoverReleaseCodeOptimizationStopsWhenSwitchFails(t *testing.T) { + originalSwitch := sendSetCodeOptimizationDebug + originalCompile := runFreshCompileForPausePointRecovery + t.Cleanup(func() { + sendSetCodeOptimizationDebug = originalSwitch + runFreshCompileForPausePointRecovery = originalCompile + }) + sendSetCodeOptimizationDebug = func(context.Context, unityipc.Connection) error { + return io.ErrUnexpectedEOF + } + runFreshCompileForPausePointRecovery = func(context.Context, unityipc.Connection, map[string]any, io.Writer, io.Writer) int { + t.Fatal("compile must not run after a failed switch") + return 0 + } + var stdout, stderr bytes.Buffer + + code := recoverReleaseCodeOptimization(context.Background(), unityipc.Connection{ProjectRoot: t.TempDir()}, &stdout, &stderr) + + if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "unexpected EOF") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +// Verifies a successful resend whose warnings cannot be rewritten fails instead of printing a +// response that silently drops the switch note. +func TestCompleteEnableWithReleaseRecoveryFailsWhenRewriteFails(t *testing.T) { + stubPausePointRecoverySwitchAndCompile(t) + sendCount := 0 + var stdout, stderr bytes.Buffer + + code := completeEnableWithReleaseRecovery( + context.Background(), + unityipc.Connection{ProjectRoot: t.TempDir()}, + &stdout, + &stderr, + func(writer io.Writer) int { + sendCount++ + if sendCount == 1 { + _, _ = writer.Write([]byte(releaseCodeOptimizationEnableFailureJSON)) + return 1 + } + _, _ = writer.Write([]byte(`{"Success":true,"Warnings":"not-a-list"}`)) + return 0 + }, + ) + + if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "cannot unmarshal string into Go value of type []string") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +// Verifies enable send failures and undecodable enable results are reported and returned as errors. +func TestSendEnablePausePointAndDecodeReportsFailures(t *testing.T) { + original := sendEnablePausePointIPC + t.Cleanup(func() { sendEnablePausePointIPC = original }) + + cases := map[string]struct { + outcome unityipc.UnitySendOutcome + err error + wantStderr string + }{ + "send failure": {err: io.ErrUnexpectedEOF, wantStderr: "unexpected EOF"}, + "undecodable result": {outcome: unityipc.UnitySendOutcome{Result: json.RawMessage(`"text"`)}, wantStderr: "cannot unmarshal string"}, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + sendEnablePausePointIPC = func(context.Context, unityipc.Connection, map[string]any, io.Writer) (unityipc.UnitySendOutcome, error) { + return testCase.outcome, testCase.err + } + var stderr bytes.Buffer + + raw, _, _, err := sendEnablePausePointAndDecode(context.Background(), unityipc.Connection{ProjectRoot: t.TempDir()}, map[string]any{}, &stderr) + + if err == nil || raw != nil { + t.Fatalf("expected an error and no raw result, got raw=%s err=%v", raw, err) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr must contain %q:\n%s", testCase.wantStderr, stderr.String()) + } + }) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go b/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go index 9498904d76..b7bcf9413d 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_resume_play_test.go @@ -986,3 +986,100 @@ func TestRunWaitForPausePointCommandReportsResumeSkipForAlreadyHitMarker(t *test t.Fatalf("Skipped mismatch: %#v", resumeResult) } } + +// Verifies the remaining resume outcomes: default messages for Success=false without text, a +// refused Play, and a completed resume. +func TestResumePlayModeForPausePointFromUnityRemainingBranches(t *testing.T) { + originalSend := sendControlPlayModeForPausePoint + t.Cleanup(func() { sendControlPlayModeForPausePoint = originalSend }) + + t.Run("Status Success=false without message", func(t *testing.T) { + assertResumePlayModeFromUnityBranch(t, stubControlPlayModeFixed(controlPlayModeToolResponse{}, nil), + pausePointResumePlayResult{Error: "control-play-mode Status returned Success=false"}, []string{"Status"}) + }) + t.Run("Play Success=false with message", func(t *testing.T) { + assertResumePlayModeFromUnityBranch(t, stubControlPlayModePlayResponse(controlPlayModeToolResponse{Message: "play denied"}), + pausePointResumePlayResult{WasPaused: true, Error: "play denied"}, []string{"Status", "Play"}) + }) + t.Run("Play Success=false without message", func(t *testing.T) { + assertResumePlayModeFromUnityBranch(t, stubControlPlayModePlayResponse(controlPlayModeToolResponse{}), + pausePointResumePlayResult{WasPaused: true, Error: "control-play-mode Play returned Success=false"}, []string{"Status", "Play"}) + }) + t.Run("Play succeeds", func(t *testing.T) { + assertResumePlayModeFromUnityBranch(t, stubControlPlayModePlayResponse(controlPlayModeToolResponse{Success: true}), + pausePointResumePlayResult{WasPaused: true, Resumed: true}, []string{"Status", "Play"}) + }) +} + +func stubControlPlayModePlayResponse( + playResponse controlPlayModeToolResponse, +) func(context.Context, unityipc.Connection, string) (controlPlayModeToolResponse, error) { + return func(_ context.Context, _ unityipc.Connection, action string) (controlPlayModeToolResponse, error) { + if action == "Status" { + return controlPlayModeToolResponse{Success: true, IsPaused: true}, nil + } + return playResponse, nil + } +} + +// Verifies a failed re-pause is recorded on the resume result instead of claiming PlayMode was paused again. +func TestRepausePlayModeAfterAbandonedWaitRecordsFailures(t *testing.T) { + originalSend := sendControlPlayModeForPausePoint + t.Cleanup(func() { sendControlPlayModeForPausePoint = originalSend }) + resumed := pausePointResumePlayResult{WasPaused: true, Resumed: true} + + cases := []struct { + name string + stub func(context.Context, unityipc.Connection, string) (controlPlayModeToolResponse, error) + want string + }{ + {name: "transport failure", stub: stubControlPlayModeStatusError("pause boom"), want: "control-play-mode Pause failed: pause boom"}, + {name: "Success=false with message", stub: stubControlPlayModeFixed(controlPlayModeToolResponse{Message: "pause denied"}, nil), want: "pause denied"}, + {name: "Success=false without message", stub: stubControlPlayModeFixed(controlPlayModeToolResponse{}, nil), want: "control-play-mode Pause returned Success=false"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + sendControlPlayModeForPausePoint = testCase.stub + + result := repausePlayModeAfterAbandonedWait(context.Background(), unityipc.Connection{}, resumed) + + if result.Repaused || result.RepauseError != testCase.want { + t.Fatalf("unexpected result: %#v", result) + } + if !result.WasPaused || !result.Resumed { + t.Fatalf("resume fields must be preserved: %#v", result) + } + }) + } +} + +// Verifies the control-play-mode sender posts the action to Unity and decodes the response, +// and surfaces transport and decode failures. +func TestSendControlPlayModeForPausePointFromUnity(t *testing.T) { + t.Run("decodes response", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointResumePlayCommandName, `{"Success":true,"IsPaused":true,"Message":"status"}`) + + response, err := sendControlPlayModeForPausePointFromUnity(context.Background(), server.connection, "Status") + if err != nil { + t.Fatalf("send failed: %v", err) + } + if request := server.receivedRequest(t); request["Action"] != "Status" { + t.Fatalf("unexpected request: %#v", request) + } + if !response.Success || !response.IsPaused || response.Message != "status" { + t.Fatalf("unexpected response: %#v", response) + } + }) + t.Run("Unity error", func(t *testing.T) { + server := startFakeUnityServer(t, t.TempDir(), pausePointResumePlayCommandName, testUnityRPCFailureResponse) + if _, err := sendControlPlayModeForPausePointFromUnity(context.Background(), server.connection, "Play"); err == nil || !strings.Contains(err.Error(), "tool exploded in Unity") { + t.Fatalf("expected the Unity error, got %v", err) + } + }) + t.Run("undecodable result", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), pausePointResumePlayCommandName, `"text"`) + if _, err := sendControlPlayModeForPausePointFromUnity(context.Background(), server.connection, "Play"); err == nil || !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected a decode error, got %v", err) + } + }) +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_status_list_test.go b/cli/project-runner/internal/projectrunner/pause_point_status_list_test.go index 1008209995..ff9f9ded97 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_status_list_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_status_list_test.go @@ -3,6 +3,8 @@ package projectrunner import ( "bytes" "context" + "errors" + "strings" "testing" "github.com/hatayama/unity-cli-loop/common/unityipc" @@ -170,3 +172,22 @@ func TestRunPausePointStatusCommandWithoutTargetWritesListResponse(t *testing.T) t.Fatalf("stdout = %s, want %s", stdout.String(), want) } } + +// Verifies a failed list query exits 1 with the error on stderr and nothing on stdout. +func TestRunPausePointStatusListCommandReportsQueryFailure(t *testing.T) { + original := queryPausePointStatusList + t.Cleanup(func() { queryPausePointStatusList = original }) + queryPausePointStatusList = func(context.Context, unityipc.Connection) (pausePointStatusListResponse, error) { + return pausePointStatusListResponse{}, errors.New("list query failed") + } + var stdout, stderr bytes.Buffer + + code := runPausePointStatusListCommand(context.Background(), unityipc.Connection{ProjectRoot: t.TempDir()}, &stdout, &stderr) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q", code, stdout.String()) + } + if !strings.Contains(stderr.String(), "list query failed") { + t.Fatalf("stderr must carry the query error:\n%s", stderr.String()) + } +} From 7fb8538643c59933262c4ea8fbd376730b095741 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:11:45 +0900 Subject: [PATCH 07/68] chore: Package manager and release scheduling commands in the release-automation CLI now have tests for failure handling (#3078) --- .../cancel_superseded_waiting_runs_test.go | 58 +++++++ .../homebrew_formula_update_test.go | 148 +++++++++++++++++ .../notify_pending_release_approvals_test.go | 125 ++++++++++++++ .../automation/unity_release_resolver_test.go | 116 +++++++++++++ .../automation/winget_manifest_github_test.go | 32 ++++ .../automation/winget_manifest_update_test.go | 153 ++++++++++++++++++ 6 files changed, 632 insertions(+) create mode 100644 cli/release-automation/internal/automation/winget_manifest_github_test.go diff --git a/cli/release-automation/internal/automation/cancel_superseded_waiting_runs_test.go b/cli/release-automation/internal/automation/cancel_superseded_waiting_runs_test.go index facaac7899..e53ceb37c5 100644 --- a/cli/release-automation/internal/automation/cancel_superseded_waiting_runs_test.go +++ b/cli/release-automation/internal/automation/cancel_superseded_waiting_runs_test.go @@ -185,3 +185,61 @@ func assertCancelSupersededWaitingRunsLogContainsLine(t *testing.T, actual strin } t.Fatalf("expected log to contain line %q, got:\n%s", expected, actual) } + +// Verifies an unknown flag fails the exported command with exit code 1 before any gh call. +func TestRunCancelSupersededWaitingRunsRejectsUnknownFlags(t *testing.T) { + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunCancelSupersededWaitingRuns(context.Background(), &stdout, &stderr, []string{"--unknown"}) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertCancelSupersededWaitingRunsLogContains(t, stderr.String(), "cancel-superseded-waiting-runs: flag provided but not defined") +} + +// Verifies complete flags produce a config carrying every value, including the numeric run id. +func TestParseCancelSupersededWaitingRunsFlagsReturnsCompleteConfig(t *testing.T) { + config, err := parseCancelSupersededWaitingRunsFlags([]string{ + "--repo", "owner/repository", "--workflow", "publish.yml", "--branch", "main", "--current-run-id", "300", + }) + if err != nil { + t.Fatalf("expected flag parsing to succeed, got %v", err) + } + want := cancelSupersededWaitingRunsConfig{repository: "owner/repository", workflow: "publish.yml", branch: "main", currentRunID: 300} + if config != want { + t.Fatalf("config = %+v, want %+v", config, want) + } +} + +// Verifies an unparsable run list fails the command instead of cancelling nothing silently. +func TestCancelSupersededWaitingRunsFailsWhenRunListIsInvalidJSON(t *testing.T) { + deps := cancelSupersededWaitingRunsDeps{ + runOutput: func(context.Context, string, ...string) (string, error) { return "{", nil }, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := runCancelSupersededWaitingRunsWithDeps(context.Background(), &stdout, &stderr, cancelSupersededWaitingRunsConfig{repository: "owner/repository", currentRunID: 1}, deps) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertCancelSupersededWaitingRunsLogContains(t, stderr.String(), "failed to parse waiting workflow runs") +} + +// Verifies the command runner returns stdout on success and names the command on failure. +func TestRunCancelSupersededWaitingRunsCommandOutputCapturesOutputAndFailures(t *testing.T) { + output, err := runCancelSupersededWaitingRunsCommandOutput(context.Background(), "git", "--version") + if err != nil { + t.Fatalf("git --version failed: %v", err) + } + assertCancelSupersededWaitingRunsLogContains(t, output, "git version") + + _, err = runCancelSupersededWaitingRunsCommandOutput(context.Background(), "git", "--no-such-option") + if err == nil { + t.Fatal("expected an unknown git option to fail") + } + assertCancelSupersededWaitingRunsLogContains(t, err.Error(), "git --no-such-option failed") +} diff --git a/cli/release-automation/internal/automation/homebrew_formula_update_test.go b/cli/release-automation/internal/automation/homebrew_formula_update_test.go index 8f7e91bc3a..b485e0f499 100644 --- a/cli/release-automation/internal/automation/homebrew_formula_update_test.go +++ b/cli/release-automation/internal/automation/homebrew_formula_update_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "os" "strings" "testing" ) @@ -404,3 +405,150 @@ func containsFlagValue(args []string, name string, value string) bool { } return false } + +// TestParseHomebrewFormulaUpdateFlagsRejectsIncompleteFlags verifies unknown flags and each missing required flag are rejected by their own message. +func TestParseHomebrewFormulaUpdateFlagsRejectsIncompleteFlags(t *testing.T) { + cases := []struct { + name string + args []string + wantErr string + }{ + {"unknown flag", []string{"--unknown"}, "flag provided but not defined"}, + {"missing repo", []string{"--tag", "dispatcher-v3.0.0", "--tap-repo", "owner/tap"}, "--repo is required"}, + {"missing tag", []string{"--repo", "owner/repo", "--tap-repo", "owner/tap"}, "--tag is required"}, + {"missing tap repo", []string{"--repo", "owner/repo", "--tag", "dispatcher-v3.0.0"}, "--tap-repo is required"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := parseHomebrewFormulaUpdateFlags(testCase.args) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// TestRunUpdateHomebrewFormulaReportsUnknownFlag verifies the exported command reports a flag error before building any deps. +func TestRunUpdateHomebrewFormulaReportsUnknownFlag(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunUpdateHomebrewFormula(context.Background(), &stdout, &stderr, []string{"--unknown"}) + + if code != 1 || !strings.Contains(stderr.String(), "update-homebrew-formula: flag provided but not defined") { + t.Fatalf("exit code = %d, stderr = %q", code, stderr.String()) + } +} + +// TestParseHomebrewFormulaUpdateFlagsReturnsCompleteConfig verifies complete flags are carried into the config unchanged. +func TestParseHomebrewFormulaUpdateFlagsReturnsCompleteConfig(t *testing.T) { + config, err := parseHomebrewFormulaUpdateFlags([]string{ + "--repo", "owner/repo", "--tag", "dispatcher-v3.0.0", "--tap-repo", "owner/tap", + }) + if err != nil { + t.Fatalf("parseHomebrewFormulaUpdateFlags failed: %v", err) + } + want := homebrewFormulaUpdateConfig{repository: "owner/repo", tag: "dispatcher-v3.0.0", tapRepo: "owner/tap"} + if config != want { + t.Fatalf("config = %+v, want %+v", config, want) + } +} + +// homebrewFailingRunner serves the checksum downloads and tap reads, failing +// the first command whose arguments contain failOn. +func homebrewFailingRunner(failOn string, tapContents string) func(context.Context, []string, string, ...string) (string, error) { + return func(_ context.Context, _ []string, name string, args ...string) (string, error) { + joined := strings.Join(args, " ") + if failOn != "" && strings.Contains(joined, failOn) { + return "", errors.New(name + " " + failOn + " failed") + } + if output, handled, err := homebrewFormulaSHADownloadOutput(joined); handled { + return output, err + } + if strings.Contains(joined, "-X PUT") { + return `{}`, nil + } + return tapContents, nil + } +} + +// TestUpdateHomebrewFormulaReportsEachFailingStep verifies every failing step exits 1 with its error and stops before the tap write. +func TestUpdateHomebrewFormulaReportsEachFailingStep(t *testing.T) { + cases := []struct { + name string + tag string + failOn string + tapContents string + wantErr string + }{ + {"invalid tag", "v3.0.0", "", "", `must start with dispatcher-v`}, + {"arm64 checksum", "dispatcher-v3.0.0", homebrewDarwinArm64AssetName + ".sha256", "", "gh " + homebrewDarwinArm64AssetName + ".sha256 failed"}, + {"amd64 checksum", "dispatcher-v3.0.0", homebrewDarwinAmd64AssetName + ".sha256", "", "gh " + homebrewDarwinAmd64AssetName + ".sha256 failed"}, + {"tap read", "dispatcher-v3.0.0", "?ref=", "", "?ref= failed"}, + {"tap contents JSON", "dispatcher-v3.0.0", "", "{", "failed to parse tap formula contents"}, + {"tap contents base64", "dispatcher-v3.0.0", "", `{"sha":"s","content":"!!!"}`, "failed to decode tap formula contents"}, + {"tap write", "dispatcher-v3.0.0", "-X PUT", `{"sha":"s","content":""}`, "-X PUT failed"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv(homebrewTapTokenEnvName, "tap-token") + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runUpdateHomebrewFormulaWithDeps( + context.Background(), + &stdout, + &stderr, + homebrewFormulaUpdateConfig{repository: "owner/repo", tag: testCase.tag, tapRepo: "owner/tap"}, + homebrewFormulaUpdateDeps{runOutput: homebrewFailingRunner(testCase.failOn, testCase.tapContents)}, + ) + + if code != 1 { + t.Fatalf("exit code = %d, want 1 (stdout=%s)", code, stdout.String()) + } + if !strings.Contains(stderr.String(), testCase.wantErr) { + t.Fatalf("stderr = %q, want it to contain %q", stderr.String(), testCase.wantErr) + } + if strings.Contains(stdout.String(), "Updated Homebrew formula") { + t.Fatalf("unexpected success log: %s", stdout.String()) + } + }) + } +} + +// TestIsHexStringRejectsNonHexCharacters verifies mixed-case hex passes and any other character fails. +func TestIsHexStringRejectsNonHexCharacters(t *testing.T) { + if !isHexString("09afAF") { + t.Fatal("expected mixed-case hex to pass") + } + if isHexString("09afAG") { + t.Fatal("expected a non-hex character to fail") + } +} + +// TestRunHomebrewFormulaUpdateCommandOutputCapturesOutputAndFailures verifies the child sees both the parent environment and the extra variables, stdout is returned on success, and the command line is reported on failure. +func TestRunHomebrewFormulaUpdateCommandOutputCapturesOutputAndFailures(t *testing.T) { + // git reads GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 only when GIT_CONFIG_COUNT is set, so the value is + // printed only if the parent's two variables and the extra GIT_CONFIG_COUNT all reach the child. + // The parent may already carry GIT_CONFIG_COUNT (some shells and CI runners inject git config this way), + // so it is removed first; otherwise the extra variable would not be what makes git read the pair. + t.Setenv("GIT_CONFIG_COUNT", "") + if err := os.Unsetenv("GIT_CONFIG_COUNT"); err != nil { + t.Fatalf("unset GIT_CONFIG_COUNT: %v", err) + } + t.Setenv("GIT_CONFIG_KEY_0", "test.key") + t.Setenv("GIT_CONFIG_VALUE_0", "from-parent") + output, err := runHomebrewFormulaUpdateCommandOutput(context.Background(), []string{"GIT_CONFIG_COUNT=1"}, "git", "config", "--get", "test.key") + if err != nil { + t.Fatalf("git config --get failed: %v", err) + } + if strings.TrimSpace(output) != "from-parent" { + t.Fatalf("output = %q", output) + } + + _, err = runHomebrewFormulaUpdateCommandOutput(context.Background(), nil, "git", "--no-such-option") + if err == nil || !strings.Contains(err.Error(), "git --no-such-option failed") { + t.Fatalf("expected a failure naming the command, got %v", err) + } +} diff --git a/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go b/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go index 8716cca6b3..f9291a3ce7 100644 --- a/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go +++ b/cli/release-automation/internal/automation/notify_pending_release_approvals_test.go @@ -268,3 +268,128 @@ func assertNotifyPendingReleaseApprovalsDoesNotContain(t *testing.T, actual stri t.Fatalf("expected %q not to contain %q", actual, unexpected) } } + +// notifyPendingApprovalsStub answers each gh command with a canned response +// and fails the first command line starting with failOn. +type notifyPendingApprovalsStub struct { + runList string + pending string + issueList string + failOn string + commands []string +} + +func (stub *notifyPendingApprovalsStub) run(_ context.Context, name string, args ...string) (string, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + stub.commands = append(stub.commands, commandLine) + if stub.failOn != "" && strings.HasPrefix(commandLine, stub.failOn) { + return "", fmt.Errorf("%s failed", stub.failOn) + } + switch { + case strings.HasPrefix(commandLine, "gh run list"): + return stub.runList, nil + case strings.HasPrefix(commandLine, "gh api repos/owner/repository/actions/runs/"): + return stub.pending, nil + case strings.HasPrefix(commandLine, "gh issue list"): + return stub.issueList, nil + } + return "", nil +} + +func newNotifyPendingApprovalsStub() *notifyPendingApprovalsStub { + return ¬ifyPendingApprovalsStub{ + runList: `[{"databaseId":7,"workflowName":"publish","headBranch":"main","url":"https://example.invalid/runs/7"}]`, + pending: `[{"environment":{"name":"release"}},{"environment":{"name":"homebrew"}}]`, + issueList: `[]`, + } +} + +func runNotifyPendingApprovalsStub(stub *notifyPendingApprovalsStub) (int, string, string) { + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runNotifyPendingReleaseApprovalsWithDeps(context.Background(), &stdout, &stderr, "owner/repository", notifyPendingReleaseApprovalsDeps{runOutput: stub.run}) + return exitCode, stdout.String(), stderr.String() +} + +// Verifies the exported command refuses to run without a repository instead of querying an unknown one. +func TestRunNotifyPendingReleaseApprovalsRequiresRepository(t *testing.T) { + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunNotifyPendingReleaseApprovals(context.Background(), &stdout, &stderr, "") + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertNotifyPendingReleaseApprovalsContains(t, stderr.String(), "GITHUB_REPOSITORY is required") +} + +// Verifies every failing gh call and unparsable gh response aborts with exit code 1 and its error. +func TestRunNotifyPendingReleaseApprovalsFailsOnEachUnusableStep(t *testing.T) { + cases := []struct { + name string + mutate func(*notifyPendingApprovalsStub) + wantErr string + }{ + {"run list JSON", func(stub *notifyPendingApprovalsStub) { stub.runList = "{" }, "failed to parse waiting workflow runs"}, + {"pending deployments", func(stub *notifyPendingApprovalsStub) { stub.failOn = "gh api" }, "failed to resolve pending deployment environment for run 7: gh api failed"}, + {"pending deployments JSON", func(stub *notifyPendingApprovalsStub) { stub.pending = "{" }, "failed to parse pending deployments"}, + {"label", func(stub *notifyPendingApprovalsStub) { stub.failOn = "gh label create" }, "gh label create failed"}, + {"issue list", func(stub *notifyPendingApprovalsStub) { stub.failOn = "gh issue list" }, "gh issue list failed"}, + {"issue list JSON", func(stub *notifyPendingApprovalsStub) { stub.issueList = "{" }, "failed to parse open issue list"}, + {"issue create", func(stub *notifyPendingApprovalsStub) { stub.failOn = "gh issue create" }, "gh issue create failed"}, + {"issue edit", func(stub *notifyPendingApprovalsStub) { + stub.issueList = `[{"number":42,"title":"Release approval pending","body":"stale"}]` + stub.failOn = "gh issue edit" + }, "gh issue edit failed"}, + {"issue close", func(stub *notifyPendingApprovalsStub) { + stub.runList = `[]` + stub.issueList = `[{"number":42,"title":"Release approval pending","body":"stale"}]` + stub.failOn = "gh issue close" + }, "gh issue close failed"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + stub := newNotifyPendingApprovalsStub() + testCase.mutate(stub) + + exitCode, _, stderr := runNotifyPendingApprovalsStub(stub) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertNotifyPendingReleaseApprovalsContains(t, stderr, testCase.wantErr) + }) + } +} + +// Verifies a changed run set edits the matching tracking issue, ignoring other labeled issues, and lists every pending environment. +func TestRunNotifyPendingReleaseApprovalsUpdatesTheTrackingIssue(t *testing.T) { + stub := newNotifyPendingApprovalsStub() + stub.issueList = `[{"number":5,"title":"Something else","body":"x"},{"number":42,"title":"Release approval pending","body":"stale"}]` + + exitCode, stdout, stderr := runNotifyPendingApprovalsStub(stub) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", exitCode, stderr) + } + assertNotifyPendingReleaseApprovalsContains(t, stdout, "Updated pending approval issue #42.") + commandLogText := strings.Join(stub.commands, "\n") + assertNotifyPendingReleaseApprovalsContains(t, commandLogText, "gh issue edit 42 --repo owner/repository --body") + assertNotifyPendingReleaseApprovalsContains(t, commandLogText, "Environment: release, homebrew") +} + +// Verifies the command runner returns stdout on success and names the command on failure. +func TestRunNotifyPendingReleaseApprovalsCommandOutputCapturesOutputAndFailures(t *testing.T) { + output, err := runNotifyPendingReleaseApprovalsCommandOutput(context.Background(), "git", "--version") + if err != nil { + t.Fatalf("git --version failed: %v", err) + } + assertNotifyPendingReleaseApprovalsContains(t, output, "git version") + + _, err = runNotifyPendingReleaseApprovalsCommandOutput(context.Background(), "git", "--no-such-option") + if err == nil { + t.Fatal("expected an unknown git option to fail") + } + assertNotifyPendingReleaseApprovalsContains(t, err.Error(), "git --no-such-option failed") +} diff --git a/cli/release-automation/internal/automation/unity_release_resolver_test.go b/cli/release-automation/internal/automation/unity_release_resolver_test.go index 2fea374933..50889532f9 100644 --- a/cli/release-automation/internal/automation/unity_release_resolver_test.go +++ b/cli/release-automation/internal/automation/unity_release_resolver_test.go @@ -164,3 +164,119 @@ func readUnityReleaseFixture(t *testing.T, name string) []byte { } return body } + +// unityReleaseRoundTripper serves a canned response without opening a connection. +type unityReleaseRoundTripper func(*http.Request) (*http.Response, error) + +func (roundTripper unityReleaseRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTripper(request) +} + +// unityReleaseFailingBody fails every read so the response body cannot be consumed. +type unityReleaseFailingBody struct{} + +func (unityReleaseFailingBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (unityReleaseFailingBody) Close() error { return nil } + +func TestResolveUnityReleaseDefaultsToTheUnityServicesAPI(t *testing.T) { + // What: an empty API base URL resolves against the Unity services API with the release query. + requestedURL := "" + client := &http.Client{Transport: unityReleaseRoundTripper(func(request *http.Request) (*http.Response, error) { + requestedURL = request.URL.String() + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(readUnityReleaseFixture(t, "unity-release-latest-first.json")))), + }, nil + })} + + release, err := ResolveUnityRelease(context.Background(), ResolveUnityReleaseRequest{Series: "6000.7", HTTPClient: client}) + if err != nil { + t.Fatalf("ResolveUnityRelease failed: %v", err) + } + if release.Version == "" { + t.Fatal("expected a resolved release") + } + if !strings.HasPrefix(requestedURL, defaultUnityReleaseAPIBaseURL+unityEditorReleasesPath+"?") || + !strings.Contains(requestedURL, "version=6000.7") { + t.Fatalf("requested URL = %q", requestedURL) + } +} + +func TestResolveUnityReleaseUsesTheDefaultHTTPClient(t *testing.T) { + // What: a request without an HTTP client still reaches the given API through the default client. + body := readUnityReleaseFixture(t, "unity-release-latest-first.json") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write(body) + })) + defer server.Close() + + release, err := ResolveUnityRelease(context.Background(), ResolveUnityReleaseRequest{Series: "6000.7", APIBaseURL: server.URL}) + if err != nil { + t.Fatalf("ResolveUnityRelease failed: %v", err) + } + if release.Version == "" || release.EditorURL == "" { + t.Fatalf("release = %+v", release) + } +} + +func TestResolveUnityReleaseRejectsUnusableInputs(t *testing.T) { + // What: a missing series, an unparsable base URL, and an unreadable response body each fail with a specific error. + failingBodyClient := &http.Client{Transport: unityReleaseRoundTripper(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: unityReleaseFailingBody{}}, nil + })} + cases := []struct { + name string + request ResolveUnityReleaseRequest + wantErr string + }{ + {"missing series", ResolveUnityReleaseRequest{}, "--series is required"}, + {"invalid base URL", ResolveUnityReleaseRequest{Series: "6000.7", APIBaseURL: "://missing-scheme"}, "parse unity release API base URL"}, + {"unreadable body", ResolveUnityReleaseRequest{Series: "6000.7", APIBaseURL: "https://example.invalid", HTTPClient: failingBodyClient}, "read unity release API response"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := ResolveUnityRelease(context.Background(), testCase.request) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +func TestSelectUnityReleaseRejectsMalformedResults(t *testing.T) { + // What: invalid JSON, a result without a version or a short revision, and a Linux archive without a URL are rejected. + cases := []struct { + name string + body string + wantErr string + }{ + {"invalid JSON", "{", "parse unity release response"}, + {"missing version", `{"results":[{"shortRevision":"abc"}]}`, "missing version or shortRevision"}, + {"missing short revision", `{"results":[{"version":"6000.7.0"}]}`, "missing version or shortRevision"}, + {"archive without URL", `{"results":[{"version":"6000.7.0","shortRevision":"abc","downloads":[{"platform":"LINUX","architecture":"X86_64","type":"TAR_XZ"}]}]}`, "download is missing url"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := selectUnityRelease([]byte(testCase.body)) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +func TestRunResolveUnityReleaseRejectsUnknownFlags(t *testing.T) { + // What: an unknown flag exits non-zero before any API request. + stderr := strings.Builder{} + + exitCode := RunResolveUnityRelease(io.Discard, &stderr, []string{"--unknown"}) + + if exitCode != 1 { + t.Fatalf("exit code = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), "flag provided but not defined") || strings.Contains(stderr.String(), "--series is required") { + t.Fatalf("expected only the flag parse error, got %q", stderr.String()) + } +} diff --git a/cli/release-automation/internal/automation/winget_manifest_github_test.go b/cli/release-automation/internal/automation/winget_manifest_github_test.go new file mode 100644 index 0000000000..4a4599478f --- /dev/null +++ b/cli/release-automation/internal/automation/winget_manifest_github_test.go @@ -0,0 +1,32 @@ +package automation + +import ( + "context" + "strings" + "testing" +) + +// TestPutWingetManifestFileUpdatesExistingFileWithItsSHA verifies an existing fork file is overwritten by passing its blob SHA. +func TestPutWingetManifestFileUpdatesExistingFileWithItsSHA(t *testing.T) { + var putArgs []string + deps := wingetManifestUpdateDeps{ + runOutput: func(_ context.Context, extraEnv []string, _ string, args ...string) (string, error) { + if len(extraEnv) != 1 || extraEnv[0] != "GH_TOKEN=token" { + t.Fatalf("environment = %v", extraEnv) + } + if strings.Contains(strings.Join(args, " "), "-X PUT") { + putArgs = append([]string{}, args...) + return `{}`, nil + } + return `{"sha":"existing-sha"}`, nil + }, + } + + err := putWingetManifestFile(context.Background(), deps, "token", "owner/fork", "branch", "manifests/a.yaml", "3.1.0", "content") + if err != nil { + t.Fatalf("putWingetManifestFile failed: %v", err) + } + if flagValue(putArgs, "sha") != "existing-sha" { + t.Fatalf("PUT args = %v, want sha=existing-sha", putArgs) + } +} diff --git a/cli/release-automation/internal/automation/winget_manifest_update_test.go b/cli/release-automation/internal/automation/winget_manifest_update_test.go index 5ac04b7bea..f1c64ab9af 100644 --- a/cli/release-automation/internal/automation/winget_manifest_update_test.go +++ b/cli/release-automation/internal/automation/winget_manifest_update_test.go @@ -578,3 +578,156 @@ func flagValue(args []string, name string) string { } return "" } + +// TestParseWingetManifestUpdateFlagsRejectsIncompleteFlags verifies unknown flags and each missing required flag are rejected by their own message. +func TestParseWingetManifestUpdateFlagsRejectsIncompleteFlags(t *testing.T) { + cases := []struct { + name string + args []string + wantErr string + }{ + {"unknown flag", []string{"--unknown"}, "flag provided but not defined"}, + {"missing repo", []string{"--tag", "dispatcher-v3.1.0", "--fork-repo", "owner/fork"}, "--repo is required"}, + {"missing tag", []string{"--repo", "owner/repo", "--fork-repo", "owner/fork"}, "--tag is required"}, + {"missing fork repo", []string{"--repo", "owner/repo", "--tag", "dispatcher-v3.1.0"}, "--fork-repo is required"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := parseWingetManifestUpdateFlags(testCase.args) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// TestRunUpdateWingetManifestReportsUnknownFlag verifies the exported command reports a flag error before building any deps. +func TestRunUpdateWingetManifestReportsUnknownFlag(t *testing.T) { + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + code := RunUpdateWingetManifest(context.Background(), &stdout, &stderr, []string{"--unknown"}) + + if code != 1 || !strings.Contains(stderr.String(), "update-winget-manifest: flag provided but not defined") { + t.Fatalf("exit code = %d, stderr = %q", code, stderr.String()) + } +} + +// TestParseWingetManifestUpdateFlagsReturnsCompleteConfig verifies complete flags are carried into the config unchanged. +func TestParseWingetManifestUpdateFlagsReturnsCompleteConfig(t *testing.T) { + config, err := parseWingetManifestUpdateFlags([]string{ + "--repo", "owner/repo", "--tag", "dispatcher-v3.1.0", "--fork-repo", "owner/fork", + }) + if err != nil { + t.Fatalf("parseWingetManifestUpdateFlags failed: %v", err) + } + want := wingetManifestUpdateConfig{repository: "owner/repo", tag: "dispatcher-v3.1.0", forkRepo: "owner/fork"} + if config != want { + t.Fatalf("config = %+v, want %+v", config, want) + } +} + +// wingetOverride replaces the scenario's answer for the first command whose +// joined arguments satisfy matches. +type wingetOverride struct { + matches func(joined string) bool + output string + err error +} + +func (s *wingetTestScenario) runOutputWithOverride(override wingetOverride) func(context.Context, []string, string, ...string) (string, error) { + return func(ctx context.Context, extraEnv []string, name string, args ...string) (string, error) { + if override.matches(strings.Join(args, " ")) { + return override.output, override.err + } + return s.runOutput(ctx, extraEnv, name, args...) + } +} + +func wingetJoinedContains(parts ...string) func(string) bool { + return func(joined string) bool { + for _, part := range parts { + if !strings.Contains(joined, part) { + return false + } + } + return true + } +} + +func wingetForkContentRead(joined string) bool { + return strings.Contains(joined, "repos/hatayama/winget-pkgs/contents/") && !strings.Contains(joined, "-X PUT") +} + +func runWingetOverrideScenario(t *testing.T, tag string, override wingetOverride) (string, string, int) { + t.Helper() + t.Setenv(wingetPkgsTokenEnvName, "winget-token") + scenario := newWingetTestScenario() + scenario.version = strings.TrimPrefix(tag, "dispatcher-v") + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + code := runUpdateWingetManifestWithDeps( + context.Background(), + &stdout, + &stderr, + testWingetConfig(tag), + wingetManifestUpdateDeps{runOutput: scenario.runOutputWithOverride(override)}, + ) + return stdout.String(), stderr.String(), code +} + +// TestUpdateWingetManifestReportsEachFailingStep verifies every failing or malformed gh response exits 1 with its error instead of opening a pull request. +func TestUpdateWingetManifestReportsEachFailingStep(t *testing.T) { + never := func(string) bool { return false } + cases := []struct { + name string + tag string + override wingetOverride + wantErr string + }{ + {"invalid tag", "v3.1.0", wingetOverride{matches: never}, "must start with dispatcher-v"}, + {"package lookup", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("uloop?ref=master"), err: errors.New("package lookup failed")}, "package lookup failed"}, + {"version lookup", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("uloop/3.1.0?ref=master"), err: errors.New("version lookup failed")}, "version lookup failed"}, + {"release view", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("release view"), err: errors.New("release view failed")}, "release view failed"}, + {"release view JSON", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("release view"), output: "{"}, "failed to parse release metadata"}, + {"release date", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("release view"), output: `{"publishedAt":"2026"}`}, `invalid publishedAt value "2026"`}, + {"checksum download", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("release download"), err: errors.New("checksum download failed")}, "checksum download failed"}, + {"fork sync", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("merge-upstream"), err: errors.New("fork sync failed")}, "fork sync failed"}, + {"upstream ref", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("git/ref/heads/master"), err: errors.New("upstream ref failed")}, "upstream ref failed"}, + {"empty upstream ref", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("git/ref/heads/master"), output: "\n"}, "winget upstream master SHA is empty"}, + {"branch creation", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("git/refs"), err: errors.New("gh api failed: HTTP 422 Validation Failed")}, "HTTP 422 Validation Failed"}, + {"fork content read", "dispatcher-v3.1.0", wingetOverride{matches: wingetForkContentRead, err: errors.New("fork content read failed")}, "fork content read failed"}, + {"fork content JSON", "dispatcher-v3.1.0", wingetOverride{matches: wingetForkContentRead, output: "{"}, "failed to parse winget fork content response"}, + {"manifest write", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("-X PUT"), err: errors.New("manifest write failed")}, "manifest write failed"}, + {"pull request list", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("state=open"), err: errors.New("pull request list failed")}, "pull request list failed"}, + {"pull request list JSON", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("state=open"), output: "{"}, "failed to parse winget pull request list"}, + {"pull request create", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("/pulls", "-X POST"), err: errors.New("pull request create failed")}, "pull request create failed"}, + {"pull request create JSON", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("/pulls", "-X POST"), output: "{"}, "failed to parse created winget pull request"}, + {"pull request URL", "dispatcher-v3.1.0", wingetOverride{matches: wingetJoinedContains("/pulls", "-X POST"), output: `{}`}, "created winget pull request URL is empty"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + stdout, stderr, code := runWingetOverrideScenario(t, testCase.tag, testCase.override) + + if code != 1 { + t.Fatalf("exit code = %d, want 1 (stdout=%s)", code, stdout) + } + if !strings.Contains(stderr, testCase.wantErr) { + t.Fatalf("stderr = %q, want it to contain %q", stderr, testCase.wantErr) + } + }) + } +} + +// TestUpdateWingetManifestPrintsCreatedPullRequestURL verifies a full submission prints the URL of the pull request it opened. +func TestUpdateWingetManifestPrintsCreatedPullRequestURL(t *testing.T) { + stdout, stderr, code := runWingetOverrideScenario(t, "dispatcher-v3.1.0", wingetOverride{matches: func(string) bool { return false }}) + + if code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr) + } + if strings.TrimSpace(stdout) != "https://example.invalid/new" { + t.Fatalf("stdout = %q", stdout) + } +} From c3b6ff0ea469faca0baf8b563ee372c399aad219 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:12:33 +0900 Subject: [PATCH 08/68] chore: Dispatcher install, update, and download paths in the dispatcher CLI now have tests for failure handling (#3079) --- .../dispatcher/dispatcher_download_test.go | 610 ++++++++++++++++++ .../dispatcher/dispatcher_freshness_test.go | 197 ++++++ .../dispatcher_update_version_test.go | 39 ++ .../dispatcher/dispatcher_v2_delegate_test.go | 76 +++ .../dispatcher/dispatcher_v2_detect_test.go | 226 +++++++ .../dispatcher/dispatcher_v2_install_test.go | 149 +++++ .../dispatcher/dispatcher_v2_run_test.go | 189 +++++- .../internal/dispatcher/install_test.go | 115 ++++ .../dispatcher/run_dispatcher_test.go | 201 ++++++ .../internal/dispatcher/uninstall_test.go | 56 ++ .../internal/dispatcher/update_test.go | 155 +++++ 11 files changed, 2012 insertions(+), 1 deletion(-) create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_download_test.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_freshness_test.go create mode 100644 cli/dispatcher/internal/dispatcher/run_dispatcher_test.go diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_download_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_download_test.go new file mode 100644 index 0000000000..eb98e67e15 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_download_test.go @@ -0,0 +1,610 @@ +package dispatcher + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" +) + +// stubDispatcherHTTPResponses serves the given body for every request path ending with a key +// and 404 for everything else, then restores the real client when the test ends. +func stubDispatcherHTTPResponses(t *testing.T, bodies map[string][]byte) { + t.Helper() + previousHTTPClient := dispatcherHTTPClient + t.Cleanup(func() { + dispatcherHTTPClient = previousHTTPClient + }) + dispatcherHTTPClient = &http.Client{ + Transport: dispatcherRoundTripFunc(func(request *http.Request) (*http.Response, error) { + content := []byte{} + statusCode := http.StatusNotFound + for suffix, body := range bodies { + if strings.HasSuffix(request.URL.Path, suffix) { + content = body + statusCode = http.StatusOK + } + } + return &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Body: io.NopCloser(bytes.NewReader(content)), + }, nil + }), + } +} + +func dispatcherTestChecksumLine(t *testing.T, filePath string) []byte { + t.Helper() + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read %s: %v", filePath, err) + } + checksum := sha256.Sum256(content) + return []byte(hex.EncodeToString(checksum[:]) + " " + filepath.Base(filePath) + "\n") +} + +func writeDispatcherTestFile(t *testing.T, filePath string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatalf("failed to create directory for %s: %v", filePath, err) + } + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", filePath, err) + } +} + +func TestDispatcherReleaseAssetNameCoversSupportedAndUnsupportedPlatforms(t *testing.T) { + // Verifies each published platform maps to its archive name and every other platform is rejected with a descriptive error. + cases := []struct { + goos string + goarch string + wantName string + wantError string + }{ + {goos: "darwin", goarch: "amd64", wantName: "uloop-project-runner-darwin-amd64.tar.gz"}, + {goos: "darwin", goarch: "arm64", wantName: "uloop-project-runner-darwin-arm64.tar.gz"}, + {goos: "darwin", goarch: "386", wantError: "unsupported darwin architecture: 386"}, + {goos: "windows", goarch: "amd64", wantName: "uloop-project-runner-windows-amd64.zip"}, + {goos: "windows", goarch: "arm64", wantError: "unsupported windows architecture: arm64"}, + {goos: "linux", goarch: "amd64", wantName: "uloop-project-runner-linux-amd64.tar.gz"}, + {goos: "linux", goarch: "arm64", wantError: "unsupported platform: linux-arm64"}, + {goos: "freebsd", goarch: "amd64", wantError: "unsupported platform: freebsd-amd64"}, + } + for _, testCase := range cases { + t.Run(testCase.goos+"-"+testCase.goarch, func(t *testing.T) { + name, err := dispatcherReleaseAssetName(testCase.goos, testCase.goarch) + if testCase.wantError != "" { + if err == nil || err.Error() != testCase.wantError { + t.Fatalf("error mismatch: got %v want %q", err, testCase.wantError) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != testCase.wantName { + t.Fatalf("asset name mismatch: got %s want %s", name, testCase.wantName) + } + }) + } +} + +func TestDownloadDispatcherFileRejectsNonSuccessStatus(t *testing.T) { + // Verifies a non-2xx response is reported with the URL and status and leaves no destination file. + stubDispatcherHTTPResponses(t, nil) + destinationPath := filepath.Join(t.TempDir(), "asset") + + err := downloadDispatcherFile(context.Background(), "https://example.invalid/asset", destinationPath) + + if err == nil || !strings.Contains(err.Error(), "download failed for https://example.invalid/asset") { + t.Fatalf("expected a download status error, got %v", err) + } + if fileExists(destinationPath) { + t.Fatal("a failed download must not create the destination file") + } +} + +func TestDownloadDispatcherFileReturnsTransportError(t *testing.T) { + // Verifies transport failures are returned to the caller unchanged. + previousHTTPClient := dispatcherHTTPClient + t.Cleanup(func() { + dispatcherHTTPClient = previousHTTPClient + }) + transportErr := errors.New("simulated transport failure") + dispatcherHTTPClient = &http.Client{ + Transport: dispatcherRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, transportErr + }), + } + + err := downloadDispatcherFile(context.Background(), "https://example.invalid/asset", filepath.Join(t.TempDir(), "asset")) + + if !errors.Is(err, transportErr) { + t.Fatalf("expected the transport error, got %v", err) + } +} + +func TestDownloadDispatcherFileRejectsInvalidURL(t *testing.T) { + // Verifies a URL that cannot form a request fails before any network call. + err := downloadDispatcherFile(context.Background(), "://missing-scheme", filepath.Join(t.TempDir(), "asset")) + if err == nil || !strings.Contains(err.Error(), "missing protocol scheme") { + t.Fatalf("expected an invalid URL error, got %v", err) + } +} + +func TestDownloadDispatcherFileReportsUnwritableDestination(t *testing.T) { + // Verifies a destination inside a missing directory surfaces the file creation error. + stubDispatcherHTTPResponses(t, map[string][]byte{"/asset": []byte("content")}) + destinationPath := filepath.Join(t.TempDir(), "missing", "asset") + + err := downloadDispatcherFile(context.Background(), "https://example.invalid/asset", destinationPath) + + if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "open "+destinationPath) { + t.Fatalf("expected a destination open error, got %v", err) + } +} + +func TestVerifyDispatcherChecksumFailures(t *testing.T) { + // Verifies each way checksum verification can fail is reported instead of accepting the asset. + tempDir := t.TempDir() + assetPath := filepath.Join(tempDir, "asset.tar.gz") + writeDispatcherTestFile(t, assetPath, "asset") + validChecksumPath := filepath.Join(tempDir, "valid.sha256") + if err := os.WriteFile(validChecksumPath, dispatcherTestChecksumLine(t, assetPath), 0o644); err != nil { + t.Fatalf("failed to write checksum: %v", err) + } + emptyChecksumPath := filepath.Join(tempDir, "empty.sha256") + writeDispatcherTestFile(t, emptyChecksumPath, " \n") + wrongChecksumPath := filepath.Join(tempDir, "wrong.sha256") + writeDispatcherTestFile(t, wrongChecksumPath, strings.Repeat("0", 64)+" asset.tar.gz\n") + + cases := []struct { + name string + assetPath string + checksumPath string + wantMessage string + wantNotExist bool + }{ + {name: "missing checksum file", assetPath: assetPath, checksumPath: filepath.Join(tempDir, "missing.sha256"), wantMessage: "missing.sha256", wantNotExist: true}, + {name: "empty checksum file", assetPath: assetPath, checksumPath: emptyChecksumPath, wantMessage: "checksum file is empty"}, + {name: "missing asset", assetPath: filepath.Join(tempDir, "missing.tar.gz"), checksumPath: validChecksumPath, wantMessage: "missing.tar.gz", wantNotExist: true}, + {name: "unreadable asset", assetPath: tempDir, checksumPath: validChecksumPath, wantMessage: "read " + tempDir}, + {name: "mismatch", assetPath: assetPath, checksumPath: wrongChecksumPath, wantMessage: "checksum mismatch for asset.tar.gz"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := verifyDispatcherChecksum(testCase.assetPath, testCase.checksumPath) + assertDispatcherTestError(t, err, testCase.wantMessage, testCase.wantNotExist) + }) + } +} + +func assertDispatcherTestError(t *testing.T, err error, wantMessage string, wantNotExist bool) { + t.Helper() + if wantNotExist && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected a not-exist error, got %v", err) + } + if err == nil || !strings.Contains(err.Error(), wantMessage) { + t.Fatalf("expected error containing %q, got %v", wantMessage, err) + } +} + +func TestVerifyDispatcherChecksumAcceptsUppercaseDigest(t *testing.T) { + // Verifies the expected digest is compared case-insensitively. + tempDir := t.TempDir() + assetPath := filepath.Join(tempDir, "asset.tar.gz") + writeDispatcherTestFile(t, assetPath, "asset") + checksumPath := filepath.Join(tempDir, "asset.tar.gz.sha256") + writeDispatcherTestFile(t, checksumPath, strings.ToUpper(string(dispatcherTestChecksumLine(t, assetPath)))) + + if err := verifyDispatcherChecksum(assetPath, checksumPath); err != nil { + t.Fatalf("uppercase digest must be accepted: %v", err) + } +} + +func TestExtractDispatcherRealCLIFromTarGzFailures(t *testing.T) { + // Verifies tar.gz extraction rejects missing, corrupt, and runner-less archives. + tempDir := t.TempDir() + runnerlessArchivePath := filepath.Join(tempDir, "runnerless.tar.gz") + writeDispatcherTarGzArchive(t, runnerlessArchivePath, []dispatcherArchiveTestEntry{ + {Name: "README.md", Content: "docs"}, + }) + notGzipPath := filepath.Join(tempDir, "not-gzip.tar.gz") + writeDispatcherTestFile(t, notGzipPath, "plain text") + corruptTarPath := filepath.Join(tempDir, "corrupt-tar.tar.gz") + writeDispatcherGzipFile(t, corruptTarPath, []byte(strings.Repeat("x", 600))) + + cases := []struct { + name string + archivePath string + wantMessage string + wantNotExist bool + }{ + {name: "missing archive", archivePath: filepath.Join(tempDir, "missing.tar.gz"), wantMessage: "missing.tar.gz", wantNotExist: true}, + {name: "not gzip", archivePath: notGzipPath, wantMessage: "gzip: invalid header"}, + {name: "corrupt tar", archivePath: corruptTarPath, wantMessage: "archive/tar"}, + {name: "runner missing", archivePath: runnerlessArchivePath, wantMessage: "archive does not contain uloop-project-runner"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + destinationPath := filepath.Join(t.TempDir(), "uloop-project-runner") + err := extractDispatcherRealCLI(testCase.archivePath, "asset.tar.gz", destinationPath, "linux") + assertDispatcherTestError(t, err, testCase.wantMessage, testCase.wantNotExist) + if fileExists(destinationPath) { + t.Fatal("a failed extraction must not create the destination file") + } + }) + } +} + +func writeDispatcherGzipFile(t *testing.T, filePath string, content []byte) { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + if _, err := gzipWriter.Write(content); err != nil { + t.Fatalf("failed to write gzip content: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("failed to close gzip writer: %v", err) + } + if err := os.WriteFile(filePath, buffer.Bytes(), 0o644); err != nil { + t.Fatalf("failed to write gzip file: %v", err) + } +} + +func TestExtractDispatcherRealCLIFromTarGzSkipsNonRegularEntries(t *testing.T) { + // Verifies a directory entry named like the runner is skipped and the regular file in a subdirectory is extracted. + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, "runner.tar.gz") + writeDispatcherTarGzArchiveWithDirectory(t, archivePath, "uloop-project-runner/", []dispatcherArchiveTestEntry{ + {Name: "docs/README.md", Content: "docs"}, + {Name: "bin/uloop-project-runner", Content: "runner"}, + }) + destinationPath := filepath.Join(tempDir, "extracted") + + if err := extractDispatcherRealCLI(archivePath, "asset.tar.gz", destinationPath, "linux"); err != nil { + t.Fatalf("extractDispatcherRealCLI failed: %v", err) + } + assertFileContent(t, destinationPath, "runner") +} + +func TestExtractDispatcherRealCLIFromZipFailures(t *testing.T) { + // Verifies zip extraction rejects corrupt archives and archives without the runner executable. + tempDir := t.TempDir() + runnerlessArchivePath := filepath.Join(tempDir, "runnerless.zip") + writeDispatcherZipArchive(t, runnerlessArchivePath, []dispatcherArchiveTestEntry{ + {Name: "uloop-project-runner.exe/", Content: ""}, + {Name: "README.md", Content: "docs"}, + }) + corruptArchivePath := filepath.Join(tempDir, "corrupt.zip") + writeDispatcherTestFile(t, corruptArchivePath, "not a zip") + + cases := []struct { + name string + archivePath string + wantMessage string + }{ + {name: "corrupt zip", archivePath: corruptArchivePath, wantMessage: "zip: not a valid zip file"}, + {name: "runner missing", archivePath: runnerlessArchivePath, wantMessage: "archive does not contain uloop-project-runner.exe"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + destinationPath := filepath.Join(t.TempDir(), "uloop-project-runner.exe") + err := extractDispatcherRealCLI(testCase.archivePath, "asset.zip", destinationPath, "windows") + assertDispatcherTestError(t, err, testCase.wantMessage, false) + }) + } +} + +func TestExtractDispatcherRealCLIReportsUnwritableDestination(t *testing.T) { + // Verifies extraction surfaces a destination write failure for both archive formats. + tempDir := t.TempDir() + tarPath := filepath.Join(tempDir, "runner.tar.gz") + writeDispatcherTarGzArchive(t, tarPath, []dispatcherArchiveTestEntry{{Name: "uloop-project-runner", Content: "runner"}}) + zipPath := filepath.Join(tempDir, "runner.zip") + writeDispatcherZipArchive(t, zipPath, []dispatcherArchiveTestEntry{{Name: "uloop-project-runner.exe", Content: "runner"}}) + destinationPath := filepath.Join(tempDir, "missing", "runner") + + for _, err := range []error{ + extractDispatcherRealCLI(tarPath, "asset.tar.gz", destinationPath, "linux"), + extractDispatcherRealCLI(zipPath, "asset.zip", destinationPath, "windows"), + } { + if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "open "+destinationPath) { + t.Fatalf("expected the destination open error, got %v", err) + } + } +} + +func TestInstallDownloadedDispatcherRealCLIRetriesRenameAfterRemovingIncompleteEntry(t *testing.T) { + // Verifies a first rename failure over an incomplete cache entry removes that entry and retries the rename. + tempDir := t.TempDir() + realCLIPath := filepath.Join(tempDir, "uloop-project-runner") + tempRealCLIPath := filepath.Join(tempDir, "downloaded") + writeDispatcherTestFile(t, realCLIPath, "incomplete") + writeDispatcherTestFile(t, tempRealCLIPath, "downloaded") + + previousRename := dispatcherRename + t.Cleanup(func() { + dispatcherRename = previousRename + }) + renameCalls := 0 + dispatcherRename = func(oldPath string, newPath string) error { + renameCalls++ + if renameCalls == 1 { + return errors.New("destination busy") + } + if fileExists(newPath) { + t.Fatalf("incomplete entry must be removed before the retry: %s", newPath) + } + return previousRename(oldPath, newPath) + } + + path, err := installDownloadedDispatcherRealCLI(tempRealCLIPath, realCLIPath) + if err != nil { + t.Fatalf("installDownloadedDispatcherRealCLI failed: %v", err) + } + if path != realCLIPath || renameCalls != 2 { + t.Fatalf("unexpected result: path=%s renameCalls=%d", path, renameCalls) + } + assertFileContent(t, realCLIPath, "downloaded") + assertFileContent(t, dispatcherRealCLIReadyPath(realCLIPath), "ready\n") +} + +func TestInstallDownloadedDispatcherRealCLIReportsSecondRenameFailure(t *testing.T) { + // Verifies a rename that keeps failing is reported and no READY marker is written. + tempDir := t.TempDir() + realCLIPath := filepath.Join(tempDir, "uloop-project-runner") + tempRealCLIPath := filepath.Join(tempDir, "downloaded") + writeDispatcherTestFile(t, tempRealCLIPath, "downloaded") + + previousRename := dispatcherRename + t.Cleanup(func() { + dispatcherRename = previousRename + }) + renameErr := errors.New("rename always fails") + dispatcherRename = func(string, string) error { + return renameErr + } + + _, err := installDownloadedDispatcherRealCLI(tempRealCLIPath, realCLIPath) + if !errors.Is(err, renameErr) { + t.Fatalf("expected the rename error, got %v", err) + } + if fileExists(dispatcherRealCLIReadyPath(realCLIPath)) { + t.Fatal("READY must not be written when the runner was not installed") + } +} + +func TestInstallDownloadedDispatcherRealCLIReportsCleanupFailures(t *testing.T) { + // Verifies failures to clear a stale READY marker or a stale cache entry abort the install. + cases := []struct { + name string + setup func(t *testing.T, realCLIPath string) + blockedPath func(realCLIPath string) string + }{ + { + name: "stale READY cannot be removed", + setup: func(t *testing.T, realCLIPath string) { + writeDispatcherTestFile(t, filepath.Join(dispatcherRealCLIReadyPath(realCLIPath), "child"), "x") + }, + blockedPath: dispatcherRealCLIReadyPath, + }, + { + name: "stale entry cannot be removed", + setup: func(t *testing.T, realCLIPath string) { + writeDispatcherTestFile(t, filepath.Join(realCLIPath, "child"), "x") + }, + blockedPath: func(realCLIPath string) string { return realCLIPath }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + tempDir := t.TempDir() + realCLIPath := filepath.Join(tempDir, "uloop-project-runner") + tempRealCLIPath := filepath.Join(tempDir, "downloaded") + writeDispatcherTestFile(t, tempRealCLIPath, "downloaded") + testCase.setup(t, realCLIPath) + stubDispatcherRenameFailsOnce(t) + + _, err := installDownloadedDispatcherRealCLI(tempRealCLIPath, realCLIPath) + if err == nil || !strings.Contains(err.Error(), "remove "+testCase.blockedPath(realCLIPath)+":") { + t.Fatalf("expected the cleanup failure to abort the install, got %v", err) + } + assertFileContent(t, tempRealCLIPath, "downloaded") + }) + } +} + +func stubDispatcherRenameFailsOnce(t *testing.T) { + t.Helper() + previousRename := dispatcherRename + t.Cleanup(func() { + dispatcherRename = previousRename + }) + renameCalls := 0 + dispatcherRename = func(oldPath string, newPath string) error { + renameCalls++ + if renameCalls == 1 { + return errors.New("destination busy") + } + return previousRename(oldPath, newPath) + } +} + +func TestMarkDispatcherRealCLIReadyReportsWriteFailure(t *testing.T) { + // Verifies a READY marker that cannot be written is reported instead of claiming the runner is ready. + tempDir := t.TempDir() + realCLIPath := filepath.Join(tempDir, "uloop-project-runner") + if err := os.MkdirAll(dispatcherRealCLIReadyPath(realCLIPath), 0o755); err != nil { + t.Fatalf("failed to create READY directory: %v", err) + } + + path, err := markDispatcherRealCLIReady(realCLIPath) + if err == nil || path != "" || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("expected a write failure, got path=%q err=%v", path, err) + } +} + +func TestDownloadDispatcherRealCLIForPinFailures(t *testing.T) { + // Verifies each download stage failure stops the install before a runner is cached. + archiveDir := t.TempDir() + archivePath := filepath.Join(archiveDir, "uloop-project-runner-linux-amd64.tar.gz") + writeDispatcherTarGzArchive(t, archivePath, []dispatcherArchiveTestEntry{{Name: "README.md", Content: "docs"}}) + archiveContent, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("failed to read archive: %v", err) + } + validChecksum := dispatcherTestChecksumLine(t, archivePath) + assetSuffix := "/uloop-project-runner-linux-amd64.tar.gz" + + cases := []struct { + name string + goarch string + bodies map[string][]byte + wantMessage string + }{ + {name: "unsupported platform", goarch: "arm64", wantMessage: "unsupported platform"}, + {name: "archive download", goarch: "amd64", wantMessage: "linux-amd64.tar.gz: Not Found"}, + {name: "checksum download", goarch: "amd64", bodies: map[string][]byte{assetSuffix: archiveContent}, wantMessage: "linux-amd64.tar.gz.sha256: Not Found"}, + {name: "checksum mismatch", goarch: "amd64", bodies: map[string][]byte{assetSuffix: archiveContent, assetSuffix + ".sha256": []byte(strings.Repeat("0", 64))}, wantMessage: "checksum mismatch"}, + {name: "runner missing from archive", goarch: "amd64", bodies: map[string][]byte{assetSuffix: archiveContent, assetSuffix + ".sha256": validChecksum}, wantMessage: "archive does not contain"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + stubDispatcherHTTPResponses(t, testCase.bodies) + restoreAttestation := stubAttestationVerifyPasses() + t.Cleanup(restoreAttestation) + cacheRoot := t.TempDir() + + _, err := downloadDispatcherRealCLIForPin(context.Background(), cacheRoot, dispatcherPin{ProjectRunnerVersion: "3.0.0"}, "linux", testCase.goarch, io.Discard) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got %v", testCase.wantMessage, err) + } + if fileExists(dispatcherCachedRealCLIPath(cacheRoot, "3.0.0", "linux", testCase.goarch)) { + t.Fatal("a failed download must not leave a cached runner") + } + }) + } +} + +func TestDownloadDispatcherRealCLIForPinReportsUnusableCacheRoot(t *testing.T) { + // Verifies a cache root that is a regular file fails before any download starts. + cacheRoot := filepath.Join(t.TempDir(), "cache-file") + writeDispatcherTestFile(t, cacheRoot, "not a directory") + forbidDispatcherHTTPRequests(t) + + _, err := downloadDispatcherRealCLIForPin(context.Background(), cacheRoot, dispatcherPin{ProjectRunnerVersion: "3.0.0"}, "linux", "amd64", io.Discard) + + if err == nil || !strings.Contains(err.Error(), "mkdir "+cacheRoot) { + t.Fatalf("expected a cache directory creation error, got %v", err) + } +} + +func TestResolveDispatcherRealCLIDownloadsOnCacheMiss(t *testing.T) { + // Verifies a cache miss for the pinned version downloads into ULOOP_CACHE_DIR and returns the cached path. + assetName, err := dispatcherReleaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("no published runner asset for %s-%s", runtime.GOOS, runtime.GOARCH) + } + archivePath := filepath.Join(t.TempDir(), assetName) + entryName := dispatcherRealCLIFileName(runtime.GOOS) + if strings.HasSuffix(assetName, ".zip") { + writeDispatcherZipArchive(t, archivePath, []dispatcherArchiveTestEntry{{Name: entryName, Content: "runner"}}) + } else { + writeDispatcherTarGzArchive(t, archivePath, []dispatcherArchiveTestEntry{{Name: entryName, Content: "runner"}}) + } + archiveContent, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("failed to read archive: %v", err) + } + stubDispatcherHTTPResponses(t, map[string][]byte{ + "/" + assetName: archiveContent, + "/" + assetName + ".sha256": dispatcherTestChecksumLine(t, archivePath), + }) + restoreAttestation := stubAttestationVerifyPasses() + t.Cleanup(restoreAttestation) + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + t.Setenv(nativepath.ProjectRunnerPathEnvName, "") + + realCLIPath, err := resolveDispatcherRealCLI(context.Background(), dispatcherPin{ProjectRunnerVersion: " 3.0.0 "}, io.Discard) + if err != nil { + t.Fatalf("resolveDispatcherRealCLI failed: %v", err) + } + if realCLIPath != dispatcherCachedRealCLIPath(cacheRoot, "3.0.0", runtime.GOOS, runtime.GOARCH) { + t.Fatalf("cached path mismatch: %s", realCLIPath) + } + assertFileContent(t, realCLIPath, "runner") +} + +func TestResolveDispatcherRealCLIReportsMissingCacheRoot(t *testing.T) { + // Verifies an unresolvable cache root is reported instead of downloading into an arbitrary directory. + unsetDispatcherCacheRoot(t) + t.Setenv(nativepath.ProjectRunnerPathEnvName, "") + // A regression must not download into the working directory. + t.Chdir(t.TempDir()) + forbidDispatcherHTTPRequests(t) + + _, err := resolveDispatcherRealCLI(context.Background(), dispatcherPin{ProjectRunnerVersion: "3.0.0"}, io.Discard) + + if err == nil || !strings.Contains(err.Error(), "$HOME is not defined") { + t.Fatalf("expected a cache root resolution error, got %v", err) + } +} + +func writeDispatcherTarGzArchiveWithDirectory(t *testing.T, archivePath string, directoryName string, entries []dispatcherArchiveTestEntry) { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + if err := tarWriter.WriteHeader(&tar.Header{Name: directoryName, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatalf("failed to write tar directory header: %v", err) + } + for _, entry := range entries { + content := []byte(entry.Content) + if err := tarWriter.WriteHeader(&tar.Header{Name: entry.Name, Mode: 0o755, Size: int64(len(content))}); err != nil { + t.Fatalf("failed to write tar header: %v", err) + } + if _, err := tarWriter.Write(content); err != nil { + t.Fatalf("failed to write tar content: %v", err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatalf("failed to close tar writer: %v", err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatalf("failed to close gzip writer: %v", err) + } + if err := os.WriteFile(archivePath, buffer.Bytes(), 0o644); err != nil { + t.Fatalf("failed to write tar archive: %v", err) + } +} + +// forbidDispatcherHTTPRequests fails the test on any HTTP request, for paths that must stop before downloading. +func forbidDispatcherHTTPRequests(t *testing.T) { + t.Helper() + previousHTTPClient := dispatcherHTTPClient + t.Cleanup(func() { + dispatcherHTTPClient = previousHTTPClient + }) + dispatcherHTTPClient = &http.Client{ + Transport: dispatcherRoundTripFunc(func(request *http.Request) (*http.Response, error) { + t.Errorf("unexpected HTTP request: %s", request.URL) + return nil, errors.New("HTTP requests are forbidden in this test") + }), + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_freshness_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_freshness_test.go new file mode 100644 index 0000000000..db28a96f10 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_freshness_test.go @@ -0,0 +1,197 @@ +package dispatcher + +import ( + "bytes" + "context" + "errors" + "io" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/update" +) + +func unsetDispatcherCacheRoot(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Windows resolves the cache root from LOCALAPPDATA, not HOME.") + } + t.Setenv(nativepath.CacheDirEnvName, "") + t.Setenv("XDG_CACHE_HOME", "") + t.Setenv("HOME", "") + if _, err := dispatcherCacheRoot(runtime.GOOS); err == nil { + t.Fatal("precondition failed: the cache root still resolves, so the test could write to the real cache") + } +} + +func TestDetectManagedDispatcherInstallIgnoresExecutablePathFailure(t *testing.T) { + // Verifies an executable path lookup failure is treated as an unmanaged install so ordinary commands keep running. + previous := resolveUpdateExecutablePathFunc + t.Cleanup(func() { + resolveUpdateExecutablePathFunc = previous + }) + // The path would be detected as Homebrew-managed if the error were ignored. + resolveUpdateExecutablePathFunc = func() (string, error) { + return "/opt/homebrew/Cellar/uloop/1.0.0/bin/uloop", errors.New("executable path unavailable") + } + + if detectManagedDispatcherInstall().IsManaged() { + t.Fatal("a failed executable lookup must not report a managed install") + } +} + +func TestExecuteDispatcherFreshnessPlanRejectsUnknownAction(t *testing.T) { + // Verifies an unknown freshness action fails fast with a routing-bug envelope instead of running the command. + var stderr bytes.Buffer + + handled, code := executeDispatcherFreshnessPlan(context.Background(), dispatcherFreshnessPlan{Action: "unexpected"}, &stderr, defaultDispatcherRunDeps()) + + if !handled || code != 1 { + t.Fatalf("result mismatch: handled=%t code=%d", handled, code) + } + if !strings.Contains(stderr.String(), "Dispatcher freshness routing bug: unknown action: unexpected") { + t.Fatalf("missing routing bug message: %s", stderr.String()) + } +} + +func TestRunDispatcherFreshnessUpdateTurnsFailedRequiredUpdateIntoManualUpdate(t *testing.T) { + // Verifies a failed required update stops the command and tells the user to run `uloop update` with the cause. + t.Setenv(nativepath.CacheDirEnvName, t.TempDir()) + deps := defaultDispatcherRunDeps() + deps.runUpdate = func(context.Context) (bool, error) { + return false, errors.New("network unavailable") + } + + var stderr bytes.Buffer + handled, code := runDispatcherFreshnessUpdate( + context.Background(), + dispatcherFreshnessPlan{Action: dispatcherFreshnessRunRequiredUpdate, MinimumVersion: "999.0.0"}, + &stderr, + deps) + + if !handled || code != 1 { + t.Fatalf("result mismatch: handled=%t code=%d", handled, code) + } + for _, expected := range []string{"999.0.0", "Automatic update failed: network unavailable", "Run `uloop update` and retry the command."} { + if !strings.Contains(stderr.String(), expected) { + t.Fatalf("missing %q in envelope: %s", expected, stderr.String()) + } + } +} + +func TestDispatcherSelfUpdateDueWithDeps(t *testing.T) { + // Verifies the optional update check is due when the state file is missing or corrupt and not due right after a check. + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + deps := defaultDispatcherRunDeps() + deps.now = func() time.Time { return now } + + cases := []struct { + name string + content string + wantDue bool + }{ + {name: "missing state", wantDue: true}, + {name: "corrupt state", content: "{", wantDue: true}, + {name: "recent check", content: `{"lastChecked":"` + now.Add(-time.Hour).Format(time.RFC3339) + `"}`, wantDue: false}, + {name: "stale check", content: `{"lastChecked":"` + now.Add(-25*time.Hour).Format(time.RFC3339) + `"}`, wantDue: true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + if testCase.content != "" { + writeDispatcherTestFile(t, filepath.Join(cacheRoot, dispatcherUpdateStateFileName), testCase.content) + } + + if due := dispatcherSelfUpdateDueWithDeps(deps); due != testCase.wantDue { + t.Fatalf("due mismatch: got %t want %t", due, testCase.wantDue) + } + }) + } +} + +func TestDispatcherSelfUpdateIsNotDueWithoutCacheRoot(t *testing.T) { + // Verifies an unresolvable cache root skips the optional update check instead of updating on every command. + unsetDispatcherCacheRoot(t) + + if dispatcherSelfUpdateDueWithDeps(defaultDispatcherRunDeps()) { + t.Fatal("update must not be due when the cache root cannot be resolved") + } +} + +func TestMarkDispatcherSelfUpdateCheckedWritesState(t *testing.T) { + // Verifies a completed check records the current time so the next check is throttled. + cacheRoot := filepath.Join(t.TempDir(), "nested", "cache") + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + deps := defaultDispatcherRunDeps() + deps.now = func() time.Time { return now } + + markDispatcherSelfUpdateCheckedWithDeps(deps) + + assertFileContent(t, filepath.Join(cacheRoot, dispatcherUpdateStateFileName), `{"lastChecked":"2026-01-02T03:04:05Z"}`) + if dispatcherSelfUpdateDueWithDeps(deps) { + t.Fatal("update must not be due right after it was marked checked") + } +} + +func TestRunDispatcherUpdateCommandForOSReportsFailures(t *testing.T) { + // Verifies target resolution, unsupported platform, and installer failures are returned without reporting an update. + cases := []struct { + name string + goos string + resolveErr error + runErr error + wantMessage string + }{ + {name: "resolution failure", goos: "linux", resolveErr: errors.New("release lookup failed"), wantMessage: "release lookup failed"}, + {name: "unsupported platform", goos: "plan9", wantMessage: "only supported on macOS, Linux, and Windows"}, + {name: "installer failure", goos: "linux", runErr: errors.New("installer exited 1"), wantMessage: "installer exited 1"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + stubDispatcherUpdateRun(t, testCase.resolveErr, testCase.runErr) + + updated, err := runDispatcherUpdateCommandForOS(context.Background(), testCase.goos) + + if updated { + t.Fatal("a failed update must not report an installed update") + } + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got %v", testCase.wantMessage, err) + } + }) + } +} + +func stubDispatcherUpdateRun(t *testing.T, resolveErr error, runErr error) { + t.Helper() + previousResolver := resolveUpdateTargetVersionFunc + previousRunner := updateRunCommand + t.Cleanup(func() { + resolveUpdateTargetVersionFunc = previousResolver + updateRunCommand = previousRunner + }) + resolveUpdateTargetVersionFunc = func(_ context.Context, options update.Options) (update.Options, error) { + options.TargetVersion = "999.0.0" + return options, resolveErr + } + updateRunCommand = func(context.Context, update.Command, io.Writer, io.Writer) error { + return runErr + } +} + +func TestRunDispatcherUpdateCommandForOSReportsInstalledUpdate(t *testing.T) { + // Verifies a newer resolved target runs the installer and reports that an update was installed. + stubDispatcherUpdateRun(t, nil, nil) + + updated, err := runDispatcherUpdateCommandForOS(context.Background(), "linux") + + if err != nil || !updated { + t.Fatalf("expected an installed update, got updated=%t err=%v", updated, err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_update_version_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_update_version_test.go index ccda34bab2..25bb0c64f4 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_update_version_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_update_version_test.go @@ -2,6 +2,8 @@ package dispatcher import ( "bytes" + "context" + "errors" "testing" ) @@ -23,3 +25,40 @@ func TestWriteOptionalDispatcherUpdateCompletionReportsNormalizedVersions(t *tes t.Fatalf("update output mismatch: %s", stderr.String()) } } + +func TestDispatcherInstalledVersionOrEmptyHidesReadFailure(t *testing.T) { + // Verifies a failed installed-version read yields an empty version instead of an error. + previous := dispatcherReadInstalledVersion + t.Cleanup(func() { + dispatcherReadInstalledVersion = previous + }) + // The version would leak through if the error were ignored. + dispatcherReadInstalledVersion = func(context.Context) (string, error) { + return "9.9.9", errors.New("version probe failed") + } + + if version := dispatcherInstalledVersionOrEmpty(context.Background()); version != "" { + t.Fatalf("expected an empty version, got %q", version) + } +} + +func TestWriteManualDispatcherUpdateCompletion(t *testing.T) { + // Verifies the manual update summary for an unknown, unchanged, and changed installed version. + cases := []struct { + toVersion string + want string + }{ + {toVersion: "", want: "uloop dispatcher update completed.\n"}, + {toVersion: "v3.0.0", want: "uloop dispatcher is already up to date at 3.0.0.\n"}, + {toVersion: "3.1.0", want: "uloop dispatcher updated from 3.0.0 to 3.1.0.\n"}, + } + for _, testCase := range cases { + var stdout bytes.Buffer + + writeManualDispatcherUpdateCompletion(&stdout, "3.0.0", testCase.toVersion) + + if stdout.String() != testCase.want { + t.Fatalf("toVersion %q: got %q want %q", testCase.toVersion, stdout.String(), testCase.want) + } + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go index 5a552b5eb0..a0adb5563b 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_delegate_test.go @@ -1,8 +1,12 @@ package dispatcher import ( + "encoding/json" + "errors" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -57,3 +61,75 @@ func writeDispatcherV2PackageBin(t *testing.T, installPath string, bin string) { t.Fatalf("write V2 package.json: %v", err) } } + +func TestResolveDispatcherV2CLIEntrypointRejectsInvalidPackages(t *testing.T) { + // Verifies a missing, unparsable, or unusable V2 package.json is reported instead of executing an arbitrary file. + cases := []struct { + name string + bin string + rawContent string + wantMessage string + wantNotExist bool + }{ + {name: "missing package", wantNotExist: true}, + {name: "unparsable package", rawContent: "{", wantMessage: "parse "}, + {name: "bin is neither string nor object", bin: "5", wantMessage: "package bin must be a string or object"}, + {name: "bin object without uloop", bin: `{"other":"cli.js"}`, wantMessage: "package bin does not define uloop"}, + {name: "bin object with empty uloop", bin: `{"uloop":""}`, wantMessage: "package bin does not define uloop"}, + {name: "absolute entrypoint", bin: dispatcherTestJSONString(t, filepath.Join(t.TempDir(), "cli.js")), wantMessage: "bin entrypoint must be relative"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + installPath := t.TempDir() + if testCase.bin != "" { + writeDispatcherV2PackageBin(t, installPath, testCase.bin) + } + if testCase.rawContent != "" { + writeDispatcherTestFile(t, filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, dispatcherPackageJSONFileName), testCase.rawContent) + } + + entrypoint, err := resolveDispatcherV2CLIEntrypoint(installPath) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) || testCase.wantNotExist && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected error containing %q (not-exist=%t), got entrypoint=%q err=%v", testCase.wantMessage, testCase.wantNotExist, entrypoint, err) + } + }) + } +} + +func TestDefaultDispatcherV2NodePathFindsNodeOnPath(t *testing.T) { + // Verifies the default Node lookup resolves `node` from PATH. + nodePath := writeDispatcherFakeNode(t, "exit 0") + + resolved, err := defaultDispatcherV2NodePath() + if err != nil { + t.Fatalf("defaultDispatcherV2NodePath failed: %v", err) + } + if resolved != nodePath { + t.Fatalf("node path mismatch: got %s want %s", resolved, nodePath) + } +} + +// writeDispatcherFakeNode installs a POSIX shell script named node as the only PATH entry. +func writeDispatcherFakeNode(t *testing.T, body string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell scripts are not executable on Windows.") + } + binDirectory := t.TempDir() + nodePath := filepath.Join(binDirectory, dispatcherNodeCommandName) + if err := os.WriteFile(nodePath, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatalf("failed to write fake node: %v", err) + } + t.Setenv("PATH", binDirectory) + return nodePath +} + +func dispatcherTestJSONString(t *testing.T, value string) string { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to encode %q: %v", value, err) + } + return string(encoded) +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 8edb4e580d..1d0199270f 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" @@ -839,3 +840,228 @@ func assertPackageLockSourceKeepsPinnedRunner(t *testing.T, lockVersion string, t.Fatalf("pinned runner was not used: code=%d forwarded=%v", code, forwarded) } } + +func TestDetectV2DispatcherProjectReportsUnreadableManifests(t *testing.T) { + // Verifies a manifest or packages-lock that cannot be read or parsed is reported instead of guessing the package generation. + cases := []struct { + name string + setup func(t *testing.T, projectRoot string) + want string + wantPath string + }{ + { + name: "manifest is a directory", + setup: func(t *testing.T, projectRoot string) { + mkdirDispatcherTestDirectory(t, filepath.Join(projectRoot, "Packages", "manifest.json")) + }, + want: "read ", wantPath: "manifest.json", + }, + { + name: "manifest is not JSON", + setup: func(t *testing.T, projectRoot string) { + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Packages", "manifest.json"), "{") + }, + want: "manifest.json: unexpected end of JSON input", + }, + { + name: "lock is a directory", + setup: func(t *testing.T, projectRoot string) { + writeV2PackageManifest(t, projectRoot) + mkdirDispatcherTestDirectory(t, filepath.Join(projectRoot, "Packages", "packages-lock.json")) + }, + want: "read ", wantPath: "packages-lock.json", + }, + { + name: "lock is not JSON", + setup: func(t *testing.T, projectRoot string) { + writeV2PackageManifest(t, projectRoot) + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Packages", "packages-lock.json"), "{") + }, + want: "packages-lock.json: unexpected end of JSON input", + }, + { + name: "package cache is a file", + setup: func(t *testing.T, projectRoot string) { + skipDispatcherTestOnWindows(t, "Windows reads a file passed to ReadDir as a missing directory.") + writeV2PackageManifest(t, projectRoot) + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Library", "PackageCache"), "not a directory") + }, + want: "PackageCache: not a directory", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := createDispatcherUnityProject(t) + testCase.setup(t, projectRoot) + + project, err := detectV2DispatcherProject(projectRoot) + + if err == nil || !strings.Contains(err.Error(), testCase.want) || !strings.Contains(err.Error(), testCase.wantPath) { + t.Fatalf("expected error containing %q and %q, got project=%+v err=%v", testCase.want, testCase.wantPath, project, err) + } + }) + } +} + +func mkdirDispatcherTestDirectory(t *testing.T, directory string) { + t.Helper() + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", directory, err) + } +} + +func TestDetectV2DispatcherProjectSkipsV3PackageMatchedByGitHash(t *testing.T) { + // Verifies a git lock hash that selects a V3 cache generation stops detection even when a V2 generation is also cached. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writePackagesLockWithGitHash(t, projectRoot, "bbbbbb1234") + writeV2PackageCachePackageJSON(t, projectRoot, "aaaaaa", "2.2.0") + writeV2PackageCachePackageJSON(t, projectRoot, "bbbbbb", "3.0.0") + + project, err := detectV2DispatcherProject(projectRoot) + + if err != nil || project.IsV2 { + t.Fatalf("expected a non-V2 result, got project=%+v err=%v", project, err) + } +} + +func TestDetectV2DispatcherProjectFromGitDependencyCacheVersions(t *testing.T) { + // Verifies git manifest dependencies without a lock fall back to PackageCache and only report V2 for V2-only caches. + cases := []struct { + name string + cacheVersions map[string]string + wantV2 bool + wantError string + }{ + {name: "no cache", cacheVersions: nil}, + {name: "single V3 cache", cacheVersions: map[string]string{"aaaaaa": "3.0.0"}}, + {name: "mixed generations", cacheVersions: map[string]string{"aaaaaa": "2.2.0", "bbbbbb": "3.0.0"}, wantError: "multiple package generations found"}, + {name: "invalid entries are ignored", cacheVersions: map[string]string{"aaaaaa": "2.2.0", "bbbbbb": "not-a-version"}, wantV2: true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + for suffix, version := range testCase.cacheVersions { + writeV2PackageCachePackageJSON(t, projectRoot, suffix, version) + } + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Library", "PackageCache", "README.md"), "not a package") + mkdirDispatcherTestDirectory(t, filepath.Join(projectRoot, "Library", "PackageCache", "com.example.other@1.0.0")) + + project, err := detectV2DispatcherProject(projectRoot) + + if testCase.wantError != "" { + if err == nil || !strings.Contains(err.Error(), testCase.wantError) { + t.Fatalf("expected error containing %q, got %v", testCase.wantError, err) + } + return + } + if err != nil || project.IsV2 != testCase.wantV2 { + t.Fatalf("unexpected result: project=%+v err=%v", project, err) + } + }) + } +} + +func TestDetectV2DispatcherProjectSkipsLockWithoutUnityPackage(t *testing.T) { + // Verifies a packages-lock without the Unity package entry is treated as unresolved and falls back to the git cache. + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Packages", "packages-lock.json"), `{"dependencies":{"com.example.other":{"version":"1.0.0"}}}`) + writeV2PackageCachePackageJSON(t, projectRoot, "aaaaaa", "2.2.0") + + project, err := detectV2DispatcherProject(projectRoot) + + if err != nil || !project.IsV2 || project.PackageVersion != "2.2.0" { + t.Fatalf("expected V2 from the cache, got project=%+v err=%v", project, err) + } +} + +func TestDetectV2DispatcherProjectIgnoresNonStringManifestDependency(t *testing.T) { + // Verifies a manifest dependency that is not a string is neither a file: nor a git dependency. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Packages", "manifest.json"), `{"dependencies":{"`+dispatcherUnityPackageName+`":{"version":"2.2.0"}}}`) + writeV2PackageCachePackageJSON(t, projectRoot, "aaaaaa", "2.2.0") + + project, err := detectV2DispatcherProject(projectRoot) + + if err != nil || project.IsV2 { + t.Fatalf("expected a non-V2 result, got project=%+v err=%v", project, err) + } +} + +func TestDetectV2DispatcherFileDependencyProjectIgnoresUnresolvableValues(t *testing.T) { + // Verifies non-string and empty file: dependency values resolve to no project rather than an error. + projectRoot := createDispatcherUnityProject(t) + for _, dependency := range []string{`{"path":"x"}`, `"file:"`} { + project, err := detectV2DispatcherFileDependencyProject(projectRoot, json.RawMessage(dependency)) + if err != nil || project.IsV2 { + t.Fatalf("dependency %s: unexpected result project=%+v err=%v", dependency, project, err) + } + } +} + +func TestResolveDispatcherFileDependencyTarget(t *testing.T) { + // Verifies file: values resolve absolute paths as-is, relative paths against Packages/, and reject other values. + projectRoot := createDispatcherUnityProject(t) + absoluteTarget := filepath.Join(t.TempDir(), "package") + cases := []struct { + value string + want string + wantOK bool + }{ + {value: "https://example.invalid/package.git"}, + {value: "file:"}, + {value: "file:" + absoluteTarget + string(filepath.Separator), want: absoluteTarget, wantOK: true}, + {value: " file:../LocalPackage ", want: filepath.Join(projectRoot, "LocalPackage"), wantOK: true}, + } + for _, testCase := range cases { + target, ok := resolveDispatcherFileDependencyTarget(projectRoot, testCase.value) + if ok != testCase.wantOK || target != testCase.want { + t.Fatalf("value %q: got (%q, %v) want (%q, %v)", testCase.value, target, ok, testCase.want, testCase.wantOK) + } + } +} + +func TestDetectV2DispatcherEmbeddedProjectReportsUnreadablePackagesDirectory(t *testing.T) { + // Verifies a Packages path that exists but is not a directory is reported as an error. + skipDispatcherTestOnWindows(t, "Windows reads a file passed to ReadDir as a missing directory.") + projectRoot := t.TempDir() + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Packages"), "not a directory") + + if _, err := detectV2DispatcherEmbeddedProject(projectRoot); err == nil || !strings.Contains(err.Error(), "Packages: not a directory") { + t.Fatalf("expected an error for an unreadable Packages directory, got %v", err) + } +} + +func TestDetectV2DispatcherProjectSkipsEmbeddedFilesAndInvalidVersions(t *testing.T) { + // Verifies loose files in Packages/ and embedded packages with an invalid version are not treated as V2. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Packages", "README.md"), "docs") + writeDispatcherPackageJSONFile(t, filepath.Join(projectRoot, "Packages", "embedded", "package.json"), dispatcherUnityPackageName, "not-a-version") + + project, err := detectV2DispatcherProject(projectRoot) + + if err != nil || project.IsV2 { + t.Fatalf("expected a non-V2 result, got project=%+v err=%v", project, err) + } +} + +func TestIsDispatcherGitPackageDependencyRejectsNonGitValues(t *testing.T) { + // Verifies non-string, unparsable, and non-git URL dependencies are not treated as git packages. + for _, dependency := range []string{`{"url":"x"}`, `"http://[::1"`, `"https://example.invalid/package.tgz"`, `"git@example.invalid:owner/repo"`} { + if isDispatcherGitPackageDependency(json.RawMessage(dependency)) { + t.Fatalf("dependency %s must not be a git dependency", dependency) + } + } + if !isDispatcherGitPackageDependency(json.RawMessage(`"git@example.invalid:owner/repo.git#v2.0.0"`)) { + t.Fatal("scp-style git dependency must be accepted") + } +} + +func skipDispatcherTestOnWindows(t *testing.T, reason string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip(reason) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go index 0e10852a24..3dffdf99e9 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_install_test.go @@ -1,10 +1,14 @@ package dispatcher import ( + "bytes" "context" + "errors" "io" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -84,3 +88,148 @@ func writeInstalledDispatcherV2Package(t *testing.T, installPath string, version t.Fatalf("write installed package: %v", err) } } + +func TestInstallDispatcherV2CLIReportsInstallFailures(t *testing.T) { + // Verifies a failing npm run or an npm run that installs nothing is reported and leaves no cached install. + cases := []struct { + name string + runCommand func(context.Context, string, []string, io.Writer) error + wantMessage string + }{ + { + name: "npm fails", + runCommand: func(context.Context, string, []string, io.Writer) error { + return errors.New("npm exited 1") + }, + wantMessage: "npm exited 1", + }, + { + name: "npm installs another version", + runCommand: func(_ context.Context, _ string, args []string, _ io.Writer) error { + writeInstalledDispatcherV2Package(t, args[2], "2.1.0") + return nil + }, + wantMessage: "npm did not install uloop-cli@2.2.0", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + cacheRoot := t.TempDir() + + _, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, dispatcherV2InstallDeps{runCommand: testCase.runCommand}) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got %v", testCase.wantMessage, err) + } + if fileExists(dispatcherV2InstallPath(cacheRoot, "2.2.0")) { + t.Fatal("a failed install must not leave the versioned cache directory") + } + }) + } +} + +func TestInstallDispatcherV2CLIReportsUnusableCacheRoot(t *testing.T) { + // Verifies a cache root that is a regular file fails before npm runs. + cacheRoot := filepath.Join(t.TempDir(), "cache-file") + writeDispatcherTestFile(t, cacheRoot, "not a directory") + deps := dispatcherV2InstallDeps{ + runCommand: func(context.Context, string, []string, io.Writer) error { + t.Fatal("npm must not run when the cache directory cannot be created") + return nil + }, + } + + _, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, deps) + if err == nil || !strings.Contains(err.Error(), "mkdir "+cacheRoot) { + t.Fatalf("expected a cache directory error, got %v", err) + } +} + +func TestInstallDispatcherV2CLIReusesConcurrentlyInstalledVersion(t *testing.T) { + // Verifies an install that loses the rename race to a concurrent matching install reuses that install. + cacheRoot := t.TempDir() + installPath := dispatcherV2InstallPath(cacheRoot, "2.2.0") + deps := dispatcherV2InstallDeps{ + runCommand: func(_ context.Context, _ string, args []string, _ io.Writer) error { + writeInstalledDispatcherV2Package(t, args[2], "2.2.0") + writeInstalledDispatcherV2Package(t, installPath, "2.2.0") + writeDispatcherTestFile(t, filepath.Join(installPath, "marker"), "concurrent") + return nil + }, + } + + actualPath, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, deps) + + if err != nil || actualPath != installPath { + t.Fatalf("unexpected result: path=%q err=%v", actualPath, err) + } + assertFileContent(t, filepath.Join(installPath, "marker"), "concurrent") +} + +func TestInstallDispatcherV2CLIReplacesBrokenExistingInstall(t *testing.T) { + // Verifies a stale install directory without the requested version is replaced by the fresh npm install. + cacheRoot := t.TempDir() + installPath := dispatcherV2InstallPath(cacheRoot, "2.2.0") + writeInstalledDispatcherV2Package(t, installPath, "2.1.0") + deps := dispatcherV2InstallDeps{ + runCommand: func(_ context.Context, _ string, args []string, _ io.Writer) error { + writeInstalledDispatcherV2Package(t, args[2], "2.2.0") + return nil + }, + } + + actualPath, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, deps) + + if err != nil || actualPath != installPath { + t.Fatalf("unexpected result: path=%q err=%v", actualPath, err) + } + if !isInstalledDispatcherV2CLI(installPath, "2.2.0") { + t.Fatal("the stale install must be replaced by the requested version") + } +} + +func TestInstallDispatcherV2CLIReportsUnremovableBrokenInstall(t *testing.T) { + // Verifies a stale install directory that cannot be removed aborts the install instead of mixing generations. + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permissions are required to make removal fail.") + } + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions.") + } + cacheRoot := t.TempDir() + installPath := dispatcherV2InstallPath(cacheRoot, "2.2.0") + lockedDirectory := filepath.Join(installPath, "locked") + writeDispatcherTestFile(t, filepath.Join(lockedDirectory, "file"), "x") + if err := os.Chmod(lockedDirectory, 0o555); err != nil { + t.Fatalf("failed to lock directory: %v", err) + } + t.Cleanup(func() { + _ = os.Chmod(lockedDirectory, 0o755) + }) + deps := dispatcherV2InstallDeps{ + runCommand: func(_ context.Context, _ string, args []string, _ io.Writer) error { + writeInstalledDispatcherV2Package(t, args[2], "2.2.0") + return nil + }, + } + + _, err := installDispatcherV2CLI(context.Background(), cacheRoot, "2.2.0", "darwin", io.Discard, deps) + if err == nil || !strings.Contains(err.Error(), "unlinkat "+filepath.Join(lockedDirectory, "file")) { + t.Fatalf("expected the stale install removal failure, got %v", err) + } + assertFileContent(t, filepath.Join(lockedDirectory, "file"), "x") +} + +func TestRunDispatcherV2InstallCommandSendsAllOutputToStderr(t *testing.T) { + // Verifies npm stdout and stderr both go to the dispatcher stderr so the delegated command's stdout stays clean. + scriptPath := writeDispatcherTestScript(t, `echo "out:$*"; echo "err" >&2`) + + var stderr bytes.Buffer + err := runDispatcherV2InstallCommand(context.Background(), scriptPath, []string{"install", "pkg"}, &stderr) + if err != nil { + t.Fatalf("runDispatcherV2InstallCommand failed: %v", err) + } + if stderr.String() != "out:install pkg\nerr\n" { + t.Fatalf("stderr mismatch: %q", stderr.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run_test.go index 0a11ebe518..e3248760bf 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run_test.go @@ -1,6 +1,17 @@ package dispatcher -import "testing" +import ( + "bytes" + "context" + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" +) func TestDispatcherV2ModeNoticeReportsDelegatedPackageAndVersion(t *testing.T) { // Verifies the notice names the delegated V2 CLI package, its version, and the dispatcher version behind it. @@ -11,3 +22,179 @@ func TestDispatcherV2ModeNoticeReportsDelegatedPackageAndVersion(t *testing.T) { t.Fatalf("notice = %q, want %q", notice, want) } } + +// writeCachedDispatcherV2CLI places an installed V2 CLI package in the cache so no npm run is needed. +func writeCachedDispatcherV2CLI(t *testing.T, cacheRoot string, version string, bin string) string { + t.Helper() + installPath := dispatcherV2InstallPath(cacheRoot, version) + content := `{"version":"` + version + `"` + if bin != "" { + content += `,"bin":` + bin + } + writeDispatcherTestFile(t, filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, dispatcherPackageJSONFileName), content+"}") + return installPath +} + +func TestTryRunDetectedDispatcherV2ProjectKeepsLaunchNative(t *testing.T) { + // Verifies launch is never delegated to the V2 CLI, even inside a V2 project. + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("launch must not be delegated to the V2 CLI") + return 0, nil + } + + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + if project, err := detectV2DispatcherProject(projectRoot); err != nil || !project.IsV2 { + t.Fatalf("precondition failed: fixture must be a V2 project, got project=%+v err=%v", project, err) + } + + handled, code := tryRunDetectedDispatcherV2Project(context.Background(), projectRoot, []string{clicore.LaunchCommandName}, io.Discard, io.Discard, deps) + + if handled || code != 0 { + t.Fatalf("result mismatch: handled=%t code=%d", handled, code) + } +} + +func TestRunDispatcherV2CLIExecutesCachedEntrypointWithNode(t *testing.T) { + // Verifies the cached V2 CLI entrypoint runs through node with the original args, after the V2 mode notice. + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + installPath := writeCachedDispatcherV2CLI(t, cacheRoot, "2.2.0", `{"uloop":"dist/cli.js"}`) + writeDispatcherFakeNode(t, `echo "node:$*"; exit 4`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code, err := runDispatcherV2CLI(context.Background(), "2.2.0", []string{"compile", "--force-recompile"}, &stdout, &stderr) + if err != nil { + t.Fatalf("runDispatcherV2CLI failed: %v", err) + } + if code != 4 { + t.Fatalf("exit code mismatch: %d", code) + } + entrypoint := filepath.Join(installPath, "node_modules", dispatcherV2CLIPackageName, "dist", "cli.js") + if stdout.String() != "node:"+entrypoint+" compile --force-recompile\n" { + t.Fatalf("stdout mismatch: %q", stdout.String()) + } + if stderr.String() != dispatcherV2ModeNotice("2.2.0") { + t.Fatalf("stderr mismatch: %q", stderr.String()) + } +} + +func TestRunDispatcherV2CLIReturnsZeroOnSuccess(t *testing.T) { + // Verifies a successful V2 CLI run reports exit code 0 without an error. + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + writeCachedDispatcherV2CLI(t, cacheRoot, "2.2.0", `"cli.js"`) + writeDispatcherFakeNode(t, "exit 0") + + code, err := runDispatcherV2CLI(context.Background(), "2.2.0", nil, io.Discard, io.Discard) + + if err != nil || code != 0 { + t.Fatalf("result mismatch: code=%d err=%v", code, err) + } +} + +func TestRunDispatcherV2CLIReportsNodeStartFailure(t *testing.T) { + // Verifies a node executable that cannot start is returned as an error rather than an exit code. + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + writeCachedDispatcherV2CLI(t, cacheRoot, "2.2.0", `"cli.js"`) + nodePath := writeDispatcherFakeNode(t, "exit 0") + writeDispatcherExecutable(t, nodePath, "#!/nonexistent-interpreter\n") + + _, err := runDispatcherV2CLI(context.Background(), "2.2.0", nil, io.Discard, io.Discard) + + if err == nil || !strings.Contains(err.Error(), "fork/exec "+nodePath) { + t.Fatalf("expected a node start failure, got %v", err) + } +} + +func TestRunDispatcherV2CLIReportsSetupFailures(t *testing.T) { + // Verifies install, entrypoint, and Node lookup failures stop before anything is executed. + cases := []struct { + name string + setup func(t *testing.T, cacheRoot string) + wantMessage string + }{ + { + name: "cache directory cannot be created", + setup: func(t *testing.T, cacheRoot string) { + writeDispatcherTestFile(t, filepath.Join(cacheRoot, dispatcherV2CacheDirectoryName), "not a directory") + writeDispatcherFakeNode(t, "exit 0") + }, + wantMessage: "not a directory", + }, + { + name: "package declares no entrypoint", + setup: func(t *testing.T, cacheRoot string) { + writeCachedDispatcherV2CLI(t, cacheRoot, "2.2.0", "") + writeDispatcherFakeNode(t, "exit 0") + }, + wantMessage: "package bin must be a string or object", + }, + { + name: "node is missing", + setup: func(t *testing.T, cacheRoot string) { + writeCachedDispatcherV2CLI(t, cacheRoot, "2.2.0", `"cli.js"`) + t.Setenv("PATH", t.TempDir()) + }, + wantMessage: "executable file not found", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + testCase.setup(t, cacheRoot) + + var stderr bytes.Buffer + _, err := runDispatcherV2CLI(context.Background(), "2.2.0", nil, io.Discard, &stderr) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got %v", testCase.wantMessage, err) + } + if stderr.Len() != 0 { + t.Fatalf("no V2 mode notice may be written before setup succeeds: %q", stderr.String()) + } + }) + } +} + +func TestRunDispatcherV2CLIReportsMissingCacheRoot(t *testing.T) { + // Verifies an unresolvable cache root fails before installing the V2 CLI. + unsetDispatcherCacheRoot(t) + // A regression must not install into the working directory or reach a real npm. + t.Chdir(t.TempDir()) + t.Setenv("PATH", t.TempDir()) + + _, err := runDispatcherV2CLI(context.Background(), "2.2.0", nil, io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "$HOME is not defined") { + t.Fatalf("expected a cache root resolution error, got %v", err) + } +} + +type failingDispatcherWriter struct{} + +func (failingDispatcherWriter) Write([]byte) (int, error) { + return 0, errors.New("stderr closed") +} + +func TestRunDispatcherV2CLIReportsNoticeWriteFailure(t *testing.T) { + // Verifies a V2 run stops when the delegation notice cannot be written, instead of running the CLI silently. + cacheRoot := t.TempDir() + t.Setenv(nativepath.CacheDirEnvName, cacheRoot) + writeCachedDispatcherV2CLI(t, cacheRoot, "2.2.0", `"cli.js"`) + writeDispatcherFakeNode(t, `echo ran`) + + var stdout bytes.Buffer + _, err := runDispatcherV2CLI(context.Background(), "2.2.0", nil, &stdout, failingDispatcherWriter{}) + + if err == nil || !strings.Contains(err.Error(), "stderr closed") { + t.Fatalf("expected the notice write error, got %v", err) + } + if stdout.Len() != 0 { + t.Fatalf("the V2 CLI must not run after the notice failed: %q", stdout.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/install_test.go b/cli/dispatcher/internal/dispatcher/install_test.go index a2e9e20cbd..3388d33a69 100644 --- a/cli/dispatcher/internal/dispatcher/install_test.go +++ b/cli/dispatcher/internal/dispatcher/install_test.go @@ -3,6 +3,7 @@ package dispatcher import ( "bytes" "context" + "runtime" "strings" "testing" @@ -125,3 +126,117 @@ func TestInstallSetupFailureErrorIncludesInstallerStderr(t *testing.T) { t.Fatalf("installer stderr detail mismatch: %#v", cliErr.Details) } } + +func TestParseInstallOptionsReadsDirFlagForms(t *testing.T) { + // Verifies --dir accepts both the separate-value and equals forms. + for _, args := range [][]string{{"--dir", "/opt/uloop"}, {"--dir=/opt/uloop"}} { + options, err := parseInstallOptions(args) + if err != nil { + t.Fatalf("args %v: parseInstallOptions failed: %v", args, err) + } + if options.installDir != "/opt/uloop" { + t.Fatalf("args %v: install dir mismatch: %s", args, options.installDir) + } + } +} + +func TestParseInstallOptionsRejectsInvalidArguments(t *testing.T) { + // Verifies unknown, duplicated, valueless, and positional install arguments are rejected with an argument error. + cases := []struct { + name string + args []string + wantMessage string + }{ + {name: "unknown option", args: []string{"--prefix", "/opt"}, wantMessage: "Unknown install option: --prefix"}, + {name: "duplicate short flag", args: []string{"-d", "/a", "-d", "/b"}, wantMessage: "Duplicate install option: -d"}, + {name: "short flag after long flag", args: []string{"--dir", "/a", "-d", "/b"}, wantMessage: "Duplicate install option: -d"}, + {name: "long flag after short flag", args: []string{"-d", "/a", "--dir=/b"}, wantMessage: "Duplicate install option: --dir"}, + {name: "short flag without value", args: []string{"-d"}, wantMessage: "-d requires a value"}, + {name: "short flag followed by option", args: []string{"-d", "--dir"}, wantMessage: "-d requires a value"}, + {name: "positional argument", args: []string{"/opt/uloop"}, wantMessage: "/opt/uloop"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + options, err := parseInstallOptions(testCase.args) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got options=%+v err=%v", testCase.wantMessage, options, err) + } + }) + } +} + +func TestTryHandleInstallRequestReportsInvalidOptions(t *testing.T) { + // Verifies an invalid install option exits with code 1 before any installer step runs. + unsetNativeInstallLocation(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleInstallRequest(context.Background(), []string{"install", "--prefix", "/opt"}, &stdout, &stderr) + + if !handled || code != 1 { + t.Fatalf("result mismatch: handled=%t code=%d", handled, code) + } + if !strings.Contains(stderr.String(), "Unknown install option: --prefix") { + t.Fatalf("missing option error: %s", stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("no setup progress may be printed for invalid options: %s", stdout.String()) + } +} + +func TestResolveNativeInstallDirRejectsUnsupportedOS(t *testing.T) { + // Verifies platforms without an install convention report the install-specific unsupported message. + t.Setenv(nativepath.InstallDirEnvName, "") + + _, err := resolveNativeInstallDir("plan9", "") + + if err == nil || err.Error() != installUnsupportedOSMessage { + t.Fatalf("expected %q, got %v", installUnsupportedOSMessage, err) + } +} + +func TestWriteInstallCompletionForOtherOSPrintsGenericMessage(t *testing.T) { + // Verifies platforms without PATH integration get a generic completion line. + var stdout bytes.Buffer + + writeInstallCompletion(&stdout, "plan9") + + if stdout.String() != "Install setup completed.\n" { + t.Fatalf("completion mismatch: %q", stdout.String()) + } +} + +// unsetNativeInstallLocation clears every input the install directory is resolved from and +// fails the test if it still resolves, so a regression can never reach the real installer. +func unsetNativeInstallLocation(t *testing.T) { + t.Helper() + t.Setenv(nativepath.InstallDirEnvName, "") + t.Setenv(nativepath.LocalAppDataEnvName, "") + t.Setenv("HOME", "") + if _, err := resolveNativeInstallDir(runtime.GOOS, ""); err == nil { + t.Fatal("precondition failed: the install directory still resolves, so the real installer could run") + } + if _, err := resolveUninstallInstallDir(runtime.GOOS); err == nil { + t.Fatal("precondition failed: the uninstall directory still resolves, so the real uninstaller could run") + } +} + +func TestTryHandleInstallRequestReportsUnresolvableInstallDirectory(t *testing.T) { + // Verifies install stops with code 1 when no install directory can be resolved. + unsetNativeInstallLocation(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleInstallRequest(context.Background(), []string{"install"}, &stdout, &stderr) + + if !handled || code != 1 || stdout.Len() != 0 { + t.Fatalf("result mismatch: handled=%t code=%d stdout=%q", handled, code, stdout.String()) + } + _, wantErr := resolveNativeInstallDir(runtime.GOOS, "") + envelope := decodeDispatcherTestEnvelope(t, stderr.String()) + errorObject, _ := envelope["Error"].(map[string]any) + if errorObject["Message"] != wantErr.Error() { + t.Fatalf("expected the install directory error %q: %s", wantErr, stderr.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher_test.go b/cli/dispatcher/internal/dispatcher/run_dispatcher_test.go new file mode 100644 index 0000000000..59487da6ac --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher_test.go @@ -0,0 +1,201 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" +) + +func writeDispatcherTestScript(t *testing.T, body string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell scripts are not executable on Windows.") + } + scriptPath := filepath.Join(t.TempDir(), "fake-cli.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatalf("failed to write fake CLI script: %v", err) + } + return scriptPath +} + +func decodeDispatcherTestEnvelope(t *testing.T, stderr string) map[string]any { + t.Helper() + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &envelope); err != nil { + t.Fatalf("stderr is not a JSON error envelope: %v\n%s", err, stderr) + } + return envelope +} + +func TestRunRealCLICommandForwardsArgsAndOutput(t *testing.T) { + // Verifies the resolved runner receives the original arguments and its stdout reaches the caller. + scriptPath := writeDispatcherTestScript(t, `echo "args:$*"`) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runRealCLICommand(context.Background(), scriptPath, []string{"compile", "--force-recompile"}, &stdout, &stderr) + + if code != 0 { + t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) + } + if stdout.String() != "args:compile --force-recompile\n" { + t.Fatalf("stdout mismatch: %q", stdout.String()) + } +} + +func TestRunRealCLICommandReturnsRunnerExitCode(t *testing.T) { + // Verifies a non-zero runner exit code is propagated as-is without a dispatcher error envelope. + scriptPath := writeDispatcherTestScript(t, "exit 3") + + var stderr bytes.Buffer + code := runRealCLICommand(context.Background(), scriptPath, nil, io.Discard, &stderr) + + if code != 3 { + t.Fatalf("exit code mismatch: %d", code) + } + if stderr.String() != "" { + t.Fatalf("dispatcher must not add its own error for a runner exit code: %s", stderr.String()) + } +} + +func TestRunRealCLICommandReportsStartFailure(t *testing.T) { + // Verifies a runner that cannot be started yields exit code 1 and an envelope naming the executable path. + missingPath := filepath.Join(t.TempDir(), "missing-runner") + + var stderr bytes.Buffer + code := runRealCLICommand(context.Background(), missingPath, nil, io.Discard, &stderr) + + if code != 1 { + t.Fatalf("exit code mismatch: %d", code) + } + envelope := decodeDispatcherTestEnvelope(t, stderr.String()) + if !strings.Contains(stderr.String(), "Failed to run resolved uloop CLI") { + t.Fatalf("missing start failure message: %s", stderr.String()) + } + errorObject, _ := envelope["Error"].(map[string]any) + details, _ := errorObject["Details"].(map[string]any) + if details["ExecutablePath"] != missingPath { + t.Fatalf("envelope must name the executable path: %v", envelope) + } +} + +func TestShouldKeepDispatcherProcessCommandForEmptyArgs(t *testing.T) { + // Verifies an empty command line stays in the dispatcher process instead of probing for a v2 project. + if !shouldKeepDispatcherProcessCommand(nil) { + t.Fatal("empty args must stay in the dispatcher process") + } +} + +func TestResolveDispatcherProjectRootRejectsInvalidCompileCheckOptions(t *testing.T) { + // Verifies compile-check option errors surface from project-root resolution before any project lookup. + _, err := resolveDispatcherProjectRoot(t.TempDir(), "", []string{clicore.CompileCheckCommandName, "--no-such-option"}) + if err == nil || !strings.Contains(err.Error(), "Unknown compile-check option: --no-such-option") { + t.Fatalf("expected the compile-check option error, got %v", err) + } +} + +func TestResolveDispatcherProjectRootResolvesCompileCheckProject(t *testing.T) { + // Verifies compile-check resolves the explicit project path without requiring a running Editor. + projectRoot := createDispatcherUnityProject(t) + + resolved, err := resolveDispatcherProjectRoot(t.TempDir(), projectRoot, []string{clicore.CompileCheckCommandName}) + if err != nil { + t.Fatalf("resolveDispatcherProjectRoot failed: %v", err) + } + if resolved != projectRoot { + t.Fatalf("project root mismatch: got %s want %s", resolved, projectRoot) + } +} + +func TestResolveDispatcherProjectRootRejectsInvalidLaunchOptions(t *testing.T) { + // Verifies launch option errors surface from project-root resolution. + _, err := resolveDispatcherProjectRoot(t.TempDir(), "", []string{clicore.LaunchCommandName, "--no-such-option"}) + if err == nil || !strings.Contains(err.Error(), "Unknown launch option: --no-such-option") { + t.Fatalf("expected the launch option error, got %v", err) + } +} + +func TestRunDispatcherRejectsMalformedGlobalProjectPath(t *testing.T) { + // Verifies a --project-path without a value fails before any routing and exits with code 1. + t.Chdir(t.TempDir()) + + var stderr bytes.Buffer + code := runDispatcherWithDeps(context.Background(), []string{"compile", "--project-path"}, io.Discard, &stderr, fakeDispatcherRunDeps(t)) + + if code != 1 { + t.Fatalf("exit code mismatch: %d", code) + } + if !strings.Contains(stderr.String(), "--project-path requires a value") { + t.Fatalf("error must name the malformed option: %s", stderr.String()) + } +} + +func TestRunDispatcherReportsUnresolvableProjectForRunnerCommand(t *testing.T) { + // Verifies a runner-owned command outside any Unity project fails with exit code 1 before resolving a runner. + t.Chdir(t.TempDir()) + + var stderr bytes.Buffer + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, &stderr, fakeDispatcherRunDeps(t)) + + if code != 1 { + t.Fatalf("exit code mismatch: %d", code) + } + if !strings.Contains(stderr.String(), "unity project not found") { + t.Fatalf("expected the project resolution error: %s", stderr.String()) + } +} + +func TestRunDispatcherReportsRealCLIResolutionFailure(t *testing.T) { + // Verifies an unusable ULOOP_PROJECT_RUNNER_PATH override is reported as a runner resolution error. + projectRoot := createDispatcherUnityProject(t) + writeDispatcherProjectPin(t, projectRoot, "3.0.0") + t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") + t.Setenv(nativepath.ProjectRunnerPathEnvName, filepath.Join(t.TempDir(), "missing-runner")) + t.Chdir(projectRoot) + + var stderr bytes.Buffer + code := runDispatcherWithDeps(context.Background(), []string{"compile"}, io.Discard, &stderr, fakeDispatcherRunDeps(t)) + + if code != 1 { + t.Fatalf("exit code mismatch: %d", code) + } + if !strings.Contains(stderr.String(), "no executable file exists there") { + t.Fatalf("expected the override failure in the envelope: %s", stderr.String()) + } +} + +func writeDispatcherExecutable(t *testing.T, filePath string, content string) { + t.Helper() + if err := os.WriteFile(filePath, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write %s: %v", filePath, err) + } +} + +// fakeDispatcherRunDeps replaces every dependency that could run a real runner, V2 CLI, or +// self-update with one that fails the test, so a routing regression cannot reach real side effects. +func fakeDispatcherRunDeps(t *testing.T) dispatcherRunDeps { + t.Helper() + deps := defaultDispatcherRunDeps() + deps.runRealCLI = func(context.Context, string, []string, io.Writer, io.Writer) int { + t.Fatal("the project runner must not be executed") + return 0 + } + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("the V2 CLI must not be executed") + return 0, nil + } + deps.runUpdate = func(context.Context) (bool, error) { + t.Fatal("the dispatcher self-update must not run") + return false, nil + } + return deps +} diff --git a/cli/dispatcher/internal/dispatcher/uninstall_test.go b/cli/dispatcher/internal/dispatcher/uninstall_test.go index 8054d44b6f..9339518d2a 100644 --- a/cli/dispatcher/internal/dispatcher/uninstall_test.go +++ b/cli/dispatcher/internal/dispatcher/uninstall_test.go @@ -3,8 +3,11 @@ package dispatcher import ( "bytes" "context" + "runtime" "strings" "testing" + + "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" ) func TestRunDispatcherUninstallHelpDoesNotRequireUnityProject(t *testing.T) { @@ -58,3 +61,56 @@ func TestPrintUninstallHelpDescribesPosixPathBlockRemoval(t *testing.T) { t.Fatalf("uninstall help output mismatch: %s", stdout.String()) } } + +func TestTryHandleUninstallRequestRejectsExtraArguments(t *testing.T) { + // Verifies uninstall refuses any option before resolving or removing anything. + unsetNativeInstallLocation(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleUninstallRequest(context.Background(), []string{"uninstall", "--force"}, &stdout, &stderr) + + if !handled || code != 1 { + t.Fatalf("result mismatch: handled=%t code=%d", handled, code) + } + if !strings.Contains(stderr.String(), "Unknown uninstall option: --force") { + t.Fatalf("missing option error: %s", stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("no removal progress may be printed: %s", stdout.String()) + } +} + +func TestResolveUninstallInstallDir(t *testing.T) { + // Verifies uninstall honors ULOOP_INSTALL_DIR and reports the uninstall-specific message on unsupported platforms. + t.Setenv(nativepath.InstallDirEnvName, "/opt/uloop/bin") + + installDir, err := resolveUninstallInstallDir("linux") + if err != nil || installDir != "/opt/uloop/bin" { + t.Fatalf("unexpected result: dir=%q err=%v", installDir, err) + } + + t.Setenv(nativepath.InstallDirEnvName, "") + if _, err := resolveUninstallInstallDir("plan9"); err == nil || err.Error() != uninstallUnsupportedOSMessage { + t.Fatalf("expected %q, got %v", uninstallUnsupportedOSMessage, err) + } +} + +func TestTryHandleUninstallRequestReportsUnresolvableInstallDirectory(t *testing.T) { + // Verifies uninstall stops with code 1 when no install directory can be resolved. + unsetNativeInstallLocation(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleUninstallRequest(context.Background(), []string{"uninstall"}, &stdout, &stderr) + + if !handled || code != 1 || stdout.Len() != 0 { + t.Fatalf("result mismatch: handled=%t code=%d stdout=%q", handled, code, stdout.String()) + } + _, wantErr := resolveUninstallInstallDir(runtime.GOOS) + envelope := decodeDispatcherTestEnvelope(t, stderr.String()) + errorObject, _ := envelope["Error"].(map[string]any) + if errorObject["Message"] != wantErr.Error() { + t.Fatalf("expected the install directory error %q: %s", wantErr, stderr.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/update_test.go b/cli/dispatcher/internal/dispatcher/update_test.go index a81106e804..adeb15b745 100644 --- a/cli/dispatcher/internal/dispatcher/update_test.go +++ b/cli/dispatcher/internal/dispatcher/update_test.go @@ -837,3 +837,158 @@ func stringSlicesEqual(left []string, right []string) bool { } return true } + +func TestParseUpdateOptionsRejectsInvalidOptions(t *testing.T) { + // Verifies unknown, duplicated, valueless, and positional update arguments are rejected with an argument error. + cases := []struct { + name string + args []string + wantMessage string + }{ + {name: "unknown option", args: []string{"--channel", "beta"}, wantMessage: "Unknown update option: --channel"}, + {name: "duplicate option", args: []string{"--to-version", "3.0.0", "--to-version=3.0.1"}, wantMessage: "Duplicate update option: --to-version"}, + {name: "missing value", args: []string{"--to-version"}, wantMessage: "--to-version requires a value"}, + {name: "positional argument", args: []string{"3.0.0"}, wantMessage: "3.0.0"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + options, err := parseUpdateOptions(testCase.args) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got options=%+v err=%v", testCase.wantMessage, options, err) + } + }) + } +} + +func TestTryHandleUpdateRequestReportsInvalidOptions(t *testing.T) { + // Verifies an invalid update option exits with code 1 before resolving or running an installer. + restore := stubManualUpdateHooks(t, "999.0.0") + defer restore() + updateRunCommand = func(context.Context, update.Command, io.Writer, io.Writer) error { + t.Fatal("installer must not run for invalid options") + return nil + } + + var stderr bytes.Buffer + handled, code := tryHandleUpdateRequest(context.Background(), []string{clicore.UpdateCommandName, "--channel", "beta"}, io.Discard, &stderr) + + if !handled || code != 1 { + t.Fatalf("update result mismatch: handled=%t code=%d", handled, code) + } + if !strings.Contains(stderr.String(), "Unknown update option: --channel") { + t.Fatalf("missing option error: %s", stderr.String()) + } +} + +func TestTryHandleUpdateRequestReportsInstallerFailure(t *testing.T) { + // Verifies a failing installer is reported as a retryable update failure with its cause. + skipWhenNativeUpdateIsUnsupported(t) + restore := stubManualUpdateHooks(t, "999.0.0") + defer restore() + updateRunCommand = func(context.Context, update.Command, io.Writer, io.Writer) error { + return errors.New("installer exited 1") + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + handled, code := tryHandleUpdateRequest(context.Background(), []string{clicore.UpdateCommandName}, &stdout, &stderr) + + if !handled || code != 1 { + t.Fatalf("update result mismatch: handled=%t code=%d", handled, code) + } + if !strings.Contains(stderr.String(), "Update failed: installer exited 1") { + t.Fatalf("missing installer failure: %s", stderr.String()) + } + if !strings.Contains(stdout.String(), "Updating global uloop dispatcher...") { + t.Fatalf("missing progress line: %s", stdout.String()) + } +} + +func TestUpdateCommandForOSReturnsInstallerCommand(t *testing.T) { + // Verifies the default update command for a supported OS names the installer runner. + name, args, err := updateCommandForOS("linux") + if err != nil { + t.Fatalf("updateCommandForOS failed: %v", err) + } + if name != "sh" || len(args) != 0 { + t.Fatalf("command mismatch: name=%q args=%q", name, args) + } +} + +func TestRunUpdateCommandRunsVerifiedInstallerWithManifest(t *testing.T) { + // Verifies the downloaded installer runs with the command env and the Sigstore-derived archive manifest. + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installers are not executable on Windows.") + } + installerContent := []byte("echo \"manifest=$" + updateArchiveManifestEnvName + "\"\necho \"extra=$ULOOP_TEST_EXTRA\"\necho \"args=$*\" >&2\n") + checksum := sha256.Sum256(installerContent) + restoreHTTPClient := stubUpdateInstallerHTTPClient(installerContent, []byte(hex.EncodeToString(checksum[:])+" install.sh\n")) + defer restoreHTTPClient() + restoreAttestation := stubAttestationVerifyPasses() + defer restoreAttestation() + restoreHooks := stubManualUpdateHooks(t, "999.0.0") + defer restoreHooks() + + var stdout bytes.Buffer + var stderr bytes.Buffer + err := runUpdateCommand(context.Background(), update.Command{ + Name: "/bin/sh", + Env: []string{"ULOOP_TEST_EXTRA=from-command"}, + InstallerName: update.PosixScriptName, + InstallerURL: "https://example.test/install.sh", + InstallerChecksumURL: "https://example.test/install.sh.sha256", + ReleaseTag: "dispatcher-v9.9.9", + }, &stdout, &stderr) + if err != nil { + t.Fatalf("runUpdateCommand failed: %v stderr=%s", err, stderr.String()) + } + if stdout.String() != "manifest=deadbeef install.sh\n\nextra=from-command\n" { + t.Fatalf("installer stdout mismatch: %q", stdout.String()) + } + if stderr.String() != "args=\n" { + t.Fatalf("installer stderr mismatch: %q", stderr.String()) + } +} + +func TestRunUpdateCommandStopsBeforeInstallerOnFailures(t *testing.T) { + // Verifies a failed installer download or manifest lookup returns an error without running the installer. + cases := []struct { + name string + installer []byte + manifestErr error + wantMessage string + }{ + {name: "download failure", wantMessage: "download failed"}, + {name: "manifest failure", installer: []byte("exit 0\n"), manifestErr: errors.New("manifest lookup failed"), wantMessage: "manifest lookup failed"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + restoreHooks := stubManualUpdateHooks(t, "999.0.0") + defer restoreHooks() + restoreAttestation := stubAttestationVerifyPasses() + defer restoreAttestation() + bodies := map[string][]byte{} + if testCase.installer != nil { + checksum := sha256.Sum256(testCase.installer) + bodies["/install.sh"] = testCase.installer + bodies["/install.sh.sha256"] = []byte(hex.EncodeToString(checksum[:]) + " install.sh\n") + } + stubDispatcherHTTPResponses(t, bodies) + fetchAttestationSubjectManifestFunc = func(context.Context, string) (string, error) { + return "", testCase.manifestErr + } + + err := runUpdateCommand(context.Background(), update.Command{ + Name: filepath.Join(t.TempDir(), "must-not-run"), + InstallerName: update.PosixScriptName, + InstallerURL: "https://example.test/install.sh", + InstallerChecksumURL: "https://example.test/install.sh.sha256", + }, io.Discard, io.Discard) + + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got %v", testCase.wantMessage, err) + } + }) + } +} From cfb50aba387653499e6f4e13efc4b017058ee79a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:32:04 +0900 Subject: [PATCH 09/68] chore: The IPC client, endpoint checks and process parsing in the common CLI module now have tests for each failure outcome (#3081) --- .../unix_endpoint_security_test.go | 76 ++++ .../unix_endpoint_security_unix_test.go | 89 +++++ cli/common/unityipc/client_outcome_test.go | 344 ++++++++++++++++++ cli/common/unityipc/framing_test.go | 105 ++++++ .../unityprocess/process_branches_test.go | 141 +++++++ cli/common/unityprocess/process_find_test.go | 27 ++ 6 files changed, 782 insertions(+) create mode 100644 cli/common/unityipc/client_outcome_test.go create mode 100644 cli/common/unityprocess/process_branches_test.go create mode 100644 cli/common/unityprocess/process_find_test.go diff --git a/cli/common/ipcendpoint/unix_endpoint_security_test.go b/cli/common/ipcendpoint/unix_endpoint_security_test.go index 04ff7b9143..25b1d54135 100644 --- a/cli/common/ipcendpoint/unix_endpoint_security_test.go +++ b/cli/common/ipcendpoint/unix_endpoint_security_test.go @@ -91,3 +91,79 @@ func (r *fakeUnixMetadataReader) Stat(path string) (unixFileMetadata, error) { } return v, nil } + +// erroringUnixMetadataReader returns configured errors per path before falling back to secure metadata. +type erroringUnixMetadataReader struct { + *fakeUnixMetadataReader + lstatErrors map[string]error + statErrors map[string]error +} + +func (r *erroringUnixMetadataReader) Lstat(path string) (unixFileMetadata, error) { + if err, ok := r.lstatErrors[path]; ok { + return unixFileMetadata{}, err + } + return r.fakeUnixMetadataReader.Lstat(path) +} + +func (r *erroringUnixMetadataReader) Stat(path string) (unixFileMetadata, error) { + if err, ok := r.statErrors[path]; ok { + return unixFileMetadata{}, err + } + return r.fakeUnixMetadataReader.Stat(path) +} + +// Verifies each validation step reports its own failure, so an inspection error or an unexpected parent +// kind is never mistaken for a missing endpoint or a later policy check. +func TestValidateUnixEndpointPathsReportsEachFailingStep(t *testing.T) { + inspectErr := errors.New("inspect failed") + tests := []struct { + name string + configure func(*erroringUnixMetadataReader) + expectedMessage string + }{ + {"parent lstat error", func(r *erroringUnixMetadataReader) { + r.lstatErrors[testUnixParentPath] = inspectErr + }, "inspect Unix endpoint parent without following links: inspect failed"}, + {"parent is a socket", func(r *erroringUnixMetadataReader) { + r.noFollow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindSocket, OwnerUserID: 0, Permissions: 0o1777} + }, "unix endpoint parent /tmp is neither a directory nor a symbolic link"}, + {"parent stat error", func(r *erroringUnixMetadataReader) { + r.statErrors[testUnixParentPath] = inspectErr + }, "inspect resolved Unix endpoint parent: inspect failed"}, + {"resolved parent not a directory", func(r *erroringUnixMetadataReader) { + r.follow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindOther, OwnerUserID: 0, Permissions: 0o1777} + }, "resolved Unix endpoint parent /tmp must be a root-owned sticky directory"}, + {"parent not root owned", func(r *erroringUnixMetadataReader) { + r.follow[testUnixParentPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID, Permissions: 0o1777} + }, "resolved Unix endpoint parent /tmp must be a root-owned sticky directory"}, + {"endpoint lstat error", func(r *erroringUnixMetadataReader) { + r.lstatErrors[testUnixEndpointPath] = inspectErr + }, "inspect Unix endpoint directory: inspect failed"}, + {"endpoint symlink", func(r *erroringUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindSymbolicLink, OwnerUserID: testEffectiveUserID, Permissions: 0o700} + }, "unix endpoint directory /tmp/uloop-501 must be a real directory"}, + {"endpoint owner", func(r *erroringUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID + 1, Permissions: 0o700} + }, "unix endpoint directory /tmp/uloop-501 is not owned by the current user"}, + {"endpoint mode", func(r *erroringUnixMetadataReader) { + r.noFollow[testUnixEndpointPath] = unixFileMetadata{Kind: unixFileKindDirectory, OwnerUserID: testEffectiveUserID, Permissions: 0o770} + }, "unix endpoint directory /tmp/uloop-501 must have mode 0700"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := &erroringUnixMetadataReader{ + fakeUnixMetadataReader: secureUnixMetadataReader(), + lstatErrors: map[string]error{}, + statErrors: map[string]error{}, + } + test.configure(reader) + + err := validateUnixEndpointPaths(testUnixParentPath, testUnixEndpointPath, testEffectiveUserID, reader) + + if err == nil || err.Error() != test.expectedMessage { + t.Fatalf("validation error = %v, want %q", err, test.expectedMessage) + } + }) + } +} diff --git a/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go b/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go index 4b16596d59..a6931e1120 100644 --- a/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go +++ b/cli/common/ipcendpoint/unix_endpoint_security_unix_test.go @@ -3,7 +3,10 @@ package ipcendpoint import ( + "errors" "os" + "path/filepath" + "strings" "syscall" "testing" "time" @@ -51,3 +54,89 @@ func (f fakeUnixFileInfo) Mode() os.FileMode { return f.mode } func (f fakeUnixFileInfo) ModTime() time.Time { return time.Time{} } func (f fakeUnixFileInfo) IsDir() bool { return f.mode.IsDir() } func (f fakeUnixFileInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} } + +// Verifies that only unix endpoints are validated: other networks pass without touching the filesystem. +func TestValidateSkipsNonUnixNetworks(t *testing.T) { + if err := Validate("pipe", filepath.Join(t.TempDir(), "missing", "endpoint.sock")); err != nil { + t.Fatalf("non-unix endpoints should not be validated: %v", err) + } +} + +// Verifies that the live validation reports a missing endpoint directory as the typed not-created state +// and accepts a private directory owned by the current user. +func TestValidateChecksTheLiveEndpointDirectory(t *testing.T) { + parent, err := (osUnixMetadataReader{}).Stat(unixSocketParent) + if err != nil || parent.OwnerUserID != 0 || parent.Permissions&unixStickyBit == 0 { + t.Skipf("%s is not a root-owned sticky directory on this host: %#v (err=%v)", unixSocketParent, parent, err) + } + // The endpoint directory sits directly under the inspected parent, the way real endpoints do. + endpointDirectory, err := os.MkdirTemp(unixSocketParent, "uloop-test-") + if err != nil { + t.Skipf("cannot create a directory under %s on this host: %v", unixSocketParent, err) + } + t.Cleanup(func() { + if err := os.RemoveAll(endpointDirectory); err != nil { + t.Errorf("remove directory: %v", err) + } + }) + if err := os.Chmod(endpointDirectory, 0o700); err != nil { + t.Fatalf("set private mode: %v", err) + } + + missingDirectory := filepath.Join(endpointDirectory, "missing") + err = Validate("unix", filepath.Join(missingDirectory, "endpoint.sock")) + var missing UnityEndpointNotCreatedError + if !errors.As(err, &missing) || missing.EndpointDirectory != missingDirectory { + t.Fatalf("expected typed missing endpoint for %s, got %v", missingDirectory, err) + } + if err := Validate("unix", filepath.Join(endpointDirectory, "endpoint.sock")); err != nil { + t.Fatalf("a private endpoint directory should validate: %v", err) + } +} + +// Verifies that metadata without a Unix stat payload is rejected instead of read as owner 0. +func TestUnixMetadataFromFileInfoRejectsForeignSys(t *testing.T) { + _, err := unixMetadataFromFileInfo(foreignSysFileInfo{fakeUnixFileInfo{mode: os.ModeDir | 0o700}}) + + if err == nil || !strings.Contains(err.Error(), "unix metadata for endpoint has unexpected type") { + t.Fatalf("expected an unexpected-type error, got %v", err) + } +} + +// Verifies the sticky bit is kept and each file mode maps to its own kind. +func TestUnixMetadataFromFileInfoMapsStickyBitAndKinds(t *testing.T) { + tests := []struct { + mode os.FileMode + expectedKind unixFileKind + expectedPerm uint32 + }{ + {mode: os.ModeDir | os.ModeSticky | 0o777, expectedKind: unixFileKindDirectory, expectedPerm: 0o1777}, + {mode: os.ModeSymlink | 0o777, expectedKind: unixFileKindSymbolicLink, expectedPerm: 0o777}, + {mode: os.ModeSocket | 0o600, expectedKind: unixFileKindSocket, expectedPerm: 0o600}, + {mode: 0o644, expectedKind: unixFileKindOther, expectedPerm: 0o644}, + } + for _, test := range tests { + metadata, err := unixMetadataFromFileInfo(fakeUnixFileInfo{mode: test.mode, uid: 7}) + if err != nil { + t.Fatalf("convert metadata for %v: %v", test.mode, err) + } + if metadata.Kind != test.expectedKind || metadata.Permissions != test.expectedPerm || metadata.OwnerUserID != 7 { + t.Errorf("metadata for %v = %#v, want kind %v perm %#o", test.mode, metadata, test.expectedKind, test.expectedPerm) + } + } +} + +type foreignSysFileInfo struct { + fakeUnixFileInfo +} + +func (foreignSysFileInfo) Sys() any { return "not a stat payload" } + +// Verifies the live followed-stat adapter surfaces a missing path as not-exist rather than empty metadata. +func TestOSUnixMetadataReaderStatReportsMissingPath(t *testing.T) { + _, err := (osUnixMetadataReader{}).Stat(filepath.Join(t.TempDir(), "missing")) + + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected a not-exist error, got %v", err) + } +} diff --git a/cli/common/unityipc/client_outcome_test.go b/cli/common/unityipc/client_outcome_test.go new file mode 100644 index 0000000000..d2b6cdb6b1 --- /dev/null +++ b/cli/common/unityipc/client_outcome_test.go @@ -0,0 +1,344 @@ +package unityipc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "runtime" + "strings" + "testing" + "time" + + "github.com/hatayama/unity-cli-loop/common/progress" +) + +// Test support server that accepts one connection and holds it open without reading, +// so the client's request write fills the socket buffers and blocks. +func startNonReadingTestServer(t *testing.T) Connection { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + released := make(chan struct{}) + t.Cleanup(func() { + close(released) + _ = listener.Close() + }) + + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer func() { + _ = conn.Close() + }() + <-released + }() + + return Connection{ + Endpoint: Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } +} + +func encodeFrame(t *testing.T, payload string) []byte { + t.Helper() + var buffer strings.Builder + if err := Write(&buffer, []byte(payload)); err != nil { + t.Fatalf("failed to encode frame: %v", err) + } + return []byte(buffer.String()) +} + +// Verifies that a dial failure is wrapped in ConnectionAttemptError carrying the project +// root and endpoint, and that the request is never reported as dispatched. +func TestSendWrapsDialFailureInConnectionAttemptError(t *testing.T) { + endpoint := unreachableTestEndpoint() + connection := Connection{Endpoint: endpoint, ProjectRoot: ""} + client := NewClient(connection, "9.9.9") + _, expectedDialErr := dialEndpoint(context.Background(), endpoint) + if expectedDialErr == nil { + t.Fatalf("the test endpoint %q should not be dialable", endpoint.Address) + } + + outcome, err := client.SendWithProgressOutcome(context.Background(), "get-version", map[string]any{}, nil) + + var attemptErr *ConnectionAttemptError + if !errors.As(err, &attemptErr) { + t.Fatalf("expected ConnectionAttemptError, got %T: %v", err, err) + } + if attemptErr.ProjectRoot != "" || attemptErr.Endpoint != endpoint.Address { + t.Fatalf("connection attempt fields mismatch: %#v", attemptErr) + } + expectedMessage := "the Unity CLI Loop server is not reachable for this project: " + attemptErr.Cause.Error() + if err.Error() != expectedMessage { + t.Fatalf("message mismatch: got %q want %q", err.Error(), expectedMessage) + } + if attemptErr.Cause == nil || attemptErr.Cause.Error() != expectedDialErr.Error() { + t.Fatalf("cause should be the dial failure %q, got %v", expectedDialErr, attemptErr.Cause) + } + if outcome.RequestDispatched { + t.Fatalf("dial failure must not report a dispatched request: %#v", outcome) + } +} + +// Verifies that params that cannot be JSON-encoded fail with the marshal error before +// anything is written, so the request is not reported as dispatched. +func TestSendFailsWithMarshalErrorForUnencodableParams(t *testing.T) { + connection := startHeartbeatTestServer(t, func(net.Conn) {}) + client := NewClient(connection, "9.9.9") + + outcome, err := client.SendWithProgressOutcome( + context.Background(), + "execute-dynamic-code", + map[string]any{"callback": make(chan int)}, + nil, + ) + + var unsupportedErr *json.UnsupportedTypeError + if !errors.As(err, &unsupportedErr) { + t.Fatalf("expected json.UnsupportedTypeError, got %T: %v", err, err) + } + if outcome.RequestDispatched { + t.Fatalf("marshal failure must not report a dispatched request: %#v", outcome) + } +} + +// Verifies that a request write that cannot complete before the accept deadline fails with a +// timeout and is not reported as dispatched, so callers know Unity never saw the request. +func TestSendReportsUndispatchedRequestWhenWriteTimesOut(t *testing.T) { + connection := startNonReadingTestServer(t) + client := NewClient(connection, "9.9.9", withAcceptTimeoutForTest(200*time.Millisecond)) + // Why 64 MiB: far beyond loopback socket buffers, so the write blocks until the deadline. + oversizedParam := strings.Repeat("x", 64<<20) + + outcome, err := client.SendWithProgressOutcome( + context.Background(), + "execute-dynamic-code", + map[string]any{"code": oversizedParam}, + nil, + ) + + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("expected write deadline error, got %v", err) + } + if outcome.RequestDispatched { + t.Fatalf("write failure must not report a dispatched request: %#v", outcome) + } + if outcome.Timing.Write <= 0 { + t.Fatalf("write timing should be recorded on write failure: %#v", outcome.Timing) + } +} + +// Verifies that a connection closed before any response surfaces the transport EOF while +// still reporting the request as dispatched but not accepted. +func TestSendReportsDispatchedRequestWhenServerClosesBeforeResponding(t *testing.T) { + connection := startHeartbeatTestServer(t, func(net.Conn) {}) + client := NewClient(connection, "9.9.9") + + outcome, err := client.SendWithProgressOutcome(context.Background(), "get-version", map[string]any{}, nil) + + if !errors.Is(err, io.EOF) { + t.Fatalf("expected EOF from closed connection, got %T: %v", err, err) + } + if !outcome.RequestDispatched || outcome.RequestAccepted { + t.Fatalf("expected dispatched but not accepted: %#v", outcome) + } +} + +// Verifies that a response frame that is not valid JSON fails with the decode error. +func TestSendFailsWithDecodeErrorForMalformedResponse(t *testing.T) { + connection := startHeartbeatTestServer(t, func(conn net.Conn) { + writeFrame(t, conn, "not json") + }) + client := NewClient(connection, "9.9.9") + + outcome, err := client.SendWithProgressOutcome(context.Background(), "get-version", map[string]any{}, nil) + + var syntaxErr *json.SyntaxError + if !errors.As(err, &syntaxErr) { + t.Fatalf("expected json.SyntaxError, got %T: %v", err, err) + } + if !outcome.RequestDispatched || outcome.Result != nil { + t.Fatalf("expected dispatched request without result: %#v", outcome) + } +} + +// Verifies that a JSON-RPC error response becomes an RPCError carrying the code, message, +// and data from Unity, with the Unity message in its error text. +func TestSendReturnsRPCErrorFromErrorResponse(t *testing.T) { + connection := startHeartbeatTestServer(t, func(conn net.Conn) { + writeFrame(t, conn, `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"tool not found","data":{"tool":"missing"}}}`) + }) + client := NewClient(connection, "9.9.9") + + outcome, err := client.SendWithProgressOutcome(context.Background(), "missing", map[string]any{}, nil) + + var rpcErr *RPCError + if !errors.As(err, &rpcErr) { + t.Fatalf("expected RPCError, got %T: %v", err, err) + } + if rpcErr.Code != -32601 || rpcErr.Message != "tool not found" || string(rpcErr.Data) != `{"tool":"missing"}` { + t.Fatalf("rpc error fields mismatch: %#v", rpcErr) + } + if err.Error() != "unity error: tool not found" { + t.Fatalf("rpc error message mismatch: %q", err.Error()) + } + if outcome.Result != nil { + t.Fatalf("error response must not carry a result: %#v", outcome) + } +} + +// Verifies that a response without result or error becomes NoResponseError so domain-reload +// recovery can treat it as a disconnect. +func TestSendReturnsNoResponseErrorForEmptyResult(t *testing.T) { + connection := startHeartbeatTestServer(t, func(conn net.Conn) { + writeFrame(t, conn, `{"jsonrpc":"2.0","id":1}`) + }) + client := NewClient(connection, "9.9.9") + + _, err := client.SendWithProgressOutcome(context.Background(), "get-version", map[string]any{}, nil) + + var noResponseErr *NoResponseError + if !errors.As(err, &noResponseErr) { + t.Fatalf("expected NoResponseError, got %T: %v", err, err) + } + if err.Error() != "unity returned no RPC result" { + t.Fatalf("no-response message mismatch: %q", err.Error()) + } +} + +// Verifies that cancelling the request context stops processing at the next heartbeat even +// when the final response is already buffered, instead of returning that result. +func TestSendStopsAtHeartbeatWhenRequestContextIsCancelled(t *testing.T) { + heartbeat := `{"jsonrpc":"2.0","id":1,"result":{"alive":true},"uloop":{"phase":"heartbeat"}}` + connection := startHeartbeatTestServer(t, func(conn net.Conn) { + // Why one write: the ack, heartbeat, and final response land in the client's read + // buffer together, so later frames are read without touching the cancelled socket. + frames := append(encodeFrame(t, heartbeatAck), encodeFrame(t, heartbeat)...) + frames = append(frames, encodeFrame(t, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`)...) + if _, err := conn.Write(frames); err != nil { + t.Errorf("failed to write frames: %v", err) + } + }) + client := NewClient(connection, "9.9.9", withHeartbeatSilenceOverrideForTest(5*time.Second)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + outcome, err := client.SendWithProgressOutcomeAcceptContext( + ctx, + context.Background(), + "run-tests", + map[string]any{}, + func(event progress.Event) { + if event.Stage == progress.StageAccepted { + cancel() + } + }, + ) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got %v (outcome %#v)", err, outcome) + } + if !outcome.RequestAccepted || outcome.Result != nil { + t.Fatalf("expected accepted request without result: %#v", outcome) + } +} + +// Verifies that a stall that began after this request's accept is reported to the stall +// handler and as "busy executing this command" progress on a self-induced-tolerant client. +func TestSendReportsSelfInducedStallAsBusyProgress(t *testing.T) { + // Why 30: the lowest reported stall, and within the self-induced margin right after accept. + stalledHeartbeat := `{"jsonrpc":"2.0","id":1,"result":{"alive":true},"uloop":{"phase":"heartbeat","mainThreadStallSeconds":30}}` + connection := startHeartbeatTestServer(t, func(conn net.Conn) { + writeFrame(t, conn, heartbeatAck) + writeFrame(t, conn, stalledHeartbeat) + writeFrame(t, conn, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`) + }) + stallReports := []float64{} + client := NewClient(connection, "9.9.9", withHeartbeatSilenceOverrideForTest(5*time.Second)). + WithSelfInducedMainThreadStallTolerance(). + WithMainThreadStallHandler(func(stallSeconds float64) { + stallReports = append(stallReports, stallSeconds) + }) + progressMessages := []string{} + + outcome, err := client.SendWithProgressOutcome( + context.Background(), + "execute-dynamic-code", + map[string]any{}, + func(event progress.Event) { + if event.Stage == progress.StageMessage { + progressMessages = append(progressMessages, event.Message) + } + }, + ) + + if err != nil || string(outcome.Result) != `{"ok":true}` { + t.Fatalf("expected final result, got %q / %v", string(outcome.Result), err) + } + if len(stallReports) != 1 || stallReports[0] != 30 { + t.Fatalf("stall reports mismatch: %#v", stallReports) + } + expected := "Unity main thread busy executing this command for 30s; still waiting..." + if len(progressMessages) != 1 || progressMessages[0] != expected { + t.Fatalf("progress messages mismatch: %#v", progressMessages) + } +} + +type timeoutProbeError struct { + timeout bool +} + +func (err timeoutProbeError) Error() string { + return fmt.Sprintf("timeout probe (timeout=%t)", err.timeout) +} + +func (err timeoutProbeError) Timeout() bool { + return err.timeout +} + +// Verifies that deadline expiry is detected through os.ErrDeadlineExceeded and through a +// wrapped Timeout() probe (the Windows named-pipe case), and nothing else. +func TestIsDeadlineExpiryClassifiesTimeoutErrors(t *testing.T) { + cases := []struct { + name string + err error + expected bool + }{ + {name: "deadline exceeded", err: fmt.Errorf("read: %w", os.ErrDeadlineExceeded), expected: true}, + {name: "wrapped timeout probe", err: fmt.Errorf("pipe read: %w", timeoutProbeError{timeout: true}), expected: true}, + {name: "wrapped non-timeout probe", err: fmt.Errorf("pipe read: %w", timeoutProbeError{timeout: false}), expected: false}, + {name: "plain error", err: errors.New("connection reset"), expected: false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if actual := isDeadlineExpiry(testCase.err); actual != testCase.expected { + t.Fatalf("isDeadlineExpiry(%v) = %t, want %t", testCase.err, actual, testCase.expected) + } + }) + } +} + +// unreachableTestEndpoint returns an endpoint whose dial fails without opening a socket: an unknown +// network on Unix, and a pipe name nothing listens on for Windows, whose dialer always opens a pipe. +func unreachableTestEndpoint() Endpoint { + if runtime.GOOS == "windows" { + return Endpoint{Network: "pipe", Address: `\\.\pipe\uloop-test-unreachable-endpoint`} + } + return Endpoint{Network: "bogus-network", Address: ""} +} diff --git a/cli/common/unityipc/framing_test.go b/cli/common/unityipc/framing_test.go index 73f93d7abe..e0f99e6559 100644 --- a/cli/common/unityipc/framing_test.go +++ b/cli/common/unityipc/framing_test.go @@ -3,6 +3,8 @@ package unityipc import ( "bufio" "bytes" + "errors" + "io" "testing" ) @@ -39,3 +41,106 @@ func TestReadRejectsDuplicateContentLength(t *testing.T) { t.Fatal("Read succeeded for duplicate Content-Length") } } + +type failingWriter struct { + failOnCall int + calls int + written bytes.Buffer + err error +} + +func (writer *failingWriter) Write(payload []byte) (int, error) { + writer.calls++ + if writer.calls == writer.failOnCall { + return 0, writer.err + } + return writer.written.Write(payload) +} + +// Verifies that Write refuses an empty payload without emitting a header. +func TestWriteRejectsEmptyPayload(t *testing.T) { + writer := &failingWriter{} + + err := Write(writer, nil) + + if err == nil || err.Error() != "payload must not be empty" { + t.Fatalf("expected empty payload error, got %v", err) + } + if writer.calls != 0 { + t.Fatalf("empty payload must not write anything: %d calls", writer.calls) + } +} + +// Verifies that a header write failure is returned without writing the payload, and a payload +// write failure is returned after the header was written. +func TestWritePropagatesWriterFailures(t *testing.T) { + cases := []struct { + name string + failOnCall int + expectedWritten string + }{ + {name: "header write fails", failOnCall: 1, expectedWritten: ""}, + {name: "payload write fails", failOnCall: 2, expectedWritten: "Content-Length: 2\r\n\r\n"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + writerErr := errors.New(testCase.name) + writer := &failingWriter{failOnCall: testCase.failOnCall, err: writerErr} + + err := Write(writer, []byte("{}")) + + if !errors.Is(err, writerErr) { + t.Fatalf("expected writer error %q, got %v", testCase.name, err) + } + if writer.calls != testCase.failOnCall { + t.Fatalf("write must stop at the failing call: %d calls", writer.calls) + } + if writer.written.String() != testCase.expectedWritten { + t.Fatalf("written bytes mismatch: %q", writer.written.String()) + } + }) + } +} + +// Verifies that headers other than Content-Length are skipped, case-insensitively matched. +func TestReadSkipsUnrelatedHeaders(t *testing.T) { + input := "Content-Type: application/json\r\ncontent-length: 2\r\n\r\n{}" + + payload, err := Read(bufio.NewReader(bytes.NewBufferString(input))) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if string(payload) != "{}" { + t.Fatalf("payload mismatch: %q", string(payload)) + } +} + +// Verifies that malformed or truncated frames fail with the error specific to each defect. +func TestReadRejectsMalformedFrames(t *testing.T) { + cases := []struct { + name string + input string + expectedError string + }{ + {name: "non-numeric length", input: "Content-Length: abc\r\n\r\n{}", expectedError: "invalid Content-Length header: Content-Length: abc"}, + {name: "negative length", input: "Content-Length: -1\r\n\r\n{}", expectedError: "invalid Content-Length header: Content-Length: -1"}, + {name: "missing length", input: "Content-Type: application/json\r\n\r\n{}", expectedError: "Content-Length header was not found"}, + {name: "duplicate length", input: "Content-Length: 2\r\nContent-Length: 3\r\n\r\n{}", expectedError: "duplicate Content-Length header"}, + {name: "truncated header", input: "Content-Length: 2", expectedError: io.EOF.Error()}, + {name: "truncated payload", input: "Content-Length: 10\r\n\r\n{}", expectedError: io.ErrUnexpectedEOF.Error()}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + payload, err := Read(bufio.NewReader(bytes.NewBufferString(testCase.input))) + + if err == nil || err.Error() != testCase.expectedError { + t.Fatalf("expected error %q, got %v", testCase.expectedError, err) + } + if payload != nil { + t.Fatalf("failed read must not return a payload: %q", string(payload)) + } + }) + } +} diff --git a/cli/common/unityprocess/process_branches_test.go b/cli/common/unityprocess/process_branches_test.go new file mode 100644 index 0000000000..7184191c01 --- /dev/null +++ b/cli/common/unityprocess/process_branches_test.go @@ -0,0 +1,141 @@ +package unityprocess + +import ( + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Verifies a Unity editor command without any -projectPath flag is not reported as a project's Editor. +func TestMatchMacUnityProcessRejectsCommandWithoutProjectPath(t *testing.T) { + process, matched := matchMacUnityProcess( + 123, + `/Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -useHub -hubIPC`) + + if matched { + t.Fatalf("expected no match without a project path, got %#v", process) + } +} + +// Verifies a procargs2 buffer whose last argv entry lacks its NUL terminator keeps only the complete entries. +func TestParseMacProcArgs2StopsAtUnterminatedArgument(t *testing.T) { + buf := make([]byte, 4) + binary.LittleEndian.PutUint32(buf, 2) + buf = append(buf, []byte("/usr/bin/execpath")...) + buf = append(buf, 0, 0) + buf = append(buf, []byte("/usr/bin/execpath")...) + buf = append(buf, 0) + buf = append(buf, []byte("-truncated")...) + + args, err := parseMacProcArgs2(buf) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(args) != 1 || args[0] != "/usr/bin/execpath" { + t.Fatalf("expected only the terminated argument, got %#v", args) + } +} + +// Verifies malformed Windows process-list lines are skipped individually while a valid line in the same output is kept. +func TestParseWindowsUnityProcessesSkipsMalformedLines(t *testing.T) { + encode := func(commandLine string) string { + return base64.StdEncoding.EncodeToString([]byte(commandLine)) + } + validEditorCommand := encode(`C:\Editor\Unity.exe -projectPath "C:\Projects\Kept"`) + cases := []struct { + name string + line string + }{ + {"no delimiter", validEditorCommand}, + {"pid is not a number", "notapid|" + validEditorCommand}, + {"editor command without project path", "456|" + encode(`C:\Editor\Unity.exe -useHub`)}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + output := testCase.line + "\r\n" + "123|" + validEditorCommand + "\r\n" + + processes := parseWindowsUnityProcesses(output) + + if len(processes) != 1 || processes[0].Pid != 123 || processes[0].projectPath != `C:\Projects\Kept` { + t.Fatalf("expected only the valid line, got %#v", processes) + } + }) + } +} + +// Verifies project path extraction yields no path for commands whose flag is missing, empty, or has an unclosed quote. +func TestExtractProjectPathReturnsEmptyForUnusableFlag(t *testing.T) { + cases := map[string]string{ + "no project path flag": `Unity -useHub -hubIPC`, + "flag with blank value": "Unity -projectPath= ", + "double quote never ends": `Unity -projectPath "/Users//Unclosed -useHub`, + "single quote never ends": `Unity -projectPath '/Users//Unclosed -useHub`, + } + for name, command := range cases { + t.Run(name, func(t *testing.T) { + if actual := extractProjectPath(command); actual != "" { + t.Fatalf("expected no project path for %q, got %q", command, actual) + } + }) + } +} + +// Verifies Windows project matching folds case so a differently cased path names the same project. +func TestNormalizeComparablePathFoldsCaseOnWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Only Windows project matching is case-insensitive.") + } + + root := t.TempDir() + upper, err := normalizeComparablePath(filepath.Join(root, "CaseProject")) + if err != nil { + t.Fatalf("normalizeComparablePath failed: %v", err) + } + lower, err := normalizeComparablePath(filepath.Join(root, "caseproject")) + if err != nil { + t.Fatalf("normalizeComparablePath failed: %v", err) + } + if upper != lower { + t.Fatalf("expected case-folded paths to match, got %q and %q", upper, lower) + } +} + +// Verifies the stderr captured in a wrapped *exec.ExitError is recovered for the error message. +func TestExitErrorStderrReadsWrappedExitError(t *testing.T) { + err := fmt.Errorf("listing failed: %w", &exec.ExitError{Stderr: []byte("WMI is not available")}) + + if stderr := exitErrorStderr(err); stderr != "WMI is not available" { + t.Fatalf("expected the ExitError stderr, got %q", stderr) + } +} + +// Verifies an error that is not an *exec.ExitError contributes no stderr text. +func TestExitErrorStderrReturnsEmptyForOtherErrors(t *testing.T) { + if stderr := exitErrorStderr(errors.New("executable file not found")); stderr != "" { + t.Fatalf("expected no stderr, got %q", stderr) + } +} + +// Verifies a nil command error stays nil even when stderr text is present. +func TestCommandErrorWithStderrReturnsNilWithoutError(t *testing.T) { + if err := commandErrorWithStderr(nil, "stray stderr output"); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +// Verifies the stderr text is appended after the original error so the cause stays first and unwrappable. +func TestCommandErrorWithStderrWrapsOriginalError(t *testing.T) { + original := errors.New("exit status 1") + + err := commandErrorWithStderr(original, " access denied\n") + + if !errors.Is(err, original) || !strings.HasSuffix(err.Error(), ": access denied") { + t.Fatalf("expected the original error wrapped with trimmed stderr, got %v", err) + } +} diff --git a/cli/common/unityprocess/process_find_test.go b/cli/common/unityprocess/process_find_test.go new file mode 100644 index 0000000000..0a137ff4ae --- /dev/null +++ b/cli/common/unityprocess/process_find_test.go @@ -0,0 +1,27 @@ +//go:build !windows + +package unityprocess + +import ( + "context" + "errors" + "testing" +) + +// Verifies a listing failure is returned to the caller instead of being reported as "no Editor running". +// The context is cancelled up front, and the macOS and Linux listers check it before reading any +// process information, so no OS process listing happens. Windows is excluded because its lister +// starts PowerShell instead. +func TestFindRunningUnityProcessReturnsListingError(t *testing.T) { + cancelledContext, cancel := context.WithCancel(context.Background()) + cancel() + + process, err := FindRunningUnityProcess(cancelledContext, t.TempDir()) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected the cancellation error from the listing, got %v", err) + } + if process != nil { + t.Fatalf("expected no process on a listing failure, got %#v", process) + } +} From 556197d4d165d4bf4bbfe266e9cc646de2ccf7e9 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:33:39 +0900 Subject: [PATCH 10/68] chore: Wait, compile, and retry paths in the project-runner CLI now have tests for cancellation, timeouts, and Unity failures (#3082) --- .../projectrunner/compile_attach_test.go | 106 ++++++++++ .../projectrunner/compile_wait_test.go | 71 +++++++ .../connection_retry_flow_test.go | 76 +++++++ .../control_play_mode_wait_test.go | 191 ++++++++++++++++++ .../hot_reload_compile_fallback_test.go | 53 +++++ .../run_tests_domain_reload_wait_test.go | 76 +++++++ .../projectrunner/runner_usage_test.go | 16 ++ .../set_code_optimization_test.go | 38 ++++ 8 files changed, 627 insertions(+) diff --git a/cli/project-runner/internal/projectrunner/compile_attach_test.go b/cli/project-runner/internal/projectrunner/compile_attach_test.go index 68d5d08670..9ba0ee42b5 100644 --- a/cli/project-runner/internal/projectrunner/compile_attach_test.go +++ b/cli/project-runner/internal/projectrunner/compile_attach_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "os" + "path/filepath" "runtime" "strings" "testing" "time" + "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -649,3 +653,105 @@ func TestRunCompileTimeoutWritesPendingRecord(t *testing.T) { default: } } + +// Verifies the pending-compile probe stops retrying and returns the context error when the +// context ends between failed queries, instead of waiting out the probe window. +func TestProbePendingCompileStatusReturnsContextErrorBetweenRetries(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + queries := 0 + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + queries++ + cancel() + return compileStatusResponse{}, io.ErrUnexpectedEOF + }) + deps.attachProbeTimeout = time.Minute + deps.attachProbeInterval = time.Minute + + _, err := probePendingCompileStatus(ctx, unityipc.Connection{ProjectRoot: t.TempDir()}, "compile_probe_cancel", deps) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if queries != 1 { + t.Fatalf("queries = %d, want 1", queries) + } +} + +// Verifies a reattach wait that ends with the caller's context reports the cancellation, keeps the +// pending record for a later retry, and never sends a fresh compile. +func TestRunCompileAttachReportsCancellationDuringWait(t *testing.T) { + projectRoot := t.TempDir() + if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{ + RequestID: "compile_attach_cancel", + TimedOutAtUtc: time.Now().UTC(), + }); err != nil { + t.Fatalf("write pending record failed: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + queries := 0 + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + queries++ + if queries > 1 { + cancel() + } + return compileStatusResponse{IsCompiling: true}, nil + }) + deps.sendCompile = func(context.Context, unityipc.Connection, string, map[string]any, unityipc.ProgressFunc, time.Duration) (unityipc.UnitySendOutcome, error) { + t.Fatal("a fresh compile must not be sent while reattaching") + return unityipc.UnitySendOutcome{}, nil + } + var stdout, stderr bytes.Buffer + + code := runCompileWithDomainReloadWaitWithDeps(ctx, unreachableConnection(projectRoot), map[string]any{}, &stdout, &stderr, deps) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), context.Canceled.Error()) { + t.Fatalf("stderr must report the cancellation:\n%s", stderr.String()) + } + if _, err := os.Stat(compilePendingRecordPath(projectRoot)); err != nil { + t.Fatalf("pending record must survive a cancelled reattach: %v", err) + } +} + +// Verifies a successful compile whose post-compile warmup fails still returns the compile +// result and only warns about the skipped warmup. +func TestCompleteCompileResultWarnsWhenWarmupFails(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var stderr bytes.Buffer + result := json.RawMessage(`{"Success":true,"ErrorCount":0}`) + + execution := completeCompileResult( + ctx, + unityipc.Connection{ProjectRoot: t.TempDir()}, + result, + &stderr, + clicore.NewToolSpinner(&stderr, clicore.CompileCommandName), + time.Now(), + unityipc.UnitySendOutcome{}, + ) + + if execution.exitCode != 0 || string(execution.result) != string(result) { + t.Fatalf("unexpected execution: %#v", execution) + } + if !strings.Contains(stderr.String(), "warning: post-compile warmup skipped: context canceled") { + t.Fatalf("stderr must warn about the skipped warmup:\n%s", stderr.String()) + } +} + +// Verifies a pending record that cannot be written produces a warning instead of failing silently. +func TestPersistCompilePendingRecordOrWarnReportsWriteFailure(t *testing.T) { + projectRoot := t.TempDir() + writeTestFile(t, filepath.Join(projectRoot, ".uloop"), "not a directory") + var stderr bytes.Buffer + + persistCompilePendingRecordOrWarn(projectRoot, "compile_persist_fail", &stderr) + + if !strings.Contains(stderr.String(), "warning: failed to persist pending compile request for retry attach: mkdir ") { + t.Fatalf("stderr must warn about the failed write:\n%s", stderr.String()) + } +} diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index f3caa91037..f45fe4406d 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -5,8 +5,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net" + "os" "path/filepath" "runtime" "strings" @@ -1241,3 +1243,72 @@ func vibeLogContextString(t *testing.T, entry map[string]any, key string) string } return value } + +// Verifies the timeout parser accepts each positive whole-number form and rejects zero, +// negatives, malformed json.Number values, and unsupported types. +func TestPositiveInt64FromAny(t *testing.T) { + cases := []struct { + name string + value any + want int64 + wantOK bool + }{ + {name: "int32", value: int32(4), want: 4, wantOK: true}, + {name: "int32 zero", value: int32(0)}, + {name: "int64", value: int64(5), want: 5, wantOK: true}, + {name: "int64 negative", value: int64(-5)}, + {name: "json.Number", value: json.Number("6"), want: 6, wantOK: true}, + {name: "json.Number fraction", value: json.Number("6.5")}, + {name: "json.Number negative", value: json.Number("-6")}, + {name: "string", value: "7"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got, ok := positiveInt64FromAny(testCase.value) + if got != testCase.want || ok != testCase.wantOK { + t.Fatalf("positiveInt64FromAny(%#v) = (%d, %v), want (%d, %v)", testCase.value, got, ok, testCase.want, testCase.wantOK) + } + }) + } +} + +// Verifies the compile status query reports an undecodable Unity result as a decode error. +func TestQueryCompileStatusFromUnityRejectsUndecodableResult(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), compileStatusCommandName, `[1]`) + + _, err := queryCompileStatusFromUnity(context.Background(), server.connection, "compile_status_decode") + + if err == nil || !strings.Contains(err.Error(), "cannot unmarshal array") { + t.Fatalf("expected a decode error, got %v", err) + } + if request := server.receivedRequest(t); request[compileRequestIDParam] != "compile_status_decode" { + t.Fatalf("unexpected request: %#v", request) + } +} + +// Verifies an unanswered status probe still unwraps to its transport cause. +func TestClassifyCompileStatusQueryErrorKeepsCause(t *testing.T) { + cause := os.ErrDeadlineExceeded + + err := classifyCompileStatusQueryError(unityipc.UnitySendOutcome{RequestAccepted: true}, cause) + + if !isUnansweredStatusProbe(err) { + t.Fatalf("an acknowledged deadline must be marked unanswered: %v", err) + } + if !errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("the unanswered error must unwrap to its cause: %v", err) + } +} + +// Verifies the domain-reload flag is ignored when the result fails to decode, even after the flag +// itself was decoded as true, and that the control-field strip leaves undecodable output untouched. +func TestExecuteDynamicCodeControlResultHandlesUndecodableOutput(t *testing.T) { + // Why the duplicate key: the decoder sets the flag to true before the second value fails, so + // only the decode-error branch keeps the wait from being requested. + if executeDynamicCodeDomainReloadWaitRequired([]byte(`{"DomainReloadWaitRequired":true,"DomainReloadWaitRequired":"yes"}`)) { + t.Fatal("output that fails to decode must not request a domain reload wait") + } + if stripped := stripExecuteDynamicCodeControlResult([]byte("not json")); string(stripped) != "not json" { + t.Fatalf("undecodable output must be returned unchanged, got %q", stripped) + } +} diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go index ce0d5ec9ee..9f7b7fa13e 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow_test.go @@ -3,10 +3,12 @@ package projectrunner import ( "context" "errors" + "io" "os" "path/filepath" "strings" "testing" + "time" clierrors "github.com/hatayama/unity-cli-loop/common/errors" @@ -173,3 +175,77 @@ func TestFinishUndispatchedRetryProbeRecordsTheProbeFailureInTheVibeLog(t *testi t.Fatalf("the probe failure was not recorded: %s", contents) } } + +func cancelledContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} + +// Verifies the busy retry stops with the caller's context error when the caller cancelled, and +// with the last busy error when only the retry window ran out. +func TestFinishBusyRetryStopsWithTheRightError(t *testing.T) { + busy := errors.New("busy") + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + deps := connectionRetryDeps{retryTimeout: time.Hour} + + cases := []struct { + name string + ctx context.Context + retryContext context.Context + startedAt time.Time + want error + }{ + {name: "window elapsed after caller cancelled", ctx: cancelledContext(), retryContext: context.Background(), startedAt: time.Now().Add(-2 * time.Hour), want: context.Canceled}, + {name: "retry context ended after caller cancelled", ctx: cancelledContext(), retryContext: cancelledContext(), startedAt: time.Now(), want: context.Canceled}, + {name: "retry context ended on its own", ctx: context.Background(), retryContext: cancelledContext(), startedAt: time.Now(), want: busy}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + finished, _, err := finishBusyRetry(testCase.ctx, testCase.retryContext, testCase.startedAt, ticker, unityipc.UnitySendOutcome{}, busy, deps) + if !finished || !errors.Is(err, testCase.want) { + t.Fatalf("finished=%v err=%v, want finished with %v", finished, err, testCase.want) + } + }) + } +} + +// Verifies a transport error right after a busy answer reports the busy answer, unless the caller +// cancelled, in which case the cancellation wins. +func TestFinishNonRetryableConnectionAttemptPrefersBusyOverTransportError(t *testing.T) { + busy := serverBusyRPCError(t) + current := sendAttempt{outcome: unityipc.UnitySendOutcome{RequestDispatched: true}, err: io.ErrUnexpectedEOF} + last := sendAttempt{err: busy} + + _, err := finishNonRetryableConnectionAttempt(context.Background(), current, last, 0, nil) + if !errors.Is(err, busy) { + t.Fatalf("err = %v, want the earlier busy answer", err) + } + + _, err = finishNonRetryableConnectionAttempt(cancelledContext(), current, last, 0, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +// Verifies the unity-alive retry reports the caller's cancellation when its retry context ends +// because the caller cancelled, and Unity-not-responding otherwise. +func TestFinishUnityAliveRetryWaitWhenRetryContextEnds(t *testing.T) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + deps := connectionRetryDeps{retryTimeout: time.Hour} + connection := unityipc.Connection{ProjectRoot: t.TempDir()} + dialErr := errors.New("dial refused") + + finished, _, err := finishUnityAliveRetryWait(cancelledContext(), cancelledContext(), time.Now(), ticker, connection, unityipc.UnitySendOutcome{}, dialErr, deps) + if !finished || !errors.Is(err, context.Canceled) { + t.Fatalf("finished=%v err=%v, want context.Canceled", finished, err) + } + + finished, _, err = finishUnityAliveRetryWait(context.Background(), cancelledContext(), time.Now(), ticker, connection, unityipc.UnitySendOutcome{}, dialErr, deps) + var notResponding clierrors.UnityServerNotRespondingError + if !finished || !errors.As(err, ¬Responding) { + t.Fatalf("finished=%v err=%v, want UnityServerNotRespondingError", finished, err) + } +} diff --git a/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go b/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go index 4cf27bf433..85700b080d 100644 --- a/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go +++ b/cli/project-runner/internal/projectrunner/control_play_mode_wait_test.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net" + "strings" "testing" "time" @@ -822,3 +823,193 @@ func TestDecodeControlPlayModeResponsePreservesActiveScenario(t *testing.T) { t.Fatalf("ActiveScenario should be omitted: %s", withoutScenarioJSON) } } + +// Verifies the initial control-play-mode response short-circuits the wait: an undecodable result +// fails, an already-reached state returns at once, and a Unity error is reported without polling. +func TestRunControlPlayModeWithStateWaitHandlesInitialResponse(t *testing.T) { + cases := []struct { + name string + response string + wantCode int + wantStdout string + wantStderr string + }{ + {name: "undecodable result", response: `{"jsonrpc":"2.0","result":"text","id":1}`, wantCode: 1, wantStderr: "cannot unmarshal"}, + {name: "state already reached", response: `{"jsonrpc":"2.0","result":{"IsPlaying":true,"IsPaused":false,"Message":"already playing"},"id":1}`, wantStdout: "already playing"}, + {name: "Unity error", response: testUnityRPCFailureResponse, wantCode: 1, wantStderr: "tool exploded in Unity"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := startFakeUnityServer(t, t.TempDir(), controlPlayModeCommandName, testCase.response) + var stdout, stderr bytes.Buffer + + code := runControlPlayModeWithStateWait( + context.Background(), + server.connection, + map[string]any{controlPlayModeActionParam: "Play", controlPlayModeTimeoutParam: 1}, + &stdout, + &stderr, + ) + + if code != testCase.wantCode { + t.Fatalf("exit code = %d, want %d; stderr=%s", code, testCase.wantCode, stderr.String()) + } + server.receivedRequest(t) + if !strings.Contains(stdout.String(), testCase.wantStdout) { + t.Fatalf("stdout must contain %q:\n%s", testCase.wantStdout, stdout.String()) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr must contain %q:\n%s", testCase.wantStderr, stderr.String()) + } + }) + } +} + +// serveDroppedConnections reads each request and closes the connection without answering, so +// the client sees a transport disconnect after dispatch. It calls onSecondAccept once the second +// connection arrives, which is the first state probe after the dropped action request. +func serveDroppedConnections(listener net.Listener, onSecondAccept func()) { + accepted := 0 + for { + conn, err := listener.Accept() + if err != nil { + return + } + accepted++ + if accepted == 2 { + onSecondAccept() + } + _, _ = unityipc.Read(bufio.NewReader(conn)) + _ = conn.Close() + } +} + +// Verifies a disconnect during the action request moves on to the state wait, and the caller's +// context ending during that wait is reported as a failure. +func TestRunControlPlayModeWithStateWaitReportsContextEndDuringWait(t *testing.T) { + originalPoll := controlPlayModeStatePoll + controlPlayModeStatePoll = time.Millisecond + t.Cleanup(func() { controlPlayModeStatePoll = originalPoll }) + // Why the cancel comes from the server rather than a timer: only a state probe proves the action + // request already ended in a disconnect, so the wait is cancelled at that point on any machine. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + listener := newLoopbackIpcListener(t) + go serveDroppedConnections(listener, cancel) + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: listener.Addr().Network(), Address: listener.Addr().String()}, + ProjectRoot: t.TempDir(), + } + var stdout, stderr bytes.Buffer + + code := runControlPlayModeWithStateWait( + ctx, + connection, + map[string]any{controlPlayModeActionParam: "Stop", controlPlayModeTimeoutParam: 30}, + &stdout, + &stderr, + ) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), context.Canceled.Error()) { + t.Fatalf("stderr must report the cancelled wait:\n%s", stderr.String()) + } +} + +// Verifies a wait that never got a status response times out with the last transport error attached. +func TestWaitForControlPlayModeStateTimesOutWithLastError(t *testing.T) { + originalPoll := controlPlayModeStatePoll + controlPlayModeStatePoll = time.Millisecond + t.Cleanup(func() { controlPlayModeStatePoll = originalPoll }) + + _, completed, err := waitForControlPlayModeState( + context.Background(), + unreachableConnection(t.TempDir()), + "Play", + 20*time.Millisecond, + ) + + if completed || err == nil { + t.Fatalf("expected an error, got completed=%v err=%v", completed, err) + } + if !strings.Contains(err.Error(), "timed out waiting for play mode state:") { + t.Fatalf("error must wrap the last transport error: %v", err) + } +} + +// Verifies only a dispatched request that lost its transport is treated as a reload disconnect. +func TestShouldWaitForControlPlayModeDisconnect(t *testing.T) { + if shouldWaitForControlPlayModeDisconnect(io.EOF, unityipc.UnitySendOutcome{}) { + t.Fatal("an undispatched request must not wait") + } + if !shouldWaitForControlPlayModeDisconnect(io.EOF, unityipc.UnitySendOutcome{RequestDispatched: true}) { + t.Fatal("a dispatched request that lost its transport must wait") + } +} + +// Verifies the action defaults to Play and the timeout accepts int, int64, and float64 values, +// falling back to the default for missing, non-positive, non-numeric, or too-large float values. +func TestControlPlayModeParamDefaults(t *testing.T) { + if action := controlPlayModeAction(map[string]any{}); action != "Play" { + t.Fatalf("default action = %q, want Play", action) + } + cases := []struct { + name string + value any + want int + }{ + {name: "int", value: 5, want: 5}, + {name: "int64", value: int64(6), want: 6}, + {name: "float64", value: float64(7), want: 7}, + {name: "negative", value: -1, want: controlPlayModeDefaultTimeout}, + // On amd64 int(1e300) is negative even without the guard; on arm64 it saturates, so only the + // guard keeps it from becoming a huge timeout there. + {name: "too-large float64", value: float64(1e300), want: controlPlayModeDefaultTimeout}, + {name: "string", value: "8", want: controlPlayModeDefaultTimeout}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := controlPlayModeTimeoutSeconds(map[string]any{controlPlayModeTimeoutParam: testCase.value}); got != testCase.want { + t.Fatalf("timeout = %d, want %d", got, testCase.want) + } + }) + } + if got := controlPlayModeTimeoutSeconds(map[string]any{}); got != controlPlayModeDefaultTimeout { + t.Fatalf("missing timeout = %d, want default", got) + } +} + +// Verifies state matching and the user-facing messages for every action, including unknown ones. +func TestControlPlayModeStateAndMessages(t *testing.T) { + if !controlPlayModeStateMatches("Pause", controlPlayModeResponse{IsPlaying: true, IsPaused: true}) { + t.Fatal("Pause must match a paused editor") + } + if controlPlayModeStateMatches("Status", controlPlayModeResponse{IsPlaying: true}) { + t.Fatal("an action without a target state must never match") + } + for action, want := range map[string][2]string{ + "Stop": {"Play mode stopped", "Play mode stop"}, + "Pause": {"Play mode paused", "Play mode pause"}, + "Play": {"Play mode started", "Play mode start"}, + } { + if got := completedControlPlayModeMessage(action, controlPlayModeResponse{}, false); got != want[0] { + t.Fatalf("completed message for %s = %q, want %q", action, got, want[0]) + } + if got := requestedControlPlayModeMessage(action); got != want[1] { + t.Fatalf("requested message for %s = %q, want %q", action, got, want[1]) + } + } +} + +// Verifies the compile-errors envelope counts the listed errors when Unity omitted the count. +func TestControlPlayModeCompileErrorsErrorCountsListedErrors(t *testing.T) { + cliError := controlPlayModeCompileErrorsError("", "Play", controlPlayModeResponse{ + CompileErrors: []controlPlayModeCompileError{{Message: "a"}, {Message: "b"}}, + }) + + if cliError.Details["CompileErrorCount"] != 2 { + t.Fatalf("CompileErrorCount = %#v, want 2", cliError.Details["CompileErrorCount"]) + } +} diff --git a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go index 2f7ff77d85..f0eab31bed 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_compile_fallback_test.go @@ -317,3 +317,56 @@ func assertCompactJSONEqual(t *testing.T, output string, expected string) { t.Fatalf("response must pass through unchanged:\nwant %s\ngot %s", expectedCompact.String(), actualCompact.String()) } } + +// Verifies a hot-reload request Unity rejects exits with the failure on stderr and never compiles. +func TestRunHotReloadWithCompileFallbackSkipsCompileWhenReloadFails(t *testing.T) { + original := hotReloadFallbackCompile + t.Cleanup(func() { hotReloadFallbackCompile = original }) + hotReloadFallbackCompile = func(context.Context, unityipc.Connection, io.Writer) compileExecutionResult { + t.Fatal("the fallback compile must not run after a failed reload request") + return compileExecutionResult{} + } + server := startFakeUnityServer(t, t.TempDir(), hotReloadCommandName, testUnityRPCFailureResponse) + var stdout, stderr bytes.Buffer + + code := runHotReloadWithCompileFallback(context.Background(), server.connection, map[string]any{}, &stdout, &stderr) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q", code, stdout.String()) + } + server.receivedRequest(t) + if !strings.Contains(stderr.String(), "tool exploded in Unity") { + t.Fatalf("stderr must carry the Unity error:\n%s", stderr.String()) + } +} + +// Verifies a fallback compile result that cannot be decoded fails the command instead of +// printing a merged response with a guessed Success. +func TestRunHotReloadFailsWhenFallbackCompileResultIsUndecodable(t *testing.T) { + stdout, stderr, compileCalls, code := runHotReloadWithFakeCompile( + t, + `{"Success":false,"CompileFallback":"Requested","Message":"Hot reload left edits unapplied."}`, + compileExecutionResult{result: json.RawMessage(`[1]`), exitCode: 0}, + ) + + if code != 1 || stdout != "" || compileCalls != 1 { + t.Fatalf("code=%d compileCalls=%d stdout=%q", code, compileCalls, stdout) + } + if !strings.Contains(stderr, "cannot unmarshal array") { + t.Fatalf("stderr must report the decode failure:\n%s", stderr) + } +} + +// Verifies a reload answer that fails to decode never counts as a fallback request, even after the +// request value itself was decoded, and a JSON null reload response is rejected rather than merged. +func TestHotReloadCompileFallbackRejectsNonObjectResponses(t *testing.T) { + // Why the duplicate key: the decoder keeps "Requested" when the second value fails, so only the + // decode-error check keeps the half-read answer from requesting a compile. + if isHotReloadCompileFallbackRequested([]byte(`{"CompileFallback":"Requested","CompileFallback":1}`)) { + t.Fatal("an answer that fails to decode must not request a compile") + } + _, err := injectHotReloadCompileFallback(json.RawMessage(`null`), json.RawMessage(`{"Success":true}`)) + if err == nil || err.Error() != "hot-reload response must be a JSON object" { + t.Fatalf("expected the non-object error, got %v", err) + } +} diff --git a/cli/project-runner/internal/projectrunner/run_tests_domain_reload_wait_test.go b/cli/project-runner/internal/projectrunner/run_tests_domain_reload_wait_test.go index 7eea823db9..214d7ae825 100644 --- a/cli/project-runner/internal/projectrunner/run_tests_domain_reload_wait_test.go +++ b/cli/project-runner/internal/projectrunner/run_tests_domain_reload_wait_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" ) @@ -330,3 +331,78 @@ func TestRunTestsWaitTimeoutErrorIsNotSafeToRetry(t *testing.T) { t.Fatalf("retry flags mismatch: retryable=%v safeToRetry=%v", cliErr.Retryable, cliErr.SafeToRetry) } } + +// Verifies the run-tests status query sends the request id to Unity and decodes the stored result, +// and surfaces Unity errors and undecodable payloads. +func TestQueryRunTestsStatusFromUnity(t *testing.T) { + t.Run("decodes status", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), runTestsStatusCommandName, `{"Success":true,"HasResult":true,"Result":{"TestCount":3}}`) + + status, err := queryRunTestsStatusFromUnity(context.Background(), server.connection, "run_tests_1_abcd") + if err != nil { + t.Fatalf("query failed: %v", err) + } + if request := server.receivedRequest(t); request[runTestsRequestIDParam] != "run_tests_1_abcd" { + t.Fatalf("unexpected request: %#v", request) + } + if !status.HasResult || string(status.Result) != `{"TestCount":3}` { + t.Fatalf("unexpected status: %#v", status) + } + }) + t.Run("Unity error", func(t *testing.T) { + server := startFakeUnityServer(t, t.TempDir(), runTestsStatusCommandName, testUnityRPCFailureResponse) + if _, err := queryRunTestsStatusFromUnity(context.Background(), server.connection, "run_tests_1_abcd"); err == nil || !strings.Contains(err.Error(), "tool exploded in Unity") { + t.Fatalf("expected the Unity error, got %v", err) + } + }) + t.Run("undecodable result", func(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), runTestsStatusCommandName, `[1]`) + if _, err := queryRunTestsStatusFromUnity(context.Background(), server.connection, "run_tests_1_abcd"); err == nil || !strings.Contains(err.Error(), "cannot unmarshal array") { + t.Fatalf("expected a decode error, got %v", err) + } + }) +} + +// Verifies the recovered-result wait reports a cancelled context and a timeout as failures with +// no result, the timeout using the run-tests wait timeout envelope. +func TestFinishRunTestsRecoveredResultReportsFailures(t *testing.T) { + noResult := func(context.Context, unityipc.Connection, string) (runTestsStatusResponse, error) { + return runTestsStatusResponse{Success: true}, nil + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + cases := []struct { + name string + ctx context.Context + timeout time.Duration + wantStderr string + }{ + {name: "cancelled", ctx: cancelled, timeout: time.Minute, wantStderr: "canceled"}, + {name: "timed out", ctx: context.Background(), timeout: 0, wantStderr: runTestsWaitTimeoutErrorCode}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stderr bytes.Buffer + spinner := clicore.NewToolSpinner(&stderr, clicore.RunTestsCommandName) + + execution := finishRunTestsRecoveredResult( + testCase.ctx, + compileWaitTestConnection(t), + "run_tests_1_abcd", + testCase.timeout, + &stderr, + spinner, + time.Now(), + unityipc.UnitySendOutcome{}, + noResult, + ) + + if execution.exitCode != 1 || len(execution.result) != 0 { + t.Fatalf("unexpected execution: %#v", execution) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr must contain %q:\n%s", testCase.wantStderr, stderr.String()) + } + }) + } +} diff --git a/cli/project-runner/internal/projectrunner/runner_usage_test.go b/cli/project-runner/internal/projectrunner/runner_usage_test.go index c1595e014e..7e5844b3b7 100644 --- a/cli/project-runner/internal/projectrunner/runner_usage_test.go +++ b/cli/project-runner/internal/projectrunner/runner_usage_test.go @@ -5,6 +5,8 @@ import ( "context" "strings" "testing" + + "github.com/hatayama/unity-cli-loop/common/clicontract" ) // Verifies the project runner refuses dispatcher-owned bootstrap @@ -81,3 +83,17 @@ func TestRunProjectLocalCommandHelpPrintsRunnerUsage(t *testing.T) { t.Fatalf("command help must not duplicate the full tool help: %s", stdout.String()) } } + +// Verifies a bare --version request prints only the project runner version and is handled. +func TestTryHandleRunnerInfoRequestPrintsVersion(t *testing.T) { + var stdout bytes.Buffer + + handled, code := tryHandleRunnerInfoRequest([]string{"--version"}, &stdout) + + if !handled || code != 0 { + t.Fatalf("handled=%v code=%d", handled, code) + } + if got := strings.TrimSpace(stdout.String()); got != clicontract.ProjectRunnerVersion() { + t.Fatalf("stdout = %q, want %q", got, clicontract.ProjectRunnerVersion()) + } +} diff --git a/cli/project-runner/internal/projectrunner/set_code_optimization_test.go b/cli/project-runner/internal/projectrunner/set_code_optimization_test.go index d6c2a3862d..fde855fac5 100644 --- a/cli/project-runner/internal/projectrunner/set_code_optimization_test.go +++ b/cli/project-runner/internal/projectrunner/set_code_optimization_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "strings" "testing" @@ -124,3 +125,40 @@ func assertSetCodeOptimizationBridgeSelection( t.Fatalf("stdout mismatch:\n got:\n%s\nwant:\n%s", stdout.String(), expectedOutput) } } + +// Verifies a bridge command that fails is reported on stderr with exit 1 and nothing on stdout. +func TestRunSetCodeOptimizationCommandReportsSendFailure(t *testing.T) { + var sentCommand string + dependencies := setCodeOptimizationCommandDependencies{ + send: func(_ context.Context, _ unityipc.Connection, bridgeCommand string) (json.RawMessage, error) { + sentCommand = bridgeCommand + return nil, io.ErrUnexpectedEOF + }, + } + var stdout, stderr bytes.Buffer + + code := runSetCodeOptimizationCommandWithDependencies( + context.Background(), unityipc.Connection{ProjectRoot: t.TempDir()}, []string{"debug"}, &stdout, &stderr, dependencies) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("code=%d stdout=%q", code, stdout.String()) + } + if sentCommand != setCodeOptimizationDebugCommandName { + t.Fatalf("sent %q, want %q", sentCommand, setCodeOptimizationDebugCommandName) + } + if !strings.Contains(stderr.String(), "unexpected EOF") { + t.Fatalf("stderr must report the send failure:\n%s", stderr.String()) + } +} + +// Verifies the default sender sends the named bridge command and returns Unity's result as is. +func TestSendSetCodeOptimizationBridgeCommandReturnsUnityResult(t *testing.T) { + server := startFakeUnityResultServer(t, t.TempDir(), setCodeOptimizationDebugStartupCommandName, `{"Success":true,"Message":"startup"}`) + + result, err := sendSetCodeOptimizationBridgeCommand(context.Background(), server.connection, setCodeOptimizationDebugStartupCommandName) + + if err != nil || string(result) != `{"Success":true,"Message":"startup"}` { + t.Fatalf("result=%s err=%v", result, err) + } + server.receivedRequest(t) +} From 7965fbd7f91acd80ac20e342fe63623419f145d6 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:36:15 +0900 Subject: [PATCH 11/68] chore: Protocol and release PR guards in the release-automation CLI now have tests for failure handling (#3083) --- .../automation/ipc_protocol_reminder_test.go | 114 ++++++++++ .../package_release_pr_merge_test.go | 205 +++++++++++++++++ .../package_release_pr_ready_merge_test.go | 88 ++++++++ .../protocol_minimum_version_comment_test.go | 213 ++++++++++++++++++ .../protocol_minimum_version_guard_test.go | 192 ++++++++++++++++ .../automation/release_pr_checks_test.go | 169 ++++++++++++++ .../automation/release_trigger_guard_test.go | 166 ++++++++++++++ .../automation/testdata/mockcli/main.go | 28 +++ 8 files changed, 1175 insertions(+) create mode 100644 cli/release-automation/internal/automation/package_release_pr_ready_merge_test.go create mode 100644 cli/release-automation/internal/automation/protocol_minimum_version_comment_test.go diff --git a/cli/release-automation/internal/automation/ipc_protocol_reminder_test.go b/cli/release-automation/internal/automation/ipc_protocol_reminder_test.go index b6ad8ab4bf..3a7bdbde04 100644 --- a/cli/release-automation/internal/automation/ipc_protocol_reminder_test.go +++ b/cli/release-automation/internal/automation/ipc_protocol_reminder_test.go @@ -1,6 +1,8 @@ package automation import ( + "bytes" + "context" "os" "path/filepath" "strings" @@ -78,3 +80,115 @@ func TestAppendIPCProtocolReminderSummary_WritesChangedFilesAndGuidance(t *testi t.Fatalf("summary misses protocol guidance:\n%s", text) } } + +// writeIPCProtocolReminderRepo creates a repository whose second commit touches an IPC-facing file +// without a protocol declaration, and makes it the working directory so git resolves it as the root. +func writeIPCProtocolReminderRepo(t *testing.T) { + t.Helper() + repoRoot := t.TempDir() + runGitInRepo(t, repoRoot, "init", "-b", "main") + writePackagePinConsistencyFile(t, repoRoot, "README.md", "base\n") + runGitInRepo(t, repoRoot, "add", "-A") + runGitInRepo(t, repoRoot, "commit", "-m", "base") + runGitInRepo(t, repoRoot, "tag", "base") + writePackagePinConsistencyFile(t, repoRoot, "cli/common/unityipc/client.go", "package unityipc\n") + runGitInRepo(t, repoRoot, "add", "-A") + runGitInRepo(t, repoRoot, "commit", "-m", "ipc change") + t.Chdir(repoRoot) +} + +func TestRunIPCProtocolReminder_WhenIPCFileChangesWithoutDeclaration_NotifiesAndWritesSummary(t *testing.T) { + // Verifies an IPC-only change prints the review notice and appends the step summary. + writeIPCProtocolReminderRepo(t) + summaryPath := filepath.Join(t.TempDir(), "summary.md") + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunIPCProtocolReminder(context.Background(), &stdout, &stderr, IPCProtocolReminderConfig{BaseRef: "base", StepSummaryPath: summaryPath}) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "::notice title=Review IPC protocol version::") { + t.Fatalf("expected a review notice, got %q", stdout.String()) + } + summary, err := os.ReadFile(summaryPath) + if err != nil { + t.Fatalf("read summary: %v", err) + } + if !strings.Contains(string(summary), "- `cli/common/unityipc/client.go`") { + t.Fatalf("summary does not list the changed file:\n%s", summary) + } +} + +func TestRunIPCProtocolReminder_FailsOnUnusableInputs(t *testing.T) { + // Verifies a missing base ref, an unknown base ref, and an unwritable summary path each fail with exit code 1. + cases := []struct { + name string + config func(t *testing.T) IPCProtocolReminderConfig + wantErr string + }{ + {"missing base", func(*testing.T) IPCProtocolReminderConfig { return IPCProtocolReminderConfig{} }, "--base is required"}, + {"unknown base", func(*testing.T) IPCProtocolReminderConfig { + return IPCProtocolReminderConfig{BaseRef: "no-such-ref"} + }, "failed to inspect changed files"}, + {"summary is a directory", func(t *testing.T) IPCProtocolReminderConfig { + return IPCProtocolReminderConfig{BaseRef: "base", StepSummaryPath: t.TempDir()} + }, "failed to append GitHub step summary"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + writeIPCProtocolReminderRepo(t) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunIPCProtocolReminder(context.Background(), &stdout, &stderr, testCase.config(t)) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), testCase.wantErr) { + t.Fatalf("stderr = %q, want it to contain %q", stderr.String(), testCase.wantErr) + } + }) + } +} + +func TestRunIPCProtocolReminder_WhenGitIsUnavailable_Fails(t *testing.T) { + // Verifies a repository root lookup failure is reported instead of treating the change set as empty. + t.Setenv("PATH", t.TempDir()) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunIPCProtocolReminder(context.Background(), &stdout, &stderr, IPCProtocolReminderConfig{BaseRef: "base"}) + + if exitCode != 1 || !strings.Contains(stderr.String(), "failed to resolve git repository root") { + t.Fatalf("expected a repository root failure, got exit %d and %q", exitCode, stderr.String()) + } +} + +func TestFormatIPCProtocolReminder_WhenDeclarationsChangeWithIPCFiles_AsksToVerifyTheBump(t *testing.T) { + // Verifies IPC and declaration changes together ask for bump verification, and blank or ./-prefixed paths are normalized. + result := AnalyzeIPCProtocolReminder([]string{"", "./cli/common/clicontract/contract.json", " cli/common/tools/x.json "}) + + message := FormatIPCProtocolReminder(result) + + if !strings.Contains(message, "verify the bump is intentional") { + t.Fatalf("message = %q", message) + } + if len(result.ChangedProtocolFiles) != 1 || result.ChangedProtocolFiles[0] != "cli/common/clicontract/contract.json" { + t.Fatalf("protocol files = %v", result.ChangedProtocolFiles) + } + if strings.Join(result.ChangedIPCFiles, ",") != "cli/common/clicontract/contract.json,cli/common/tools/x.json" { + t.Fatalf("IPC files = %v", result.ChangedIPCFiles) + } +} + +// Verifies a change set without IPC contract files reports that no contract surface changed. +func TestFormatIPCProtocolReminderWithoutContractChanges(t *testing.T) { + message := FormatIPCProtocolReminder(IPCProtocolReminderResult{}) + + if message != "No IPC contract surfaces changed." { + t.Fatalf("message = %q", message) + } +} diff --git a/cli/release-automation/internal/automation/package_release_pr_merge_test.go b/cli/release-automation/internal/automation/package_release_pr_merge_test.go index 4cdc3e41c6..edf6be50a7 100644 --- a/cli/release-automation/internal/automation/package_release_pr_merge_test.go +++ b/cli/release-automation/internal/automation/package_release_pr_merge_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "errors" "fmt" "strings" "testing" @@ -729,3 +730,207 @@ func assertMergePackageReleasePRStateReadCount(t *testing.T, stub *mergePackageR t.Fatalf("expected %d pull request state reads, got %d: %v", expected, count, stub.commandLog) } } + +// mergePackageReleasePROverride replaces the stubbed answer for every command line it matches, +// so a test can fail one gh call while the rest of the pass answers normally. +type mergePackageReleasePROverride struct { + matches func(commandLine string) bool + output string + err error +} + +// runMergePackageReleasePRWithOverride runs one command against polls with the override applied and, +// when sleepErr is set, a sleep that fails instead of advancing the clock. +func runMergePackageReleasePRWithOverride( + t *testing.T, + polls []mergePackageReleasePRPoll, + extraArgs []string, + override mergePackageReleasePROverride, + sleepErr error, +) (int, string, string, *mergePackageReleasePRStub) { + t.Helper() + stub := &mergePackageReleasePRStub{polls: polls} + now := time.Date(2026, 9, 8, 1, 0, 0, 0, time.UTC) + deps := mergePackageReleasePRDeps{ + now: func() time.Time { return now }, + sleep: func(ctx context.Context, duration time.Duration) error { + if sleepErr != nil { + return sleepErr + } + now = now.Add(duration) + return ctx.Err() + }, + runOutput: func(ctx context.Context, name string, args ...string) (string, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + if override.matches != nil && override.matches(commandLine) { + stub.commandLog = append(stub.commandLog, commandLine) + return override.output, override.err + } + return stub.runOutput(ctx, name, args...) + }, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + args := append([]string{ + "--repo", "owner/repository", + "--base-branch", "main", + "--timeout-minutes", "45", + "--interval-seconds", "30", + }, extraArgs...) + exitCode := RunMergePackageReleasePRWithDeps(context.Background(), &stdout, &stderr, args, deps) + return exitCode, stdout.String(), stderr.String(), stub +} + +// greenDraftPackageReleasePRPoll is a draft pull request whose head pins dispatcher-v3.4.0 and has passed every required workflow. +func greenDraftPackageReleasePRPoll() mergePackageReleasePRPoll { + return mergePackageReleasePRPoll{ + prListJSON: packageReleasePRListJSON("package123", true), + pinnedTagsByRef: packageReleasePRPinAt("package123", "dispatcher-v3.4.0"), + runs: packageReleasePRRunsAt("package123"), + manifestByRef: map[string]string{"main": `{"cli/dispatcher":"3.4.0"}`}, + } +} + +func mergePackageReleasePRCommandHasPrefix(prefix string) func(string) bool { + return func(commandLine string) bool { return strings.HasPrefix(commandLine, prefix) } +} + +func mergePackageReleasePRCommandContains(fragment string) func(string) bool { + return func(commandLine string) bool { return strings.Contains(commandLine, fragment) } +} + +func base64Of(value string) string { + return base64.StdEncoding.EncodeToString([]byte(value)) +} + +// Verifies every failing or malformed gh read before the merge fails the command with that read's own error and never merges. +func TestMergePackageReleasePRFailsOnEachUnusableRead(t *testing.T) { + pinPath := "contents/" + unityPackageCliPinFile + cases := []struct { + name string + args []string + override mergePackageReleasePROverride + wantErr string + }{ + {"release PR list", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains("autorelease: pending"), err: errors.New("release PR list failed")}, "release PR list failed"}, + {"release PR list JSON", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains("autorelease: pending"), output: "{"}, "failed to parse release PR list"}, + {"release PR without head", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains("autorelease: pending"), output: `[{"number":2002,"headRefName":"` + packageReleasePRHeadBranch + `","headRefOid":""}]`}, "release PR #2002 has no head SHA"}, + {"dispatcher PR list", []string{"--require-no-open-dispatcher-pr"}, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains("--components--dispatcher"), err: errors.New("dispatcher PR list failed")}, "dispatcher PR list failed"}, + {"dispatcher PR list JSON", []string{"--require-no-open-dispatcher-pr"}, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains("--components--dispatcher"), output: "{"}, "failed to parse dispatcher release PR list"}, + {"pin read", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(pinPath), err: errors.New("pin read failed")}, "pin read failed"}, + {"pin encoding", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(pinPath), output: "not base64!"}, "failed to decode " + unityPackageCliPinFile + " at package123"}, + {"pin JSON", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(pinPath), output: base64Of("{")}, "failed to parse " + unityPackageCliPinFile + " at package123"}, + {"pin without tag", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(pinPath), output: base64Of(`{}`)}, unityPackageCliPinFile + " at package123 has no dispatcherReleaseTag"}, + {"workflow runs", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandHasPrefix("gh run list "), err: errors.New("workflow runs failed")}, "workflow runs failed"}, + {"workflow runs JSON", nil, mergePackageReleasePROverride{matches: mergePackageReleasePRCommandHasPrefix("gh run list "), output: "{"}, "failed to parse " + packageReleasePRWorkflows()[0] + " workflow runs"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + args := append([]string{"--dispatcher-tag", "dispatcher-v3.4.0"}, testCase.args...) + + exitCode, _, stderr, stub := runMergePackageReleasePRWithOverride( + t, []mergePackageReleasePRPoll{greenDraftPackageReleasePRPoll()}, args, testCase.override, nil) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertReleasePRCheckLogContains(t, stderr, testCase.wantErr) + assertMergePackageReleasePRNeverMerged(t, stub) + }) + } +} + +// Verifies an unreadable, unparsable, or padded release manifest fails the tag resolution with its own error when no tag is given. +func TestMergePackageReleasePRFailsOnUnusableManifest(t *testing.T) { + manifestPath := "contents/" + releasePleaseManifestRelativePath + cases := []struct { + name string + override mergePackageReleasePROverride + wantErr string + }{ + {"manifest read", mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(manifestPath), err: errors.New("manifest read failed")}, "manifest read failed"}, + {"manifest JSON", mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(manifestPath), output: base64Of("{")}, "failed to parse " + releasePleaseManifestRelativePath + " at main"}, + {"padded version", mergePackageReleasePROverride{matches: mergePackageReleasePRCommandContains(manifestPath), output: base64Of(`{"cli/dispatcher":" 3.4.0"}`)}, `releases "cli/dispatcher" as " 3.4.0", which is not a bare version`}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + exitCode, _, stderr, stub := runMergePackageReleasePRWithOverride( + t, []mergePackageReleasePRPoll{greenDraftPackageReleasePRPoll()}, nil, testCase.override, nil) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertReleasePRCheckLogContains(t, stderr, testCase.wantErr) + assertMergePackageReleasePRNeverMerged(t, stub) + }) + } +} + +// Verifies a timeout without a named tag describes the manifest it waited on rather than an empty tag. +func TestMergePackageReleasePRTimeoutWithoutTagNamesTheManifest(t *testing.T) { + stalePoll := greenDraftPackageReleasePRPoll() + stalePoll.pinnedTagsByRef = packageReleasePRPinAt("package123", "dispatcher-v3.3.1") + + exitCode, _, stderr, stub := runMergePackageReleasePRWithOverride(t, []mergePackageReleasePRPoll{stalePoll}, nil, mergePackageReleasePROverride{}, nil) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertReleasePRCheckLogContains(t, stderr, "to record the dispatcher release the main manifest publishes timed out") + assertMergePackageReleasePRNeverMerged(t, stub) +} + +// Verifies an interrupted wait between passes fails with the sleep error instead of polling again. +func TestMergePackageReleasePRFailsWhenTheWaitIsInterrupted(t *testing.T) { + stalePoll := greenDraftPackageReleasePRPoll() + stalePoll.pinnedTagsByRef = packageReleasePRPinAt("package123", "dispatcher-v3.3.1") + + exitCode, _, stderr, stub := runMergePackageReleasePRWithOverride( + t, []mergePackageReleasePRPoll{stalePoll}, []string{"--dispatcher-tag", "dispatcher-v3.4.0"}, mergePackageReleasePROverride{}, errors.New("wait interrupted")) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertReleasePRCheckLogContains(t, stderr, "wait interrupted") + assertMergePackageReleasePRListCount(t, stub, 1) +} + +// Verifies unknown flags, a missing repository, an empty base branch, non-positive timings, and an empty workflow list are each rejected by their own message. +func TestParseMergePackageReleasePRFlagsRejectsInvalidInput(t *testing.T) { + cases := []struct { + name string + args []string + workflows string + wantErr string + }{ + {"unknown flag", []string{"--unknown"}, "", "flag provided but not defined"}, + {"missing repo", []string{"--base-branch", "main"}, "", "--repo is required"}, + {"empty base branch", []string{"--repo", "owner/repository", "--base-branch", ""}, "", "--base-branch must not be empty"}, + {"zero timeout", []string{"--repo", "owner/repository", "--timeout-minutes", "0"}, "", "--timeout-minutes and --interval-seconds must be positive"}, + {"zero interval", []string{"--repo", "owner/repository", "--interval-seconds", "0"}, "", "--timeout-minutes and --interval-seconds must be positive"}, + {"empty workflow list", []string{"--repo", "owner/repository"}, " , ", "RELEASE_PR_CHECK_WORKFLOWS must list at least one workflow"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("RELEASE_PR_CHECK_WORKFLOWS", testCase.workflows) + + _, err := parseMergePackageReleasePRFlags(testCase.args) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// Verifies the exported command reports a flag error before it runs any command. +func TestRunMergePackageReleasePRReportsUnknownFlag(t *testing.T) { + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunMergePackageReleasePR(context.Background(), &stdout, &stderr, []string{"--unknown"}) + + if exitCode != 1 || !strings.Contains(stderr.String(), "flag provided but not defined") { + t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String()) + } +} diff --git a/cli/release-automation/internal/automation/package_release_pr_ready_merge_test.go b/cli/release-automation/internal/automation/package_release_pr_ready_merge_test.go new file mode 100644 index 0000000000..cfbda80b20 --- /dev/null +++ b/cli/release-automation/internal/automation/package_release_pr_ready_merge_test.go @@ -0,0 +1,88 @@ +package automation + +import ( + "errors" + "testing" +) + +// Verifies how a failed draft lift is resolved: an unreadable or unparsable re-read fails with both errors, and a pull request another run already merged succeeds without merging again. +func TestMergePackageReleasePRResolvesAFailedReady(t *testing.T) { + cases := []struct { + name string + stateJSON string + override mergePackageReleasePROverride + wantExit int + wantStdout string + wantStderr []string + }{ + { + name: "state read", + override: mergePackageReleasePROverride{matches: mergePackageReleasePRCommandHasPrefix("gh pr view "), err: errors.New("state read failed")}, + wantExit: 1, + wantStderr: []string{"gh pr ready failed", "state read failed"}, + }, + { + name: "state JSON", + stateJSON: "{", + wantExit: 1, + wantStderr: []string{"gh pr ready failed", "failed to parse the state of PR #2002"}, + }, + { + name: "merged by another run", + stateJSON: `{"state":"MERGED","isDraft":false,"headRefOid":"package123"}`, + wantExit: 0, + wantStdout: "Unity package release PR #2002 was already merged by another run; nothing left to do.", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + poll := greenDraftPackageReleasePRPoll() + poll.failReady = true + poll.stateJSON = testCase.stateJSON + + exitCode, stdout, stderr, stub := runMergePackageReleasePRWithOverride( + t, []mergePackageReleasePRPoll{poll}, []string{"--dispatcher-tag", "dispatcher-v3.4.0"}, testCase.override, nil) + + if exitCode != testCase.wantExit { + t.Fatalf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", testCase.wantExit, exitCode, stdout, stderr) + } + if testCase.wantStdout != "" { + assertReleasePRCheckLogContains(t, stdout, testCase.wantStdout) + } + for _, want := range testCase.wantStderr { + assertReleasePRCheckLogContains(t, stderr, want) + } + assertMergePackageReleasePRNeverMerged(t, stub) + }) + } +} + +// Verifies a failed merge whose re-read cannot be made, or whose settle wait is interrupted, fails with the merge error and the cause. +func TestMergePackageReleasePRFailsWhenAFailedMergeCannotBeResolved(t *testing.T) { + cases := []struct { + name string + override mergePackageReleasePROverride + sleepErr error + wantErr string + }{ + {"state read", mergePackageReleasePROverride{matches: mergePackageReleasePRCommandHasPrefix("gh pr view "), err: errors.New("state read failed")}, nil, "state read failed"}, + {"settle wait", mergePackageReleasePROverride{}, errors.New("settle wait interrupted"), "settle wait interrupted"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + poll := greenDraftPackageReleasePRPoll() + poll.prListJSON = packageReleasePRListJSON("package123", false) + poll.failMerge = true + poll.stateJSON = `{"state":"OPEN","isDraft":false,"headRefOid":"package123"}` + + exitCode, _, stderr, _ := runMergePackageReleasePRWithOverride( + t, []mergePackageReleasePRPoll{poll}, []string{"--dispatcher-tag", "dispatcher-v3.4.0"}, testCase.override, testCase.sleepErr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertReleasePRCheckLogContains(t, stderr, "gh pr merge failed") + assertReleasePRCheckLogContains(t, stderr, testCase.wantErr) + }) + } +} diff --git a/cli/release-automation/internal/automation/protocol_minimum_version_comment_test.go b/cli/release-automation/internal/automation/protocol_minimum_version_comment_test.go new file mode 100644 index 0000000000..0990f23c7e --- /dev/null +++ b/cli/release-automation/internal/automation/protocol_minimum_version_comment_test.go @@ -0,0 +1,213 @@ +package automation + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// protocolMinimumVersionWarningCase changes the protocol without a matching minimum runner, so the guard warns. +func protocolMinimumVersionWarningCase() protocolMinimumVersionCommentCase { + return protocolMinimumVersionCommentCase{ + baseProtocol: 1, + baseProjectRunner: "3.0.0-beta.32", + headProtocol: 2, + headProjectRunner: "3.0.0-beta.32", + } +} + +// protocolMinimumVersionResolvedCase changes the protocol together with a matching minimum runner release. +func protocolMinimumVersionResolvedCase() protocolMinimumVersionCommentCase { + return protocolMinimumVersionCommentCase{ + baseProtocol: 1, + baseProjectRunner: "3.0.0-beta.32", + headProtocol: 2, + headProjectRunner: "3.0.0-beta.33", + releaseContent: `{"schemaVersion":1,"protocolVersion":2,"projectRunnerVersion":"3.0.0-beta.33"}`, + } +} + +func TestRunProtocolMinimumVersionComment_WhenNoCommentExists_PostsNewComment(t *testing.T) { + // Verifies a warning with no earlier bot comment posts a new comment on the pull request. + result := runProtocolMinimumVersionCommentCase(t, protocolMinimumVersionWarningCase()) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + assertProtocolMinimumVersionLogContains(t, result.stdout, "Posted protocol minimum version comment.") + assertProtocolMinimumVersionLogContains(t, result.ghLog, "api --method POST repos/owner/repository/issues/456/comments --input") + assertProtocolMinimumVersionPostedBody(t, result.ghLog, "Protocol version changed, but") +} + +// assertProtocolMinimumVersionPostedBody checks the comment body the mock gh copied from --input carries the marker that +// later runs search for and the expected warning text. +func assertProtocolMinimumVersionPostedBody(t *testing.T, ghLog string, wantText string) { + t.Helper() + _, input, found := strings.Cut(ghLog, "input: ") + if !found { + t.Fatalf("expected the mock gh to log the --input body, got:\n%s", ghLog) + } + input, _, _ = strings.Cut(input, "\n") + payload := struct { + Body string `json:"body"` + }{} + if err := json.Unmarshal([]byte(input), &payload); err != nil { + t.Fatalf("failed to parse the posted body %q: %v", input, err) + } + if !strings.HasPrefix(payload.Body, protocolMinimumVersionMarker+"\n") || !strings.Contains(payload.Body, wantText) { + t.Fatalf("posted body lacks the marker or %q:\n%s", wantText, payload.Body) + } +} + +func TestRunProtocolMinimumVersionComment_WhenResolvedWithoutComment_DoesNothing(t *testing.T) { + // Verifies a resolved warning with no earlier bot comment neither deletes nor posts anything. + result := runProtocolMinimumVersionCommentCase(t, protocolMinimumVersionResolvedCase()) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + if result.stdout != "" { + t.Fatalf("expected no output, got %q", result.stdout) + } + if strings.Contains(result.ghLog, "--method") { + t.Fatalf("expected no comment write, got gh log:\n%s", result.ghLog) + } +} + +func TestRunProtocolMinimumVersionComment_WhenRepositoryIsUnset_ResolvesItThroughGh(t *testing.T) { + // Verifies the repository comes from gh repo view when GITHUB_REPOSITORY is empty. + testCase := protocolMinimumVersionWarningCase() + testCase.repositoryFromGh = true + testCase.repoView = "resolved/repository" + + result := runProtocolMinimumVersionCommentCase(t, testCase) + + if result.exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", result.exitCode, result.stderr) + } + assertProtocolMinimumVersionLogContains(t, result.ghLog, "repo view --json nameWithOwner") + assertProtocolMinimumVersionLogContains(t, result.ghLog, "repos/resolved/repository/issues/456/comments") +} + +func TestRunProtocolMinimumVersionComment_WhenCommentLookupFails_Fails(t *testing.T) { + // Verifies a failing comment lookup fails the command both when posting and when deleting. + cases := map[string]protocolMinimumVersionCommentCase{ + "warning": protocolMinimumVersionWarningCase(), + "resolved": protocolMinimumVersionResolvedCase(), + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + testCase.failCommentLookup = true + + result := runProtocolMinimumVersionCommentCase(t, testCase) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + assertProtocolMinimumVersionLogContains(t, result.stderr, "comment lookup failed") + }) + } +} + +func TestRunProtocolMinimumVersionComment_WhenCommentBodyFileCannotBeCreated_Fails(t *testing.T) { + // Verifies an unusable temporary directory fails the command instead of posting an empty comment. + missingDirectory := filepath.Join(t.TempDir(), "missing") + t.Setenv("TMPDIR", missingDirectory) + t.Setenv("TMP", missingDirectory) + t.Setenv("TEMP", missingDirectory) + + result := runProtocolMinimumVersionCommentCase(t, protocolMinimumVersionWarningCase()) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + assertProtocolMinimumVersionLogContains(t, result.stderr, "failed to create comment body file") +} + +func TestRunProtocolMinimumVersionComment_WhenRepositoryCannotBeResolved_Fails(t *testing.T) { + // Verifies a failing gh repo view fails the command before any comment is touched. + testCase := protocolMinimumVersionWarningCase() + testCase.repositoryFromGh = true + + result := runProtocolMinimumVersionCommentCase(t, testCase) + + if result.exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", result.exitCode, result.stdout) + } + assertProtocolMinimumVersionLogContains(t, result.stderr, "repo view failed") +} + +func TestRunProtocolMinimumVersionComment_WithoutPullRequestOrBaseRef_Skips(t *testing.T) { + // Verifies a run without a pull request number or base ref skips without failing. + cases := []struct { + name string + environment map[string]string + want string + }{ + {"no pull request", map[string]string{"PR_NUMBER": "", "GITHUB_BASE_REF": "main"}, "no PR number was provided"}, + {"no base ref", map[string]string{"PR_NUMBER": "1", "GITHUB_BASE_REF": "", "PROTOCOL_MINIMUM_VERSION_BASE_REF": ""}, "no base ref was provided"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + // Mock git and gh come first on PATH so a regression past the skip reaches the mocks, not GitHub. + workDir := setupProtocolMinimumVersionMocks(t, protocolMinimumVersionRefCase{}) + for key, value := range testCase.environment { + t.Setenv(key, value) + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunProtocolMinimumVersionComment(context.Background(), &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d\nstderr: %s", exitCode, stderr.String()) + } + assertProtocolMinimumVersionLogContains(t, stdout.String(), testCase.want) + if _, err := os.Stat(filepath.Join(workDir, "gh.log")); !os.IsNotExist(err) { + t.Fatalf("expected no gh call, got stat error %v", err) + } + }) + } +} + +func TestRunProtocolMinimumVersionComment_WhenGitIsUnavailable_Fails(t *testing.T) { + // Verifies a guard analysis failure fails the command instead of deleting the existing comment. + t.Setenv("PATH", t.TempDir()) + t.Setenv("ULOOP_REPOSITORY_ROOT", t.TempDir()) + t.Setenv("PR_NUMBER", "1") + t.Setenv("PROTOCOL_MINIMUM_VERSION_BASE_REF", "origin/main") + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunProtocolMinimumVersionComment(context.Background(), &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", exitCode, stdout.String()) + } + assertProtocolMinimumVersionLogContains(t, stderr.String(), "failed to resolve git repository root") +} + +func TestProtocolMinimumVersionCommentConfigFromEnvironment_AppliesDefaults(t *testing.T) { + // Verifies the working directory, origin-prefixed base ref, HEAD, and bot author are used when their variables are unset. + t.Setenv("ULOOP_REPOSITORY_ROOT", "") + t.Setenv("PROTOCOL_MINIMUM_VERSION_BASE_REF", "") + t.Setenv("GITHUB_BASE_REF", "main") + t.Setenv("PROTOCOL_MINIMUM_VERSION_HEAD_REF", "") + t.Setenv("PROTOCOL_MINIMUM_VERSION_COMMENT_AUTHOR", "") + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + config, err := protocolMinimumVersionCommentConfigFromEnvironment() + if err != nil { + t.Fatalf("expected config to resolve, got %v", err) + } + if config.repositoryRoot != workingDirectory || config.baseRef != "origin/main" || config.headRef != "HEAD" || config.commentAuthor != protocolMinimumVersionCommentAuthor { + t.Fatalf("config = %+v", config) + } +} diff --git a/cli/release-automation/internal/automation/protocol_minimum_version_guard_test.go b/cli/release-automation/internal/automation/protocol_minimum_version_guard_test.go index e7b72b350b..aaf428ae69 100644 --- a/cli/release-automation/internal/automation/protocol_minimum_version_guard_test.go +++ b/cli/release-automation/internal/automation/protocol_minimum_version_guard_test.go @@ -385,6 +385,7 @@ func TestRunProtocolMinimumVersionComment_WhenWarningExists_UpsertsComment(t *te } assertProtocolMinimumVersionLogContains(t, result.stdout, "Updated protocol minimum version comment.") assertProtocolMinimumVersionLogContains(t, result.ghLog, "api --method PATCH repos/owner/repository/issues/comments/123 --input") + assertProtocolMinimumVersionPostedBody(t, result.ghLog, "Protocol version changed, but") assertProtocolMinimumVersionLogContains(t, result.ghLog, ".user.login == \"github-actions[bot]\"") } @@ -422,6 +423,7 @@ func TestRunProtocolMinimumVersionComment_WhenMinimumReleaseProtocolDiffers_Upse } assertProtocolMinimumVersionLogContains(t, result.stdout, "Updated protocol minimum version comment.") assertProtocolMinimumVersionLogContains(t, result.ghLog, "api --method PATCH repos/owner/repository/issues/comments/123 --input") + assertProtocolMinimumVersionPostedBody(t, result.ghLog, "does not point to a published project runner release") } type protocolMinimumVersionRefCase struct { @@ -493,6 +495,11 @@ type protocolMinimumVersionCommentCase struct { headProjectRunner string releaseContent string commentIDs string + // repositoryFromGh leaves GITHUB_REPOSITORY empty so the repository is resolved + // through gh repo view, which prints repoView or fails when repoView is empty. + repositoryFromGh bool + repoView string + failCommentLookup bool } type protocolMinimumVersionCommentResult struct { @@ -528,6 +535,13 @@ func runProtocolMinimumVersionCommentCase(t *testing.T, testCase protocolMinimum t.Setenv("ULOOP_REPOSITORY_ROOT", workDir) t.Setenv("PR_NUMBER", "456") t.Setenv("GITHUB_REPOSITORY", "owner/repository") + if testCase.repositoryFromGh { + t.Setenv("GITHUB_REPOSITORY", "") + t.Setenv("GH_REPO_VIEW", testCase.repoView) + } + if testCase.failCommentLookup { + t.Setenv("GH_FAIL_PAGINATE", "1") + } t.Setenv("GITHUB_BASE_REF", "v3-beta") t.Setenv("PROTOCOL_MINIMUM_VERSION_HEAD_REF", "protocol-pr-head") t.Setenv("GIT_LOG", gitLogPath) @@ -712,6 +726,10 @@ set -eu printf '%s\n' "$*" >> "$GH_LOG" if [ "$1" = "release" ] && [ "$2" = "view" ]; then + if [ -n "${GH_FAIL_RELEASE_VIEW:-}" ]; then + echo "release not found" >&2 + exit 1 + fi if [ -n "${GH_RELEASE_VIEW:-}" ]; then printf '%s\n' "$GH_RELEASE_VIEW" else @@ -720,7 +738,20 @@ if [ "$1" = "release" ] && [ "$2" = "view" ]; then exit 0 fi +if [ "$1" = "repo" ] && [ "$2" = "view" ]; then + if [ -n "${GH_REPO_VIEW:-}" ]; then + printf '%s\n' "$GH_REPO_VIEW" + exit 0 + fi + echo "repo view failed" >&2 + exit 1 +fi + if [ "$1" = "api" ] && [ "$2" = "--paginate" ]; then + if [ -n "${GH_FAIL_PAGINATE:-}" ]; then + echo "comment lookup failed" >&2 + exit 1 + fi if [ -n "$GH_COMMENT_IDS" ]; then printf '%s\n' "$GH_COMMENT_IDS" fi @@ -728,6 +759,11 @@ if [ "$1" = "api" ] && [ "$2" = "--paginate" ]; then fi if [ "$1" = "api" ] && [ "$2" = "--method" ]; then + # The comment body travels in the --input file, which is removed after the call, so it is copied + # into the log for the tests to read. + if [ "${5:-}" = "--input" ] && [ -n "${6:-}" ]; then + { printf 'input: '; tr -d '\n' < "$6"; printf '\n'; } >> "$GH_LOG" + fi exit 0 fi @@ -764,3 +800,159 @@ func assertProtocolMinimumVersionLogContains(t *testing.T, actual string, expect t.Fatalf("expected log to contain %q, got:\n%s", expected, actual) } } + +// setupProtocolMinimumVersionMocks puts the mock git and gh first on PATH and returns the mock repository root. +func setupProtocolMinimumVersionMocks(t *testing.T, testCase protocolMinimumVersionRefCase) string { + t.Helper() + workDir := t.TempDir() + mockBin := filepath.Join(workDir, "bin") + if err := os.MkdirAll(mockBin, 0o755); err != nil { + t.Fatalf("failed to create mock bin: %v", err) + } + writeProtocolMinimumVersionMockGit(t, filepath.Join(mockBin, "git")) + writeProtocolMinimumVersionMockGH(t, filepath.Join(mockBin, "gh")) + prepareProtocolMinimumVersionGitContents(t, workDir, testCase) + t.Setenv("PATH", mockBin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("ULOOP_REPOSITORY_ROOT", workDir) + t.Setenv("GIT_LOG", filepath.Join(workDir, "git.log")) + t.Setenv("GH_LOG", filepath.Join(workDir, "gh.log")) + return workDir +} + +// writeProtocolMinimumVersionWorkingTree writes the constants and pin files that a check without a ref reads from disk. +func writeProtocolMinimumVersionWorkingTree(t *testing.T, workDir string, constants string, pin string) { + t.Helper() + if constants != "" { + writeDispatcherMinimumVersionFile(t, filepath.Join(workDir, protocolMinimumVersionFile), constants) + } + if pin != "" { + writeDispatcherMinimumVersionFile(t, filepath.Join(workDir, unityPackageCliPinFile), pin) + } +} + +func TestRunMinimumCliReleaseProtocolCheck_FailsOnUnusableInputs(t *testing.T) { + // Verifies missing or malformed working-tree inputs, a missing or malformed release contract, and an unpublished or malformed release each fail the check. + validConstants := buildProtocolMinimumVersionConstants(2) + validPin := buildProtocolMinimumVersionPin("3.0.0-beta.33") + validRelease := `{"protocolVersion":2,"projectRunnerVersion":"3.0.0-beta.33"}` + cases := []struct { + name string + constants string + pin string + release string + environment map[string]string + wantErr string + }{ + {"missing constants", "", validPin, validRelease, nil, "failed to read " + protocolMinimumVersionFile}, + {"missing pin", validConstants, "", validRelease, nil, "failed to read " + unityPackageCliPinFile}, + {"invalid pin", validConstants, "{", validRelease, nil, "invalid"}, + {"no required protocol", "public static class CliConstants {}", validPin, validRelease, nil, "does not define REQUIRED_CLI_PROTOCOL_VERSION"}, + {"no release contract", validConstants, validPin, "", nil, releaseContractMissingSentinel + ": project runner release uloop-project-runner-v3.0.0-beta.33 does not provide"}, + {"invalid release contract", validConstants, validPin, "{", nil, "project runner release contract is invalid JSON"}, + {"unpublished release", validConstants, validPin, validRelease, map[string]string{"GH_FAIL_RELEASE_VIEW": "1"}, "is not published with complete native assets"}, + {"invalid release metadata", validConstants, validPin, validRelease, map[string]string{"GH_RELEASE_VIEW": "{"}, "metadata is invalid JSON"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + workDir := setupProtocolMinimumVersionMocks(t, protocolMinimumVersionRefCase{releaseContent: testCase.release}) + writeProtocolMinimumVersionWorkingTree(t, workDir, testCase.constants, testCase.pin) + for key, value := range testCase.environment { + t.Setenv(key, value) + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunMinimumCliReleaseProtocolCheck(context.Background(), &stdout, &stderr, "") + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", exitCode, stdout.String()) + } + assertProtocolMinimumVersionLogContains(t, stderr.String(), testCase.wantErr) + if testCase.name == "no required protocol" { + // The working-tree precondition must stop the check before any release is looked up. + gitLog, _ := os.ReadFile(filepath.Join(workDir, "git.log")) + if strings.Contains(string(gitLog), "show uloop-project-runner-v") { + t.Fatalf("expected no release lookup, got git log:\n%s", gitLog) + } + } + }) + } +} + +func TestRunMinimumCliReleaseProtocolCheck_WhenRefCannotBeRead_Fails(t *testing.T) { + // Verifies an unreadable ref fails instead of falling back to the working tree. + setupProtocolMinimumVersionMocks(t, protocolMinimumVersionRefCase{}) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunMinimumCliReleaseProtocolCheck(context.Background(), &stdout, &stderr, "unknown-ref") + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertProtocolMinimumVersionLogContains(t, stderr.String(), "failed to read "+protocolMinimumVersionFile) +} + +func TestRunMinimumCliReleaseProtocolCheck_WhenGitIsUnavailable_Fails(t *testing.T) { + // Verifies a repository root lookup failure fails the check. + t.Setenv("PATH", t.TempDir()) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunMinimumCliReleaseProtocolCheck(context.Background(), &stdout, &stderr, "") + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertProtocolMinimumVersionLogContains(t, stderr.String(), "failed to resolve git repository root") +} + +func TestRunProtocolMinimumVersionGuard_FailsWhenRefsCannotBeAnalyzed(t *testing.T) { + // Verifies a missing base ref, an unreadable base or head ref, and an unavailable git each fail the guard. + cases := []struct { + name string + config ProtocolMinimumVersionGuardConfig + noGit bool + wantErr string + }{ + {"missing base", ProtocolMinimumVersionGuardConfig{}, false, "--base is required"}, + {"unreadable base", ProtocolMinimumVersionGuardConfig{BaseRef: "unknown-ref", HeadRef: "protocol-pr-head"}, false, "unknown-ref"}, + {"unreadable default head", ProtocolMinimumVersionGuardConfig{BaseRef: "origin/v3-beta"}, false, "HEAD:"}, + {"git unavailable", ProtocolMinimumVersionGuardConfig{BaseRef: "origin/v3-beta"}, true, "failed to resolve git repository root"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + setupProtocolMinimumVersionMocks(t, protocolMinimumVersionRefCase{ + baseProtocol: 1, baseProjectRunner: "3.0.0-beta.32", headProtocol: 1, headProjectRunner: "3.0.0-beta.32", + }) + if testCase.noGit { + t.Setenv("PATH", t.TempDir()) + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunProtocolMinimumVersionGuard(context.Background(), &stdout, &stderr, testCase.config) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", exitCode, stdout.String()) + } + assertProtocolMinimumVersionLogContains(t, stderr.String(), testCase.wantErr) + }) + } +} + +func TestVerifyMinimumProjectRunnerReleaseProtocol_WhenRequiredProtocolIsMissing_Fails(t *testing.T) { + // Verifies the release check refuses to compare against a package that declares no required protocol. + err := verifyMinimumProjectRunnerReleaseProtocol("uloop-project-runner-v3.0.0", ProtocolMinimumVersionValues{}, []byte(`{"protocolVersion":2}`)) + + if err == nil || !strings.Contains(err.Error(), "does not define REQUIRED_CLI_PROTOCOL_VERSION") { + t.Fatalf("expected a missing required protocol error, got %v", err) + } +} + +func TestProtocolMinimumVersionValueLabel_WhenRequiredProtocolIsMissing_ShowsPlaceholder(t *testing.T) { + // Verifies a missing required protocol is rendered as a placeholder rather than as protocol 0. + if label := protocolMinimumVersionValueLabel(ProtocolMinimumVersionValues{}); label != "``" { + t.Fatalf("label = %q", label) + } +} diff --git a/cli/release-automation/internal/automation/release_pr_checks_test.go b/cli/release-automation/internal/automation/release_pr_checks_test.go index d92523a703..f387618685 100644 --- a/cli/release-automation/internal/automation/release_pr_checks_test.go +++ b/cli/release-automation/internal/automation/release_pr_checks_test.go @@ -942,3 +942,172 @@ func TestReleasePRChecksLeaveNonPackageComponentBodyUnchanged(t *testing.T) { assertReleasePRCheckLogDoesNotContain(t, commandLogText, "gh pr view 2001") assertReleasePRCheckLogDoesNotContain(t, commandLogText, "gh pr edit 2001") } + +const ( + releasePRCheckCombinedPRList = `[{"number":2000,"headRefName":"release-please--branches--main","headRefOid":"dispatcher123","title":"chore(main): release 3.6.0","url":"https://example.test/pr/2000"}]` + releasePRCheckDispatcherPRList = `[{"number":2001,"headRefName":"release-please--branches--main--components--dispatcher","headRefOid":"dispatcher123","title":"chore(main): release dispatcher 3.6.0","url":"https://example.test/pr/2001"}]` +) + +// releasePRCheckFailureStub answers like runMultiReleasePRCheck's stub, but fails the first command +// line starting with failOn and can return a different listing for the second gh pr list call. +type releasePRCheckFailureStub struct { + prList string + secondPRList string + failOn string + viewBody string + // failReady fails only the final gh pr ready call, which has no --undo flag. + failReady bool + prListCalls int +} + +func (stub *releasePRCheckFailureStub) run(_ context.Context, name string, args ...string) (string, error) { + commandLine := strings.Join(append([]string{name}, args...), " ") + if stub.failOn != "" && strings.HasPrefix(commandLine, stub.failOn) { + return "", fmt.Errorf("%s failed", stub.failOn) + } + if stub.failReady && strings.HasPrefix(commandLine, "gh pr ready ") && !strings.HasSuffix(commandLine, "--undo") { + return "", fmt.Errorf("gh pr ready failed") + } + switch { + case strings.HasPrefix(commandLine, "gh pr list "): + stub.prListCalls++ + if stub.prListCalls > 1 && stub.secondPRList != "" { + return stub.secondPRList, nil + } + return stub.prList, nil + case strings.HasPrefix(commandLine, "gh pr view "): + return stub.viewBody, nil + case strings.HasPrefix(commandLine, "gh run list "): + runID := multiReleasePRCheckRunID(commandLine) + return `[{"databaseId":` + runID + `,"headSha":"` + multiReleasePRCheckHeadSHA(commandLine) + + `","createdAt":"2026-09-08T01:00:01Z","status":"queued","conclusion":"","url":"https://example.test/run/` + runID + `"}]`, nil + } + return "", nil +} + +func runReleasePRCheckFailureStub(t *testing.T, stub *releasePRCheckFailureStub) (int, string, string) { + t.Helper() + t.Setenv("GITHUB_REPOSITORY", "owner/repository") + t.Setenv("TARGET_BRANCH", "main") + t.Setenv("RELEASE_PR_CHECK_WORKFLOWS", "") + t.Setenv("RELEASE_PR_CHECK_LOOKUP_ATTEMPTS", "1") + t.Setenv("RELEASE_PR_CHECK_LOOKUP_INTERVAL_SECONDS", "") + t.Setenv("RELEASE_PR_CHECK_WATCH_INTERVAL_SECONDS", "1") + if stub.viewBody == "" { + stub.viewBody = `{"body":"
3.6.0\n
\n"}` + } + deps := releasePRCheckDeps{ + now: func() time.Time { return time.Date(2026, 9, 8, 1, 0, 0, 0, time.UTC) }, + sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() }, + runOutput: stub.run, + } + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := runReleasePleasePRChecksWithDeps(context.Background(), &stdout, &stderr, deps) + return exitCode, stdout.String(), stderr.String() +} + +// Verifies each failing gh step, an unparsable PR body, and a PR that disappears before ready fail the release PR instead of marking it ready. +func TestReleasePRChecksFailOnEachFailingStep(t *testing.T) { + cases := []struct { + name string + stub releasePRCheckFailureStub + wantErr string + }{ + {"body view", releasePRCheckFailureStub{prList: releasePRCheckCombinedPRList, failOn: "gh pr view"}, "gh pr view failed"}, + {"body JSON", releasePRCheckFailureStub{prList: releasePRCheckCombinedPRList, viewBody: "{"}, "failed to parse release PR body"}, + {"body edit", releasePRCheckFailureStub{prList: releasePRCheckCombinedPRList, failOn: "gh pr edit"}, "gh pr edit failed"}, + {"mark draft", releasePRCheckFailureStub{prList: releasePRCheckDispatcherPRList, failOn: "gh pr ready 2001 --repo owner/repository --undo"}, "--undo failed"}, + {"workflow dispatch", releasePRCheckFailureStub{prList: releasePRCheckDispatcherPRList, failOn: "gh workflow run"}, "gh workflow run failed"}, + {"pull request gone", releasePRCheckFailureStub{prList: releasePRCheckDispatcherPRList, secondPRList: `[]`}, "release PR #2001 is no longer pending before marking ready"}, + {"pull request relookup", releasePRCheckFailureStub{prList: releasePRCheckDispatcherPRList, secondPRList: "{"}, "failed to parse release PR list"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + stub := testCase.stub + + exitCode, stdout, stderr := runReleasePRCheckFailureStub(t, &stub) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", exitCode, stdout) + } + assertReleasePRCheckLogContains(t, stderr, testCase.wantErr) + assertReleasePRCheckLogDoesNotContain(t, stdout, "as ready after checks passed") + }) + } +} + +// Verifies a failing gh pr ready after green checks fails the release PR with the gh error. +func TestReleasePRChecksFailWhenMarkingReadyFails(t *testing.T) { + stub := releasePRCheckFailureStub{prList: releasePRCheckDispatcherPRList, failReady: true} + + exitCode, stdout, stderr := runReleasePRCheckFailureStub(t, &stub) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d\nstdout: %s", exitCode, stdout) + } + assertReleasePRCheckLogContains(t, stderr, "gh pr ready failed") +} + +// Verifies a missing repository or target branch and a non-positive or non-numeric interval each fail the configuration. +func TestReleasePRCheckConfigFromEnvironmentRejectsInvalidValues(t *testing.T) { + cases := []struct { + name string + environment map[string]string + wantErr string + }{ + {"missing repository", map[string]string{"GITHUB_REPOSITORY": ""}, "GITHUB_REPOSITORY is required"}, + {"missing target branch", map[string]string{"TARGET_BRANCH": ""}, "TARGET_BRANCH is required"}, + {"zero lookup attempts", map[string]string{"RELEASE_PR_CHECK_LOOKUP_ATTEMPTS": "0"}, "RELEASE_PR_CHECK_LOOKUP_ATTEMPTS must be a positive integer"}, + {"non-numeric lookup interval", map[string]string{"RELEASE_PR_CHECK_LOOKUP_INTERVAL_SECONDS": "soon"}, "RELEASE_PR_CHECK_LOOKUP_INTERVAL_SECONDS must be a positive integer"}, + {"negative watch interval", map[string]string{"RELEASE_PR_CHECK_WATCH_INTERVAL_SECONDS": "-1"}, "RELEASE_PR_CHECK_WATCH_INTERVAL_SECONDS must be a positive integer"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv("GITHUB_REPOSITORY", "owner/repository") + t.Setenv("TARGET_BRANCH", "main") + t.Setenv("RELEASE_PR_CHECK_WORKFLOWS", "") + t.Setenv("RELEASE_PR_CHECK_LOOKUP_ATTEMPTS", "") + t.Setenv("RELEASE_PR_CHECK_LOOKUP_INTERVAL_SECONDS", "") + t.Setenv("RELEASE_PR_CHECK_WATCH_INTERVAL_SECONDS", "") + for key, value := range testCase.environment { + t.Setenv(key, value) + } + + _, err := releasePRCheckConfigFromEnvironment() + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// Verifies the production sleep returns after its duration and returns the context error when cancelled first. +func TestReleasePRCheckSleepHonorsDurationAndCancellation(t *testing.T) { + if err := releasePRCheckSleep(context.Background(), time.Nanosecond); err != nil { + t.Fatalf("expected a completed sleep, got %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := releasePRCheckSleep(ctx, time.Hour); err != context.Canceled { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +// Verifies an unusable temporary directory fails the body clarification instead of editing the PR with an empty body. +func TestReleasePRChecksFailWhenBodyFileCannotBeCreated(t *testing.T) { + missingDirectory := filepath.Join(t.TempDir(), "missing") + t.Setenv("TMPDIR", missingDirectory) + t.Setenv("TMP", missingDirectory) + t.Setenv("TEMP", missingDirectory) + stub := releasePRCheckFailureStub{prList: releasePRCheckCombinedPRList} + + exitCode, _, stderr := runReleasePRCheckFailureStub(t, &stub) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + assertReleasePRCheckLogContains(t, stderr, "failed to create pull request body file") +} diff --git a/cli/release-automation/internal/automation/release_trigger_guard_test.go b/cli/release-automation/internal/automation/release_trigger_guard_test.go index 509d6f6f90..d86a5f4271 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard_test.go +++ b/cli/release-automation/internal/automation/release_trigger_guard_test.go @@ -1,6 +1,7 @@ package automation import ( + "bytes" "context" "os" "os/exec" @@ -430,3 +431,168 @@ func runGitInRepoOutput(t *testing.T, repoRoot string, args ...string) string { } return string(output) } + +// releaseTriggerGuardRepo is a temporary git repository whose first commit is tagged base and holds the initial catalog. +type releaseTriggerGuardRepo struct { + t *testing.T + root string +} + +func newReleaseTriggerGuardRepo(t *testing.T) releaseTriggerGuardRepo { + t.Helper() + repo := releaseTriggerGuardRepo{t: t, root: t.TempDir()} + runGitInRepo(t, repo.root, "init", "-b", "main") + repo.commitFile(CatalogRelativePath, mergeBaseCatalogInitial+"\n") + runGitInRepo(t, repo.root, "tag", "base") + t.Chdir(repo.root) + return repo +} + +func (repo releaseTriggerGuardRepo) commitFile(relativePath string, content string) { + repo.t.Helper() + absolutePath := filepath.Join(repo.root, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { + repo.t.Fatalf("failed to create %s directory: %v", relativePath, err) + } + if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil { + repo.t.Fatalf("failed to write %s: %v", relativePath, err) + } + runGitInRepo(repo.t, repo.root, "add", relativePath) + runGitInRepo(repo.t, repo.root, "commit", "-m", "change "+relativePath) +} + +func (repo releaseTriggerGuardRepo) removeFile(relativePath string) { + repo.t.Helper() + runGitInRepo(repo.t, repo.root, "rm", "-q", relativePath) + runGitInRepo(repo.t, repo.root, "commit", "-m", "remove "+relativePath) +} + +func runReleaseTriggerGuardForTest(config ReleaseTriggerGuardConfig) (int, string, string) { + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + exitCode := RunReleaseTriggerGuard(context.Background(), &stdout, &stderr, config) + return exitCode, stdout.String(), stderr.String() +} + +// Verifies changes outside the shared release inputs pass, and an empty head ref compares against HEAD. +func TestRunReleaseTriggerGuardPassesForUnrelatedChangesUpToHead(t *testing.T) { + repo := newReleaseTriggerGuardRepo(t) + repo.commitFile("docs/notes.md", "notes\n") + + exitCode, stdout, stderr := runReleaseTriggerGuardForTest(ReleaseTriggerGuardConfig{BaseRef: "base"}) + + if exitCode != 0 || !strings.Contains(stdout, "Release trigger guard passed.") { + t.Fatalf("exit code = %d, stdout = %q, stderr = %q", exitCode, stdout, stderr) + } +} + +// Verifies a committed shared common change without trigger updates fails with the warning naming the change. +func TestRunReleaseTriggerGuardFailsForUntriggeredSharedChange(t *testing.T) { + repo := newReleaseTriggerGuardRepo(t) + repo.commitFile("cli/common/clicore/output.go", "package clicore\n") + + exitCode, _, stderr := runReleaseTriggerGuardForTest(ReleaseTriggerGuardConfig{BaseRef: "base", HeadRef: "HEAD"}) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr, "Out-of-package release inputs changed without matching release triggers.") || + !strings.Contains(stderr, "- `cli/common/clicore/output.go`") { + t.Fatalf("expected the trigger warning naming the change, got %q", stderr) + } +} + +// Verifies a description-only catalog change is reported as skipped and does not demand release triggers. +func TestRunReleaseTriggerGuardSkipsDescriptionOnlyCatalogChange(t *testing.T) { + repo := newReleaseTriggerGuardRepo(t) + repo.commitFile(CatalogRelativePath, mergeBaseCatalogDescriptionOnly+"\n") + + exitCode, stdout, stderr := runReleaseTriggerGuardForTest(ReleaseTriggerGuardConfig{BaseRef: "base"}) + + if exitCode != 0 || !strings.Contains(stdout, "catalog changed only in descriptions; not counted as a shared release input") { + t.Fatalf("exit code = %d, stdout = %q, stderr = %q", exitCode, stdout, stderr) + } +} + +// Verifies a catalog shape change and a catalog removed at head are both kept as shared input changes. +func TestAnalyzeReleaseTriggerGuardForRefsKeepsCatalogUnlessOnlyDescriptionsChanged(t *testing.T) { + cases := []struct { + name string + change func(releaseTriggerGuardRepo) + }{ + {"shape change", func(repo releaseTriggerGuardRepo) { + repo.commitFile(CatalogRelativePath, mergeBaseCatalogStructural+"\n") + }}, + {"removed at head", func(repo releaseTriggerGuardRepo) { repo.removeFile(CatalogRelativePath) }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + repo := newReleaseTriggerGuardRepo(t) + testCase.change(repo) + + result, err := AnalyzeReleaseTriggerGuardForRefs(context.Background(), ReleaseTriggerGuardConfig{BaseRef: "base"}) + if err != nil { + t.Fatalf("AnalyzeReleaseTriggerGuardForRefs failed: %v", err) + } + if result.DescriptionOnlyCatalogSkipped || len(result.Violations) != 1 || result.Violations[0].ChangedInputs[0] != CatalogRelativePath { + t.Fatalf("expected the catalog to stay a shared input change, got %+v", result) + } + }) + } +} + +// Verifies a catalog added after the base is kept as a shared input change because there is no base shape to compare. +func TestAnalyzeReleaseTriggerGuardForRefsKeepsCatalogAddedAfterBase(t *testing.T) { + repoRoot := t.TempDir() + runGitInRepo(t, repoRoot, "init", "-b", "main") + repo := releaseTriggerGuardRepo{t: t, root: repoRoot} + repo.commitFile("docs/notes.md", "notes\n") + runGitInRepo(t, repoRoot, "tag", "base") + repo.commitFile(CatalogRelativePath, mergeBaseCatalogInitial+"\n") + t.Chdir(repoRoot) + + result, err := AnalyzeReleaseTriggerGuardForRefs(context.Background(), ReleaseTriggerGuardConfig{BaseRef: "base"}) + if err != nil { + t.Fatalf("AnalyzeReleaseTriggerGuardForRefs failed: %v", err) + } + if result.DescriptionOnlyCatalogSkipped || len(result.Violations) != 1 || result.Violations[0].ChangedInputs[0] != CatalogRelativePath { + t.Fatalf("expected the added catalog to stay a shared input change, got %+v", result) + } +} + +// Verifies a missing base, an unknown base, an unparsable catalog, and a directory outside git each fail with their own error. +func TestRunReleaseTriggerGuardReportsUnusableInputs(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T) + config ReleaseTriggerGuardConfig + wantErr string + }{ + {"missing base", func(t *testing.T) { newReleaseTriggerGuardRepo(t) }, ReleaseTriggerGuardConfig{}, "--base is required"}, + {"unknown base", func(t *testing.T) { newReleaseTriggerGuardRepo(t) }, ReleaseTriggerGuardConfig{BaseRef: "missing-ref"}, "failed to inspect changed files"}, + {"unparsable catalog", func(t *testing.T) { newReleaseTriggerGuardRepo(t).commitFile(CatalogRelativePath, "{\n") }, ReleaseTriggerGuardConfig{BaseRef: "base"}, "failed to compare catalog shape"}, + {"outside git", func(t *testing.T) { t.Chdir(t.TempDir()) }, ReleaseTriggerGuardConfig{BaseRef: "base"}, "failed to resolve git repository root"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + testCase.setup(t) + + exitCode, _, stderr := runReleaseTriggerGuardForTest(testCase.config) + + if exitCode != 1 || !strings.Contains(stderr, testCase.wantErr) { + t.Fatalf("exit code = %d, expected stderr containing %q, got %q", exitCode, testCase.wantErr, stderr) + } + }) + } +} + +// Verifies an unknown base ref fails the catalog merge-base lookup with its own error when the catalog is in the change list. +func TestFilterDescriptionOnlyCatalogChangeReportsUnknownBase(t *testing.T) { + repo := newReleaseTriggerGuardRepo(t) + + _, _, err := filterDescriptionOnlyCatalogChange(context.Background(), repo.root, ReleaseTriggerGuardConfig{BaseRef: "missing-ref", HeadRef: "HEAD"}, []string{CatalogRelativePath}) + + if err == nil || !strings.Contains(err.Error(), "failed to resolve merge-base for catalog shape") { + t.Fatalf("expected a merge-base error, got %v", err) + } +} diff --git a/cli/release-automation/internal/automation/testdata/mockcli/main.go b/cli/release-automation/internal/automation/testdata/mockcli/main.go index b80aff72f9..475cbe4080 100644 --- a/cli/release-automation/internal/automation/testdata/mockcli/main.go +++ b/cli/release-automation/internal/automation/testdata/mockcli/main.go @@ -382,6 +382,10 @@ func runProtocolMinimumVersionGh(args []string) int { appendLogLine("GH_LOG", args) if argAt(args, 0) == "release" && argAt(args, 1) == "view" { + if os.Getenv("GH_FAIL_RELEASE_VIEW") != "" { + fmt.Fprintln(os.Stderr, "release not found") + return 1 + } releaseView := os.Getenv("GH_RELEASE_VIEW") if releaseView == "" { releaseView = `{"isDraft":false,"assets":[{"name":"uloop-project-runner-darwin-amd64.tar.gz","size":1},{"name":"uloop-project-runner-darwin-amd64.tar.gz.sha256","size":1},{"name":"uloop-project-runner-darwin-arm64.tar.gz","size":1},{"name":"uloop-project-runner-darwin-arm64.tar.gz.sha256","size":1},{"name":"uloop-project-runner-windows-amd64.zip","size":1},{"name":"uloop-project-runner-windows-amd64.zip.sha256","size":1}]}` @@ -390,7 +394,21 @@ func runProtocolMinimumVersionGh(args []string) int { return 0 } + if argAt(args, 0) == "repo" && argAt(args, 1) == "view" { + repoView := os.Getenv("GH_REPO_VIEW") + if repoView == "" { + fmt.Fprintln(os.Stderr, "repo view failed") + return 1 + } + fmt.Fprintf(os.Stdout, "%s\n", repoView) + return 0 + } + if argAt(args, 0) == "api" && argAt(args, 1) == "--paginate" { + if os.Getenv("GH_FAIL_PAGINATE") != "" { + fmt.Fprintln(os.Stderr, "comment lookup failed") + return 1 + } if commentIDs := os.Getenv("GH_COMMENT_IDS"); commentIDs != "" { fmt.Fprintf(os.Stdout, "%s\n", commentIDs) } @@ -398,6 +416,16 @@ func runProtocolMinimumVersionGh(args []string) int { } if argAt(args, 0) == "api" && argAt(args, 1) == "--method" { + // The comment body travels in the --input file, which is removed after the call, so it is + // copied into the log for the tests to read, matching the Unix mock gh. + if argAt(args, 4) == "--input" && argAt(args, 5) != "" { + input, err := os.ReadFile(argAt(args, 5)) + if err != nil { + fmt.Fprintf(os.Stderr, "mockcli: failed to read --input: %v\n", err) + return 1 + } + appendLogLine("GH_LOG", []string{"input:", strings.ReplaceAll(string(input), "\n", "")}) + } return 0 } From 42054fb6907aaf1aaf86cd115923a759d4bff5a5 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:45:07 +0900 Subject: [PATCH 12/68] chore: File length checks now count C# lines correctly after escaped quotes in interpolated verbatim strings (#3089) --- .../internal/automation/file_length_sloc.go | 26 +++++++------------ .../internal/automation/file_length_test.go | 20 ++++++++++++++ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/cli/release-automation/internal/automation/file_length_sloc.go b/cli/release-automation/internal/automation/file_length_sloc.go index 20b7768fc8..208a7d48ea 100644 --- a/cli/release-automation/internal/automation/file_length_sloc.go +++ b/cli/release-automation/internal/automation/file_length_sloc.go @@ -276,28 +276,22 @@ func (s *slocScanner) scanCSharpInterpolatedString(verbatim bool) { s.consumeEscapedRune() continue } - if s.consumeInterpolatedQuote(current, verbatim) { + // A doubled quote in a verbatim string is an escaped quote; it is consumed whole and the scan + // continues, so the character after it is read as string content rather than skipped. + if verbatim && current == '"' && s.peekRuneAt(1) == '"' { + s.markCode() + s.skipRunes(2) + continue + } + if current == '"' { + s.markCode() + s.nextRune() return } s.consumeStringRune(current) } } -func (s *slocScanner) consumeInterpolatedQuote(current rune, verbatim bool) bool { - if current != '"' { - return false - } - if verbatim && s.peekRuneAt(1) == '"' { - s.markCode() - s.nextRune() - s.nextRune() - return false - } - s.markCode() - s.nextRune() - return true -} - func (s *slocScanner) consumeInterpolationHole() { if s.peekRuneAt(1) == '{' { s.markCode() diff --git a/cli/release-automation/internal/automation/file_length_test.go b/cli/release-automation/internal/automation/file_length_test.go index 1a9f7526da..0cc507262b 100644 --- a/cli/release-automation/internal/automation/file_length_test.go +++ b/cli/release-automation/internal/automation/file_length_test.go @@ -228,3 +228,23 @@ func findingPaths(findings []FileLengthFinding) []string { } return paths } + +// Verifies a doubled quote inside an interpolated verbatim string ($@" or @$") is counted like one inside a plain verbatim +// string: the closing quote and the newline right after the doubled quote are not swallowed. +func TestCountSLOCHandlesDoubledQuotesInInterpolatedVerbatimStrings(t *testing.T) { + cases := []struct { + name string + source string + want int + }{ + {"closing quote after the doubled quote", "var s = PREFIX\"a\"\"\";\n// comment\nvar y = 1;\n", 2}, + {"newline after the doubled quote", "var s = PREFIX\"a\"\"\n// inside the string\n\";\n", 3}, + } + for _, testCase := range cases { + for _, prefix := range []string{"@", "$@", "@$"} { + t.Run(testCase.name+" "+prefix, func(t *testing.T) { + assertSLOC(t, strings.Replace(testCase.source, "PREFIX", prefix, 1), LanguageCSharp, testCase.want) + }) + } + } +} From f16fdd4cb52f2fc112ee79eafe55519659c31484 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:45:52 +0900 Subject: [PATCH 13/68] test(dispatcher): Cover skill install, sync, discovery, and dir-mode paths (#3084) --- .../dispatcher/skills_content_test.go | 29 ++ .../dispatcher/skills_dir_cleanup_test.go | 126 +++++++ .../dispatcher/skills_dir_status_test.go | 193 ++++++++++ .../internal/dispatcher/skills_dir_test.go | 197 ++++++++++ .../dispatcher/skills_dispatch_test.go | 338 ++++++++++++++++++ .../dispatcher/skills_display_test.go | 47 +++ .../internal/dispatcher/skills_sync_test.go | 189 ++++++++++ .../internal/dispatcher/skills_test.go | 305 ++++++++++++++++ 8 files changed, 1424 insertions(+) create mode 100644 cli/dispatcher/internal/dispatcher/skills_content_test.go create mode 100644 cli/dispatcher/internal/dispatcher/skills_dir_cleanup_test.go create mode 100644 cli/dispatcher/internal/dispatcher/skills_dir_status_test.go create mode 100644 cli/dispatcher/internal/dispatcher/skills_dispatch_test.go create mode 100644 cli/dispatcher/internal/dispatcher/skills_display_test.go create mode 100644 cli/dispatcher/internal/dispatcher/skills_sync_test.go diff --git a/cli/dispatcher/internal/dispatcher/skills_content_test.go b/cli/dispatcher/internal/dispatcher/skills_content_test.go new file mode 100644 index 0000000000..0b80c36bd6 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/skills_content_test.go @@ -0,0 +1,29 @@ +package dispatcher + +import ( + "bytes" + "testing" +) + +func TestNormalizeSkillFileContentHandlesUTF16AndBinaryVariants(t *testing.T) { + // Verifies CR line endings are normalized per encoding while binary-looking content is left untouched. + cases := []struct { + name string + content []byte + want []byte + }{ + {name: "UTF-16 BE without BOM", content: []byte("\x00a\x00\r\x00\n\x00b"), want: []byte("\x00a\x00\n\x00b")}, + {name: "UTF-16 BE with BOM", content: []byte("\xfe\xff\x00a\x00\r\x00\n"), want: []byte("\xfe\xff\x00a\x00\n")}, + {name: "UTF-16 LE lone CR", content: []byte("a\x00\r\x00b\x00"), want: []byte("a\x00\n\x00b\x00")}, + {name: "UTF-16 LE odd trailing byte", content: []byte("a\x00\r\x00\n\x00z"), want: []byte("a\x00\n\x00z")}, + {name: "NUL without UTF-16 line endings", content: []byte("a\r\x00b"), want: []byte("a\r\x00b")}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got := normalizeSkillFileContent("SKILL.md", testCase.content) + if !bytes.Equal(got, testCase.want) { + t.Fatalf("normalized content mismatch: got %q want %q", got, testCase.want) + } + }) + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_dir_cleanup_test.go b/cli/dispatcher/internal/dispatcher/skills_dir_cleanup_test.go new file mode 100644 index 0000000000..f224b3f711 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/skills_dir_cleanup_test.go @@ -0,0 +1,126 @@ +package dispatcher + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestSkillDirCleanupHelpersTreatMissingDirectoryAsDone(t *testing.T) { + // Verifies cleanup of an already removed skill directory succeeds without creating anything. + missingDir := filepath.Join(t.TempDir(), "uloop-sample") + + if err := removeSkillDirWithIgnorableDebris(missingDir, nil); err != nil { + t.Fatalf("removeSkillDirWithIgnorableDebris failed: %v", err) + } + if err := removeSkillDirIfEmpty(missingDir); err != nil { + t.Fatalf("removeSkillDirIfEmpty failed: %v", err) + } + if removed, err := removeStaleSyncArtifacts(missingDir, nil); err != nil || removed { + t.Fatalf("unexpected result: removed=%t err=%v", removed, err) + } + if fileExists(missingDir) { + t.Fatal("cleanup must not create the missing directory") + } +} + +func TestSkillDirCleanupHelpersReportUnlistableDirectory(t *testing.T) { + // Verifies cleanup reports a skill path that exists but cannot be listed instead of treating it as removed. + if runtime.GOOS == "windows" { + t.Skip("Windows reports listing a file differently from ENOTDIR.") + } + skillFile := filepath.Join(t.TempDir(), "uloop-sample") + writeDispatcherTestFile(t, skillFile, "not a directory") + wantSuffix := "uloop-sample: not a directory" + + if err := removeSkillDirWithIgnorableDebris(skillFile, nil); err == nil || !strings.HasSuffix(err.Error(), wantSuffix) { + t.Fatalf("removeSkillDirWithIgnorableDebris: expected %q, got %v", wantSuffix, err) + } + if err := removeSkillDirIfEmpty(skillFile); err == nil || !strings.HasSuffix(err.Error(), wantSuffix) { + t.Fatalf("removeSkillDirIfEmpty: expected %q, got %v", wantSuffix, err) + } + if _, err := removeStaleSyncArtifacts(skillFile, nil); err == nil || !strings.HasSuffix(err.Error(), wantSuffix) { + t.Fatalf("removeStaleSyncArtifacts: expected %q, got %v", wantSuffix, err) + } + if !fileExists(skillFile) { + t.Fatal("the unlistable path must be left in place") + } +} + +func TestRemoveSkillDirIfEmptyRemovesOnlyEmptyDirectory(t *testing.T) { + // Verifies an empty skill directory is removed while one with any entry is kept. + emptyDir := filepath.Join(t.TempDir(), "empty") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", emptyDir, err) + } + keptDir := filepath.Join(t.TempDir(), "kept") + writeDispatcherTestFile(t, filepath.Join(keptDir, "user.txt"), "mine") + + if err := removeSkillDirIfEmpty(emptyDir); err != nil || fileExists(emptyDir) { + t.Fatalf("expected the empty directory to be removed: err=%v", err) + } + if err := removeSkillDirIfEmpty(keptDir); err != nil || !fileExists(filepath.Join(keptDir, "user.txt")) { + t.Fatalf("expected the non-empty directory to be kept: err=%v", err) + } +} + +func TestRemoveEntryOfWrongTypeReplacesDirectoryOccupant(t *testing.T) { + // Verifies a directory at a name the source owns as a file is removed with its contents. + occupant := filepath.Join(t.TempDir(), "notes.md") + writeDispatcherTestFile(t, filepath.Join(occupant, "nested", "file.txt"), "x") + + if err := removeEntryOfWrongType(occupant, false); err != nil { + t.Fatalf("removeEntryOfWrongType failed: %v", err) + } + if fileExists(occupant) { + t.Fatal("the directory occupant must be removed") + } +} + +func TestRemoveEntryOfWrongTypeReportsUninspectableOccupant(t *testing.T) { + // Verifies an occupant that cannot be inspected is reported instead of being treated as absent. + if runtime.GOOS == "windows" { + t.Skip("Windows reports a path below a file as not found rather than ENOTDIR.") + } + parentFile := filepath.Join(t.TempDir(), "file") + writeDispatcherTestFile(t, parentFile, "x") + + err := removeEntryOfWrongType(filepath.Join(parentFile, "notes.md"), false) + + if err == nil || !strings.HasPrefix(err.Error(), "lstat "+parentFile) { + t.Fatalf("expected the lstat error, got %v", err) + } +} + +func TestRemoveStaleSyncArtifactsReportsUnremovableArtifact(t *testing.T) { + // Verifies a stale sync artifact that cannot be removed is reported instead of being counted as removed. + skillDir := filepath.Join(t.TempDir(), "uloop-sample") + artifactDir := filepath.Join(skillDir, "references"+skillSyncBackupSuffix+"123") + writeDispatcherTestFile(t, filepath.Join(artifactDir, "note.md"), "note") + lockSkillsTestDirectory(t, artifactDir, 0o555) + + removed, err := removeStaleSyncArtifacts(skillDir, nil) + + if removed || err == nil || !strings.HasPrefix(err.Error(), "unlinkat "+filepath.Join(artifactDir, "note.md")) { + t.Fatalf("expected the removal failure: removed=%t err=%v", removed, err) + } +} + +func TestIsStaleSyncArtifactNameRequiresDigitTail(t *testing.T) { + // Verifies only the marker followed by digits is treated as a sync artifact. + cases := map[string]bool{ + "SKILL.md" + skillSyncTempSuffix + "42": true, + "references" + skillSyncBackupSuffix + "7": true, + "SKILL.md" + skillSyncTempSuffix: false, + "archive" + skillSyncTempSuffix + "keepme": false, + "plain-notes.md": false, + "references" + skillSyncBackupSuffix + "7a": false, + } + for name, want := range cases { + if got := isStaleSyncArtifactName(name); got != want { + t.Fatalf("isStaleSyncArtifactName(%q) = %t, want %t", name, got, want) + } + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_dir_status_test.go b/cli/dispatcher/internal/dispatcher/skills_dir_status_test.go new file mode 100644 index 0000000000..8fe3512c29 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/skills_dir_status_test.go @@ -0,0 +1,193 @@ +package dispatcher + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// installDirModeSkillWithNotes installs a dir-mode skill whose source also owns a +// top-level notes.md, so per-file comparisons beyond SKILL.md can be exercised. +func installDirModeSkillWithNotes(t *testing.T) (skillDefinition, string) { + t.Helper() + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + writeDispatcherTestFile(t, filepath.Join(skill.sourceDirectory, "notes.md"), "notes\n") + store := filepath.Join(root, "store") + if err := syncSkillDirectoryPreservingForeignFiles(skill.sourceDirectory, filepath.Join(store, skill.name)); err != nil { + t.Fatalf("setup sync failed: %v", err) + } + assertDirSkillStatus(t, store, skill, "installed") + return skill, store +} + +func assertDirSkillStatus(t *testing.T, store string, skill skillDefinition, want string) { + t.Helper() + state, err := getDirSkillState(store, skill) + if err != nil { + t.Fatalf("getDirSkillState failed: %v", err) + } + if state.status != want { + t.Fatalf("status mismatch: got %q want %q (reason %q)", state.status, want, state.conflictReason) + } +} + +func TestGetDirSkillStateMarksDivergedOwnedFilesOutdated(t *testing.T) { + // Verifies an installed owned file that differs, cannot be read, or is no longer a regular file makes the skill outdated. + cases := []struct { + name string + modify func(t *testing.T, installedDir string) + }{ + { + name: "different content", + modify: func(t *testing.T, installedDir string) { + writeDispatcherTestFile(t, filepath.Join(installedDir, "notes.md"), "edited\n") + }, + }, + { + name: "unreadable file", + modify: func(t *testing.T, installedDir string) { + lockSkillsTestFile(t, filepath.Join(installedDir, "notes.md")) + }, + }, + { + // The symlink points at identical content, so only the regular-file check can report it. + name: "symlink with identical content", + modify: func(t *testing.T, installedDir string) { + replaceWithSymlinkToSameContent(t, filepath.Join(installedDir, "notes.md"), "notes\n") + }, + }, + { + name: "unreadable owned directory", + modify: func(t *testing.T, installedDir string) { + lockSkillsTestDirectory(t, filepath.Join(installedDir, "references"), 0o000) + }, + }, + { + name: "unreadable file in owned directory", + modify: func(t *testing.T, installedDir string) { + lockSkillsTestFile(t, filepath.Join(installedDir, "references", "note.md")) + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + skill, store := installDirModeSkillWithNotes(t) + testCase.modify(t, filepath.Join(store, skill.name)) + + assertDirSkillStatus(t, store, skill, "outdated") + }) + } +} + +func replaceWithSymlinkToSameContent(t *testing.T, path string, content string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires extra privileges on Windows.") + } + target := filepath.Join(t.TempDir(), "target.md") + writeDispatcherTestFile(t, target, content) + if err := os.Remove(path); err != nil { + t.Fatalf("failed to remove %s: %v", path, err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatalf("failed to create symlink: %v", err) + } +} + +func TestGetDirSkillStateReportsUnreadableInstalledSkillFileAsConflict(t *testing.T) { + // Verifies an unreadable installed SKILL.md is reported per skill as a conflict instead of aborting the run. + skill, store := installDirModeSkillWithNotes(t) + lockSkillsTestFile(t, filepath.Join(store, skill.name, "SKILL.md")) + + state, err := getDirSkillState(store, skill) + if err != nil { + t.Fatalf("getDirSkillState failed: %v", err) + } + if state.status != "conflict" || !strings.Contains(state.conflictReason, `cannot read SKILL.md for skill "uloop-sample"`) { + t.Fatalf("unexpected state: %+v", state) + } +} + +func TestGetDirSkillStateTreatsDirectoryWithOnlyForeignFilesAsNotInstalled(t *testing.T) { + // Verifies a skill directory holding nothing at source-owned names is not installed rather than a conflict. + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + store := filepath.Join(root, "store") + writeDispatcherTestFile(t, filepath.Join(store, skill.name, "user-notes.txt"), "mine") + + assertDirSkillStatus(t, store, skill, "not_installed") +} + +func TestGetDirSkillStateReportsUnreadableSources(t *testing.T) { + // Verifies a source-side read failure is returned as an error instead of being treated as a mismatch. + cases := []struct { + name string + removeSkill bool + lock func(t *testing.T, sourceDir string) + wantErrSuffix string + }{ + { + name: "source file", + lock: func(t *testing.T, sourceDir string) { lockSkillsTestFile(t, filepath.Join(sourceDir, "notes.md")) }, + wantErrSuffix: "notes.md: permission denied", + }, + { + name: "source directory", + lock: func(t *testing.T, sourceDir string) { + lockSkillsTestDirectory(t, filepath.Join(sourceDir, "references"), 0o000) + }, + wantErrSuffix: "references: permission denied", + }, + { + name: "file in source directory", + lock: func(t *testing.T, sourceDir string) { + lockSkillsTestFile(t, filepath.Join(sourceDir, "references", "note.md")) + }, + wantErrSuffix: "note.md: permission denied", + }, + { + // Without SKILL.md the install evidence check reads the source instead of the staleness check. + name: "source file during evidence check", + removeSkill: true, + lock: func(t *testing.T, sourceDir string) { lockSkillsTestFile(t, filepath.Join(sourceDir, "notes.md")) }, + wantErrSuffix: "notes.md: permission denied", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + skill, store := installDirModeSkillWithNotes(t) + if testCase.removeSkill { + if err := os.Remove(filepath.Join(store, skill.name, "SKILL.md")); err != nil { + t.Fatalf("failed to remove SKILL.md: %v", err) + } + } + testCase.lock(t, skill.sourceDirectory) + + _, err := getDirSkillState(store, skill) + + if err == nil || !strings.HasSuffix(err.Error(), testCase.wantErrSuffix) { + t.Fatalf("expected an error ending with %q, got %v", testCase.wantErrSuffix, err) + } + }) + } +} + +func TestGetDirSkillStateReportsUnreadableStore(t *testing.T) { + // Verifies a store path that exists but cannot be listed is an error, not "not installed". + if runtime.GOOS == "windows" { + t.Skip("Windows reports listing a file differently from ENOTDIR.") + } + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + storeFile := filepath.Join(root, "store-file") + writeDispatcherTestFile(t, storeFile, "not a directory") + + _, err := getDirSkillState(storeFile, skill) + + if err == nil || !strings.Contains(err.Error(), "store-file: not a directory") { + t.Fatalf("expected the store listing error, got %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_dir_test.go b/cli/dispatcher/internal/dispatcher/skills_dir_test.go index ef6a23e2dc..5b0cd363bc 100644 --- a/cli/dispatcher/internal/dispatcher/skills_dir_test.go +++ b/cli/dispatcher/internal/dispatcher/skills_dir_test.go @@ -1635,3 +1635,200 @@ func TestPathContainsDistinguishesDescendantFromSiblingPrefix(t *testing.T) { t.Fatal("an ancestor path must not be treated as contained") } } + +func TestSyncSkillDirectoryPreservingForeignFilesCopiesSourceWithoutSkillFile(t *testing.T) { + // Verifies a source without SKILL.md still syncs its other owned entries and skips .meta files. + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeDispatcherTestFile(t, filepath.Join(sourceDir, "references", "note.md"), "note") + writeDispatcherTestFile(t, filepath.Join(sourceDir, "references.meta"), "meta") + destinationDir := filepath.Join(t.TempDir(), "uloop-sample") + + if err := syncSkillDirectoryPreservingForeignFiles(sourceDir, destinationDir); err != nil { + t.Fatalf("sync failed: %v", err) + } + + assertFileContent(t, filepath.Join(destinationDir, "references", "note.md"), "note") + for _, unexpected := range []string{"references.meta", "SKILL.md"} { + if fileExists(filepath.Join(destinationDir, unexpected)) { + t.Fatalf("%s must not be written", unexpected) + } + } +} + +func TestSyncSkillDirectoryPreservingForeignFilesReportsFailures(t *testing.T) { + // Verifies dir-mode sync failures are returned and never replace the installed SKILL.md. + cases := []struct { + name string + setup func(t *testing.T, sourceDir string, destinationDir string) string + wantMessage string + }{ + { + name: "source missing", + wantMessage: "open ", + setup: func(t *testing.T, sourceDir string, destinationDir string) string { + return filepath.Join(t.TempDir(), "missing") + }, + }, + { + name: "source reference unreadable", + wantMessage: filepath.Join("Skill", "notes.md") + ": permission denied", + setup: func(t *testing.T, sourceDir string, destinationDir string) string { + writeDispatcherTestFile(t, filepath.Join(sourceDir, "notes.md"), "notes") + lockSkillsTestFile(t, filepath.Join(sourceDir, "notes.md")) + return sourceDir + }, + }, + { + name: "destination not writable", + wantMessage: filepath.Join("uloop-sample", "SKILL.md.uloop-tmp-"), + setup: func(t *testing.T, sourceDir string, destinationDir string) string { + lockSkillsTestDirectory(t, destinationDir, 0o555) + return sourceDir + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + destinationDir := filepath.Join(t.TempDir(), "uloop-sample") + effectiveSource := testCase.setup(t, sourceDir, destinationDir) + + err := syncSkillDirectoryPreservingForeignFiles(effectiveSource, destinationDir) + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected an error containing %q, got %v", testCase.wantMessage, err) + } + if fileExists(filepath.Join(destinationDir, "SKILL.md")) { + t.Fatal("SKILL.md must not be written when the sync fails") + } + }) + } +} + +func TestSyncSkillDirectoryPreservingForeignFilesReportsUncreatableDestination(t *testing.T) { + // Verifies a destination below a regular file fails before any entry is copied. + parentFile := filepath.Join(t.TempDir(), "file") + writeDispatcherTestFile(t, parentFile, "x") + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + + if err := syncSkillDirectoryPreservingForeignFiles(sourceDir, filepath.Join(parentFile, "uloop-sample")); err == nil || !strings.HasPrefix(err.Error(), "mkdir "+parentFile) { + t.Fatalf("expected a destination failure, got %v", err) + } +} + +func TestRunSkillsDirCommandsReportStoreThatIsAFile(t *testing.T) { + // Verifies dir-mode install and uninstall exit with code 1 and name the store when it is a regular file. + skipDispatcherTestOnWindows(t, "Windows reports a path below a file as not found, so the store reads as empty instead of failing.") + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + storeFile := filepath.Join(root, "store-file") + writeDispatcherTestFile(t, storeFile, "not a directory") + + cases := []struct { + name string + run func(stderr *bytes.Buffer) int + }{ + {name: "uninstall from a file", run: func(stderr *bytes.Buffer) int { + return runSkillsDirUninstall(storeFile, []skillDefinition{skill}, &bytes.Buffer{}, stderr) + }}, + {name: "install disabled skill into a file", run: func(stderr *bytes.Buffer) int { + return runSkillsDirInstall(storeFile, []skillDefinition{skill}, []string{"sample"}, &bytes.Buffer{}, stderr) + }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stderr bytes.Buffer + code := testCase.run(&stderr) + envelope := decodeDispatcherTestEnvelope(t, stderr.String()) + errorObject, _ := envelope["Error"].(map[string]any) + message, _ := errorObject["Message"].(string) + if code != 1 || !strings.Contains(message, storeFile) { + t.Fatalf("expected an error naming the store: code=%d stderr=%s", code, stderr.String()) + } + }) + } +} + +func TestRunSkillsDirCommandsReportUnreadableSkill(t *testing.T) { + // Verifies dir-mode install, list, and uninstall exit with code 1 when an installed skill cannot be read. + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + lockedStore := filepath.Join(root, "locked-store") + lockSkillsTestDirectory(t, filepath.Join(lockedStore, "uloop-sample"), 0o300) + wantMessage := filepath.Join("locked-store", "uloop-sample") + ": permission denied" + + cases := []struct { + name string + run func(stderr *bytes.Buffer) int + }{ + {name: "install", run: func(stderr *bytes.Buffer) int { + return runSkillsDirInstall(lockedStore, []skillDefinition{skill}, nil, &bytes.Buffer{}, stderr) + }}, + {name: "list", run: func(stderr *bytes.Buffer) int { + return runSkillsDirList(lockedStore, []skillDefinition{skill}, nil, &bytes.Buffer{}, stderr) + }}, + {name: "uninstall", run: func(stderr *bytes.Buffer) int { + return runSkillsDirUninstall(lockedStore, []skillDefinition{skill}, &bytes.Buffer{}, stderr) + }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stderr bytes.Buffer + if code := testCase.run(&stderr); code != 1 || !strings.Contains(stderr.String(), wantMessage) { + t.Fatalf("expected %q: code=%d stderr=%s", wantMessage, code, stderr.String()) + } + }) + } +} + +func TestRunSkillsDirCommandsReportMissingSource(t *testing.T) { + // Verifies dir-mode install and uninstall stop when the skill source cannot be listed, leaving the store untouched. + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + store := filepath.Join(root, "store") + if code := runSkillsDirInstall(store, []skillDefinition{skill}, nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 { + t.Fatalf("setup install failed: %d", code) + } + skill.content = []byte("---\nname: uloop-sample\n---\n\n# changed\n") + if err := os.RemoveAll(skill.sourceDirectory); err != nil { + t.Fatalf("failed to remove source: %v", err) + } + + for _, run := range []func(*bytes.Buffer) int{ + func(stderr *bytes.Buffer) int { + return runSkillsDirInstall(store, []skillDefinition{skill}, nil, &bytes.Buffer{}, stderr) + }, + func(stderr *bytes.Buffer) int { + return runSkillsDirUninstall(store, []skillDefinition{skill}, &bytes.Buffer{}, stderr) + }, + } { + var stderr bytes.Buffer + code := run(&stderr) + envelope := decodeDispatcherTestEnvelope(t, stderr.String()) + errorObject, _ := envelope["Error"].(map[string]any) + message, _ := errorObject["Message"].(string) + if code != 1 || !strings.Contains(message, filepath.Join("uloop-sample", "Skill")+": ") { + t.Fatalf("expected the missing source error: code=%d stderr=%s", code, stderr.String()) + } + } + assertFileContent(t, filepath.Join(store, "uloop-sample", "references", "note.md"), "note\n") +} + +func TestRunSkillsDirUninstallReportsBlockedRemoval(t *testing.T) { + // Verifies uninstall exits with code 1 when an owned directory cannot be removed. + root := t.TempDir() + skill := writeDirModeSkillSource(t, root, "uloop-sample") + store := filepath.Join(root, "store") + if code := runSkillsDirInstall(store, []skillDefinition{skill}, nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 { + t.Fatalf("setup install failed: %d", code) + } + lockSkillsTestDirectory(t, filepath.Join(store, "uloop-sample", "references"), 0o555) + var stderr bytes.Buffer + + code := runSkillsDirUninstall(store, []skillDefinition{skill}, &bytes.Buffer{}, &stderr) + + if code != 1 || !strings.Contains(stderr.String(), "unlinkat "+filepath.Join(store, "uloop-sample", "references", "note.md")) { + t.Fatalf("expected an error: code=%d stderr=%s", code, stderr.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go b/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go new file mode 100644 index 0000000000..88ffab8bc0 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/skills_dispatch_test.go @@ -0,0 +1,338 @@ +package dispatcher + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const sampleSkillContent = "---\nname: uloop-sample\n---\n\n# sample\n" + +// createSkillsTestProject creates a Unity project with one discoverable project skill. +func createSkillsTestProject(t *testing.T) string { + t.Helper() + projectRoot := createDispatcherUnityProject(t) + writeTestSkill(t, projectRoot, "Assets/Editor/SampleTool/Skill", sampleSkillContent) + return projectRoot +} + +func runSkillsRequestForTest(t *testing.T, startPath string, args ...string) (int, string, string) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + handled, code := tryHandleSkillsRequest(append([]string{"skills"}, args...), startPath, "", &stdout, &stderr) + if !handled { + t.Fatalf("skills request %v was not handled", args) + } + return code, stdout.String(), stderr.String() +} + +func stubSkillsUserHomeDir(t *testing.T, homeDir string, err error) { + t.Helper() + previous := userHomeDir + t.Cleanup(func() { + userHomeDir = previous + }) + userHomeDir = func() (string, error) { + return homeDir, err + } + // A regression that bypasses userHomeDir must land in a throwaway home, never the real one. + isolatedHome := t.TempDir() + t.Setenv("HOME", isolatedHome) + t.Setenv("USERPROFILE", isolatedHome) +} + +func TestTryHandleSkillsRequestInstallListUninstallRoundTrip(t *testing.T) { + // Verifies install, list, and uninstall through the public request entry point act on the project's skill. + projectRoot := createSkillsTestProject(t) + installedSkillFile := filepath.Join(projectRoot, ".claude", "skills", "uloop-sample", "SKILL.md") + + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, "install", "--claude") + if code != 0 || !strings.Contains(stdout, "Installed: 1") { + t.Fatalf("install failed: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + assertFileContent(t, installedSkillFile, sampleSkillContent) + + code, stdout, stderr = runSkillsRequestForTest(t, projectRoot, "list", "--claude") + if code != 0 || !strings.Contains(stdout, "uloop-sample (installed)") || !strings.Contains(stdout, "Claude Code (Project)") { + t.Fatalf("list failed: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + + code, stdout, stderr = runSkillsRequestForTest(t, projectRoot, "uninstall", "--claude") + if code != 0 || !strings.Contains(stdout, "Removed: 1") { + t.Fatalf("uninstall failed: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if fileExists(installedSkillFile) { + t.Fatal("uninstall must remove the installed skill") + } +} + +func TestTryHandleSkillsRequestPrintsTargetGuidanceWithoutTargets(t *testing.T) { + // Verifies install and uninstall without target flags only print guidance and change nothing. + projectRoot := createSkillsTestProject(t) + for _, subcommand := range []string{"install", "uninstall"} { + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, subcommand) + if code != 0 || !strings.Contains(stdout, "--claude") { + t.Fatalf("%s guidance mismatch: code=%d stdout=%s stderr=%s", subcommand, code, stdout, stderr) + } + } + if fileExists(filepath.Join(projectRoot, ".claude")) { + t.Fatal("guidance must not create any skill directory") + } +} + +func TestTryHandleSkillsRequestInstallsIntoOutputDir(t *testing.T) { + // Verifies --output-dir routes the request to dir mode and installs and removes the skill there. + projectRoot := createSkillsTestProject(t) + outputDir := filepath.Join(t.TempDir(), "skills") + + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, "install", "--output-dir", outputDir) + if code != 0 { + t.Fatalf("dir install failed: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + assertFileContent(t, filepath.Join(outputDir, "uloop-sample", "SKILL.md"), sampleSkillContent) + + code, stdout, stderr = runSkillsRequestForTest(t, projectRoot, "uninstall", "--output-dir", outputDir) + if code != 0 { + t.Fatalf("dir uninstall failed: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if fileExists(filepath.Join(outputDir, "uloop-sample")) { + t.Fatal("dir uninstall must remove the installed skill") + } +} + +func TestTryHandleSkillsRequestListsGlobalTargets(t *testing.T) { + // Verifies --global lists skills under the home directory and labels the location Global, even inside a project. + projectRoot := createSkillsTestProject(t) + homeDir := t.TempDir() + stubSkillsUserHomeDir(t, homeDir, nil) + + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, "list", "--global", "--claude") + + if code != 0 || !strings.Contains(stdout, "Claude Code (Global)") || !strings.Contains(stdout, filepath.Join(homeDir, ".claude", "skills")) { + t.Fatalf("global list mismatch: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } +} + +func TestTryHandleSkillsRequestReportsArgumentAndProjectErrors(t *testing.T) { + // Verifies option errors and a missing Unity project exit with code 1 before any skill is touched. + projectRoot := createSkillsTestProject(t) + cases := []struct { + name string + startPath string + args []string + wantMessage string + }{ + {name: "unknown option", startPath: projectRoot, args: []string{"install", "--bogus"}, wantMessage: "--bogus"}, + {name: "no project", startPath: t.TempDir(), args: []string{"list"}, wantMessage: "Unity project"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + code, stdout, stderr := runSkillsRequestForTest(t, testCase.startPath, testCase.args...) + if code != 1 || !strings.Contains(stderr, testCase.wantMessage) { + t.Fatalf("expected code 1 with %q: code=%d stdout=%s stderr=%s", testCase.wantMessage, code, stdout, stderr) + } + }) + } +} + +func TestTryHandleSkillsRequestUsesExplicitProjectPath(t *testing.T) { + // Verifies a global --project-path selects the project even when the start path is outside it. + projectRoot := createSkillsTestProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleSkillsRequest([]string{"skills", "list", "--claude"}, t.TempDir(), projectRoot, &stdout, &stderr) + + if !handled || code != 0 || !strings.Contains(stdout.String(), "uloop-sample (not installed)") { + t.Fatalf("explicit project list mismatch: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } +} + +func TestTryHandleSkillsRequestInstallsV3MigrationSkill(t *testing.T) { + // Verifies install-v3-migration installs the temporary migration skill from the package root. + projectRoot := createDispatcherUnityProject(t) + writeV3MigrationSkillFixture(t, projectRoot, "---\nname: v3-cli-invocation-migration\n---\n") + + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, "install-v3-migration", "--claude") + + if code != 0 { + t.Fatalf("migration install failed: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if !fileExists(filepath.Join(projectRoot, ".claude", "skills", v3MigrationSkillName, "SKILL.md")) { + t.Fatal("migration skill must be installed") + } +} + +func TestTryHandleSkillsRequestReportsMissingV3MigrationSource(t *testing.T) { + // Verifies install-v3-migration fails with code 1 when the package root holding the migration skill is missing. + projectRoot := createDispatcherUnityProject(t) + + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, "install-v3-migration", "--claude") + + if code != 1 || !strings.Contains(stderr, "package root was not found") { + t.Fatalf("expected a missing package root error: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } +} + +func TestRunSkillsSubcommandRejectsUnroutedSubcommands(t *testing.T) { + // Verifies subcommands that bypassed routing fail with code 1 without running any handler. + options := skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}} + projectRoot := t.TempDir() + cases := []struct { + name string + run func(stdout *bytes.Buffer, stderr *bytes.Buffer) int + wantStderr string + }{ + {name: "project mode", run: func(stdout *bytes.Buffer, stderr *bytes.Buffer) int { + return runSkillsSubcommand("bogus", projectRoot, nil, options, stdout, stderr) + }}, + {name: "v3 migration", run: func(stdout *bytes.Buffer, stderr *bytes.Buffer) int { + return runV3MigrationSkillsSubcommand("bogus", projectRoot, options, stdout, stderr) + }}, + {name: "dir mode", wantStderr: "The bogus subcommand does not support", run: func(stdout *bytes.Buffer, stderr *bytes.Buffer) int { + return runSkillsDirSubcommand("bogus", projectRoot, nil, t.TempDir(), stdout, stderr) + }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := testCase.run(&stdout, &stderr) + if code != 1 || stdout.Len() != 0 { + t.Fatalf("expected code 1 with no output: code=%d stdout=%s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), testCase.wantStderr) { + t.Fatalf("stderr mismatch: want %q got %s", testCase.wantStderr, stderr.String()) + } + }) + } +} + +func TestRunSkillsDirSubcommandReportsUnreadableOutputDir(t *testing.T) { + // Verifies an output directory below a regular file is reported instead of being treated as absent. + if runtime.GOOS == "windows" { + t.Skip("Windows reports a path below a file as not found rather than ENOTDIR.") + } + parentFile := filepath.Join(t.TempDir(), "file") + writeDispatcherTestFile(t, parentFile, "x") + var stderr bytes.Buffer + + code := runSkillsDirSubcommand("list", t.TempDir(), nil, filepath.Join(parentFile, "skills"), &bytes.Buffer{}, &stderr) + + if code != 1 || !strings.Contains(stderr.String(), "stat "+filepath.Join(parentFile, "skills")+": not a directory") { + t.Fatalf("expected an error: code=%d stderr=%s", code, stderr.String()) + } +} + +func TestPathContainsRejectsUnrelatablePaths(t *testing.T) { + // Verifies paths that cannot be made relative to each other are not reported as contained. + if pathContains("relative", string(filepath.Separator)+"absolute") { + t.Fatal("a relative parent cannot contain an absolute child") + } +} + +func TestRunSkillsListReportsBaseDirAndStatusErrors(t *testing.T) { + // Verifies list fails with code 1 when the global home cannot be resolved or a skill status cannot be read. + skill := skillDefinition{name: "uloop-sample", content: []byte(sampleSkillContent)} + t.Run("home lookup", func(t *testing.T) { + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + var stderr bytes.Buffer + code := runSkillsList(t.TempDir(), []skillDefinition{skill}, skillCommandOptions{global: true, targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, &stderr) + if code != 1 || !strings.Contains(stderr.String(), "home unavailable") { + t.Fatalf("expected the home error: code=%d stderr=%s", code, stderr.String()) + } + }) + t.Run("status stat", func(t *testing.T) { + projectRoot := t.TempDir() + blockSkillsDirWithFile(t, projectRoot, ".claude") + var stderr bytes.Buffer + code := runSkillsList(projectRoot, []skillDefinition{skill}, skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, &stderr) + if code != 1 || !strings.Contains(stderr.String(), filepath.Join(".claude", "skills", "uloop-sample", "SKILL.md")+": not a directory") { + t.Fatalf("expected a status error: code=%d stderr=%s", code, stderr.String()) + } + }) +} + +// blockSkillsDirWithFile replaces a target directory with a regular file so every +// lookup below it fails with ENOTDIR instead of "not exist". +func blockSkillsDirWithFile(t *testing.T, projectRoot string, targetDir string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Windows reports a path below a file as not found rather than ENOTDIR.") + } + writeDispatcherTestFile(t, filepath.Join(projectRoot, targetDir), "not a directory") +} + +func TestRunSkillsInstallReportsTargetErrors(t *testing.T) { + // Verifies install fails with code 1 when auto-refresh detection or the requested target cannot be read. + skill := skillDefinition{name: "uloop-sample", content: []byte(sampleSkillContent)} + cases := []struct { + name string + blocked string + global bool + wantMessage string + }{ + {name: "global home lookup", global: true, wantMessage: "home unavailable"}, + {name: "unrequested target unreadable", blocked: ".codex", wantMessage: filepath.Join(".codex", "skills", "uloop-sample", "SKILL.md") + ": not a directory"}, + {name: "requested target unreadable", blocked: ".claude", wantMessage: filepath.Join(".claude", "skills", "unity-cli-loop") + string(filepath.Separator)}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := t.TempDir() + if testCase.global { + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + } + if testCase.blocked != "" { + blockSkillsDirWithFile(t, projectRoot, testCase.blocked) + } + var stderr bytes.Buffer + + code := runSkillsInstall(projectRoot, []skillDefinition{skill}, skillCommandOptions{global: testCase.global, targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, &stderr) + + if code != 1 || !strings.Contains(stderr.String(), testCase.wantMessage) { + t.Fatalf("expected %q: code=%d stderr=%s", testCase.wantMessage, code, stderr.String()) + } + }) + } +} + +func TestRunSkillsUninstallReportsTargetErrors(t *testing.T) { + // Verifies uninstall fails with code 1 when the requested target cannot be read. + projectRoot := t.TempDir() + blockSkillsDirWithFile(t, projectRoot, ".claude") + skill := skillDefinition{name: "uloop-sample"} + var stderr bytes.Buffer + + code := runSkillsUninstall(projectRoot, []skillDefinition{skill}, skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, &stderr) + + // The deprecated-skill cleanup runs before the per-skill lookup, so its path is the one reported. + wantMessage := "stat " + filepath.Join(projectRoot, ".claude", "skills", deprecatedSkillNames[0]) + ": not a directory" + if code != 1 || !strings.Contains(stderr.String(), wantMessage) { + t.Fatalf("expected an error: code=%d stderr=%s", code, stderr.String()) + } +} + +func TestRunSkillsInstallReportsDeprecatedRemovals(t *testing.T) { + // Verifies install removes retired skill directories and reports how many it removed. + projectRoot := t.TempDir() + sourceDir := filepath.Join(projectRoot, "source", "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + deprecatedDir := filepath.Join(projectRoot, ".claude", "skills", deprecatedSkillNames[0]) + writeSkillFile(t, deprecatedDir, "---\nname: retired\n---\n") + skill := skillDefinition{name: "uloop-sample", content: []byte(sampleSkillContent), sourceDirectory: sourceDir} + var stdout bytes.Buffer + + code := runSkillsInstall(projectRoot, []skillDefinition{skill}, skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}}, &stdout, &bytes.Buffer{}) + + if code != 0 || !strings.Contains(stdout.String(), "Deprecated removed: 1") { + t.Fatalf("expected a deprecated removal: code=%d stdout=%s", code, stdout.String()) + } + if _, err := os.Stat(deprecatedDir); !os.IsNotExist(err) { + t.Fatalf("deprecated skill must be removed: %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_display_test.go b/cli/dispatcher/internal/dispatcher/skills_display_test.go new file mode 100644 index 0000000000..d6ea2075bf --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/skills_display_test.go @@ -0,0 +1,47 @@ +package dispatcher + +import ( + "bytes" + "strings" + "testing" +) + +func TestStatusIconAndTextForOutdated(t *testing.T) { + // Verifies outdated skills get their own icon and label in listings. + if statusIcon("outdated") != "^" || statusText("outdated") != "outdated" { + t.Fatalf("outdated display mismatch: %q %q", statusIcon("outdated"), statusText("outdated")) + } +} + +func TestPrintSkillsSubcommandHelpDescribesV3MigrationUninstall(t *testing.T) { + // Verifies uninstall-v3-migration help states it removes only the migration skill and omits --output-dir. + var stdout bytes.Buffer + + printSkillsSubcommandHelp("uninstall-v3-migration", &stdout) + + if !strings.Contains(stdout.String(), "Removes only the temporary V3 CLI invocation migration skill.") { + t.Fatalf("help missing migration note:\n%s", stdout.String()) + } + if strings.Contains(stdout.String(), skillsOutputDirFlagName) { + t.Fatalf("help must not advertise %s:\n%s", skillsOutputDirFlagName, stdout.String()) + } +} + +func TestTryHandleSkillsRequestListsDefaultTargetsWithoutFlags(t *testing.T) { + // Verifies list without target flags shows every default target and omits non-default ones. + projectRoot := createSkillsTestProject(t) + + code, stdout, stderr := runSkillsRequestForTest(t, projectRoot, "list") + + if code != 0 { + t.Fatalf("list failed: code=%d stderr=%s", code, stderr) + } + for _, targetID := range defaultSkillTargetIDs { + if !strings.Contains(stdout, targetConfigs[targetID].displayName+" (Project)") { + t.Fatalf("list missing default target %s:\n%s", targetID, stdout) + } + } + if strings.Contains(stdout, targetConfigs["windsurf"].displayName) { + t.Fatalf("list must not include non-default targets:\n%s", stdout) + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_sync_test.go b/cli/dispatcher/internal/dispatcher/skills_sync_test.go new file mode 100644 index 0000000000..11d3598afc --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/skills_sync_test.go @@ -0,0 +1,189 @@ +package dispatcher + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSyncSkillDirectorySkipsMetaDirectories(t *testing.T) { + // Verifies Unity .meta directories in the source are not copied into the installed skill. + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + writeDispatcherTestFile(t, filepath.Join(sourceDir, "references.meta", "inner.md"), "meta") + writeDispatcherTestFile(t, filepath.Join(sourceDir, "references", "guide.md"), "guide") + destinationDir := filepath.Join(t.TempDir(), "skills", "uloop-sample") + + if err := syncSkillDirectory(sourceDir, destinationDir); err != nil { + t.Fatalf("syncSkillDirectory failed: %v", err) + } + + assertFileContent(t, filepath.Join(destinationDir, "references", "guide.md"), "guide") + if fileExists(filepath.Join(destinationDir, "references.meta")) { + t.Fatal(".meta directories must not be copied") + } +} + +func TestSyncSkillDirectoryReportsFailures(t *testing.T) { + // Verifies sync failures leave the destination untouched and no temp directory behind. + cases := []struct { + name string + setup func(t *testing.T, sourceDir string, destinationDir string) + wantMessage string + }{ + { + name: "source missing", + wantMessage: string(filepath.Separator) + "Skill: ", + setup: func(t *testing.T, sourceDir string, destinationDir string) { + if err := os.RemoveAll(sourceDir); err != nil { + t.Fatalf("failed to remove source: %v", err) + } + }, + }, + { + name: "source file unreadable", + wantMessage: "SKILL.md: permission denied", + setup: func(t *testing.T, sourceDir string, destinationDir string) { + lockSkillsTestFile(t, filepath.Join(sourceDir, "SKILL.md")) + }, + }, + { + name: "parent not writable", + wantMessage: string(filepath.Separator) + "uloop-sample" + skillSyncTempSuffix, + setup: func(t *testing.T, sourceDir string, destinationDir string) { + lockSkillsTestDirectory(t, filepath.Dir(destinationDir), 0o555) + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + destinationDir := filepath.Join(t.TempDir(), "skills", "uloop-sample") + testCase.setup(t, sourceDir, destinationDir) + + err := syncSkillDirectory(sourceDir, destinationDir) + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected an error containing %q, got %v", testCase.wantMessage, err) + } + assertNoSkillSyncDebris(t, filepath.Dir(destinationDir)) + }) + } +} + +// lockSkillsTestFile removes every permission from a file and restores it when the test ends. +func lockSkillsTestFile(t *testing.T, filePath string) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("root can read files without permissions.") + } + lockSkillsTestDirectory(t, filepath.Dir(filePath), 0o755) + if err := os.Chmod(filePath, 0o000); err != nil { + t.Fatalf("failed to chmod %s: %v", filePath, err) + } + t.Cleanup(func() { + _ = os.Chmod(filePath, 0o644) + }) +} + +func assertNoSkillSyncDebris(t *testing.T, directory string) { + t.Helper() + entries, err := os.ReadDir(directory) + if os.IsNotExist(err) { + return + } + if err != nil { + t.Fatalf("failed to read %s: %v", directory, err) + } + if len(entries) != 0 { + t.Fatalf("sync must not leave debris in %s: %v", directory, entries) + } +} + +func TestSyncSkillDirectoryReportsUncreatableParent(t *testing.T) { + // Verifies a destination below a regular file fails before any copy starts. + parentFile := filepath.Join(t.TempDir(), "file") + writeDispatcherTestFile(t, parentFile, "x") + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + + err := syncSkillDirectory(sourceDir, filepath.Join(parentFile, "skills", "uloop-sample")) + if err == nil || !strings.HasPrefix(err.Error(), "mkdir "+parentFile) { + t.Fatalf("expected a parent directory failure, got %v", err) + } +} + +func TestReplaceSkillDirectoryRestoresDestinationWhenReplacementFails(t *testing.T) { + // Verifies a failed swap moves the previous install back instead of leaving the skill missing. + destinationDir := filepath.Join(t.TempDir(), "uloop-sample") + writeSkillFile(t, destinationDir, "previous") + + missingSource := filepath.Join(t.TempDir(), "missing") + err := replaceSkillDirectory(missingSource, destinationDir) + + if err == nil || !strings.HasPrefix(err.Error(), "rename "+missingSource) { + t.Fatalf("expected the replacement to fail, got %v", err) + } + assertFileContent(t, filepath.Join(destinationDir, "SKILL.md"), "previous") + assertNoSkillSyncDebrisExcept(t, filepath.Dir(destinationDir), "uloop-sample") +} + +func assertNoSkillSyncDebrisExcept(t *testing.T, directory string, keep string) { + t.Helper() + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatalf("failed to read %s: %v", directory, err) + } + for _, entry := range entries { + if entry.Name() != keep { + t.Fatalf("unexpected leftover %s in %s", entry.Name(), directory) + } + } +} + +func TestReplaceSkillDirectoryReportsBlockedBackup(t *testing.T) { + // Verifies an existing install whose parent forbids a backup is left in place and the error is returned. + sourceDir := filepath.Join(t.TempDir(), "staged") + writeSkillFile(t, sourceDir, "new") + parentDir := t.TempDir() + destinationDir := filepath.Join(parentDir, "uloop-sample") + writeSkillFile(t, destinationDir, "previous") + lockSkillsTestDirectory(t, parentDir, 0o555) + + if err := replaceSkillDirectory(sourceDir, destinationDir); err == nil || !strings.HasPrefix(err.Error(), "mkdir "+destinationDir+".uloop-backup-") { + t.Fatalf("expected the backup to fail, got %v", err) + } + assertFileContent(t, filepath.Join(destinationDir, "SKILL.md"), "previous") +} + +func TestReplaceSkillDirectoryReportsUninspectableDestination(t *testing.T) { + // Verifies a destination that cannot be inspected is reported instead of being overwritten. + parentFile := filepath.Join(t.TempDir(), "file") + blockSkillsDirWithFile(t, filepath.Dir(parentFile), "file") + + if err := replaceSkillDirectory(t.TempDir(), filepath.Join(parentFile, "uloop-sample")); err == nil || !strings.HasPrefix(err.Error(), "stat "+parentFile) { + t.Fatalf("expected a stat failure, got %v", err) + } +} + +func TestGetSkillStatusTreatsUnreadableFilesAsOutdated(t *testing.T) { + // Verifies an installed SKILL.md or reference that cannot be read makes the skill outdated so install rewrites it. + for _, lockedFile := range []string{"SKILL.md", filepath.Join("references", "guide.md")} { + t.Run(lockedFile, func(t *testing.T) { + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + writeDispatcherTestFile(t, filepath.Join(sourceDir, "references", "guide.md"), "guide") + baseDir := t.TempDir() + installedDir := getPreferredSkillDir(baseDir, "uloop-sample", true) + if err := syncSkillDirectory(sourceDir, installedDir); err != nil { + t.Fatalf("syncSkillDirectory failed: %v", err) + } + skill := skillDefinition{name: "uloop-sample", content: []byte(sampleSkillContent), sourceDirectory: sourceDir} + assertSkillStatus(t, baseDir, skill, "installed") + lockSkillsTestFile(t, filepath.Join(installedDir, lockedFile)) + + assertSkillStatus(t, baseDir, skill, "outdated") + }) + } +} diff --git a/cli/dispatcher/internal/dispatcher/skills_test.go b/cli/dispatcher/internal/dispatcher/skills_test.go index eea65bab7d..6a02284371 100644 --- a/cli/dispatcher/internal/dispatcher/skills_test.go +++ b/cli/dispatcher/internal/dispatcher/skills_test.go @@ -2,9 +2,11 @@ package dispatcher import ( "bytes" + "errors" "os" "path/filepath" "reflect" + "runtime" "sort" "strings" "testing" @@ -1509,3 +1511,306 @@ func assertSkillContentContains(t *testing.T, skills []skillDefinition, skillNam } t.Fatalf("skill not found: %s", skillName) } + +// lockSkillsTestDirectory sets POSIX permissions on a directory and restores them so t.TempDir can clean up. +func lockSkillsTestDirectory(t *testing.T, directory string, mode os.FileMode) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permissions are required for this failure.") + } + if os.Geteuid() == 0 { + t.Skip("root ignores file and directory permissions.") + } + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", directory, err) + } + if err := os.Chmod(directory, mode); err != nil { + t.Fatalf("failed to chmod %s: %v", directory, err) + } + t.Cleanup(func() { + _ = os.Chmod(directory, 0o755) + }) +} + +func sampleSkillWithSource(t *testing.T) skillDefinition { + t.Helper() + sourceDir := filepath.Join(t.TempDir(), "Skill") + writeSkillFile(t, sourceDir, sampleSkillContent) + return skillDefinition{name: "uloop-sample", content: []byte(sampleSkillContent), sourceDirectory: sourceDir} +} + +func TestInstallSkillsForTargetGlobalIgnoresProjectToolSettings(t *testing.T) { + // Verifies a global install installs skills of tools disabled in the project, since global installs have no project settings. + projectRoot := t.TempDir() + writeToolSettings(t, projectRoot, `{"disabledTools":["sample"]}`) + homeDir := t.TempDir() + stubSkillsUserHomeDir(t, homeDir, nil) + + result, err := installSkillsForTarget(projectRoot, targetConfigs["claude"], []skillDefinition{sampleSkillWithSource(t)}, true, false) + + if err != nil || result.installed != 1 { + t.Fatalf("unexpected result: %+v err=%v", result, err) + } + if !fileExists(filepath.Join(homeDir, ".claude", "skills", "uloop-sample", "SKILL.md")) { + t.Fatal("global install must write under the home directory") + } +} + +func TestInstallSkillsForTargetReportsFailures(t *testing.T) { + // Verifies each install stage failure is returned instead of reporting a partial install as success. + cases := []struct { + name string + grouped bool + setup func(t *testing.T, baseDir string, skill *skillDefinition) + wantMessage string + }{ + { + name: "source missing", + wantMessage: string(filepath.Separator) + "missing: ", + setup: func(t *testing.T, baseDir string, skill *skillDefinition) { + skill.sourceDirectory = filepath.Join(t.TempDir(), "missing") + }, + }, + { + name: "legacy migration blocked", + grouped: true, + wantMessage: "rename ", + setup: func(t *testing.T, baseDir string, skill *skillDefinition) { + writeSkillFile(t, filepath.Join(baseDir, "uloop-sample"), sampleSkillContent) + lockSkillsTestDirectory(t, filepath.Join(baseDir, managedSkillsDir), 0o555) + }, + }, + { + name: "empty managed directory unreadable", + wantMessage: "open ", + setup: func(t *testing.T, baseDir string, skill *skillDefinition) { + lockSkillsTestDirectory(t, filepath.Join(baseDir, managedSkillsDir), 0o300) + }, + }, + { + name: "alternate layout cannot be removed", + wantMessage: "unlinkat ", + setup: func(t *testing.T, baseDir string, skill *skillDefinition) { + alternateDir := filepath.Join(baseDir, managedSkillsDir, "uloop-sample") + writeSkillFile(t, filepath.Join(alternateDir, "locked"), "x") + lockSkillsTestDirectory(t, filepath.Join(alternateDir, "locked"), 0o555) + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := t.TempDir() + baseDir := filepath.Join(projectRoot, ".claude", "skills") + skill := sampleSkillWithSource(t) + testCase.setup(t, baseDir, &skill) + + _, err := installSkillsForTarget(projectRoot, targetConfigs["claude"], []skillDefinition{skill}, false, testCase.grouped) + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected an error containing %q, got %v", testCase.wantMessage, err) + } + }) + } +} + +func TestInstallSkillsForTargetReportsHomeLookupFailure(t *testing.T) { + // Verifies a global install fails when the home directory cannot be resolved. + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + + _, err := installSkillsForTarget(t.TempDir(), targetConfigs["claude"], nil, true, false) + + if err == nil || !strings.Contains(err.Error(), "home unavailable") { + t.Fatalf("expected the home error, got %v", err) + } +} + +func TestInstallSkillForTargetReportsStatusFailure(t *testing.T) { + // Verifies a skill whose installed location cannot be inspected is reported rather than overwritten. + baseDir := t.TempDir() + blockSkillsDirWithFile(t, baseDir, "uloop-sample") + result := skillInstallResult{} + + err := installSkillForTarget(baseDir, sampleSkillWithSource(t), nil, false, &result) + + if err == nil || !strings.Contains(err.Error(), "SKILL.md: not a directory") { + t.Fatalf("expected a status failure, got %v", err) + } + assertFileContent(t, filepath.Join(baseDir, "uloop-sample"), "not a directory") +} + +func TestUninstallSkillsForTargetReportsFailures(t *testing.T) { + // Verifies uninstall stops on a home lookup failure and on a skill directory that cannot be removed. + t.Run("home lookup", func(t *testing.T) { + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + if _, _, err := uninstallSkillsForTarget(t.TempDir(), targetConfigs["claude"], nil, true, false); err == nil || err.Error() != "home unavailable" { + t.Fatalf("expected the home error, got %v", err) + } + }) + t.Run("removal blocked", func(t *testing.T) { + projectRoot := t.TempDir() + lockedDir := filepath.Join(projectRoot, ".claude", "skills", "uloop-sample", "locked") + writeSkillFile(t, lockedDir, "x") + lockSkillsTestDirectory(t, lockedDir, 0o555) + + removed, _, err := uninstallSkillsForTarget(projectRoot, targetConfigs["claude"], []skillDefinition{{name: "uloop-sample"}}, false, false) + + if err == nil || removed != 0 || !strings.HasPrefix(err.Error(), "unlinkat "+filepath.Join(lockedDir, "SKILL.md")) { + t.Fatalf("expected a removal failure, got removed=%d err=%v", removed, err) + } + }) +} + +func TestCollectSkillDefinitionsHandlesEditorFolderLayouts(t *testing.T) { + // Verifies discovery reads loose SKILL.md files, skips excluded folders, empty Skill folders, and unsafe names. + projectRoot := t.TempDir() + writeTestSkill(t, projectRoot, "Assets/Editor/Loose", "---\nname: uloop-loose\n---\n") + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Assets", "Editor", "Loose", "README.md"), "docs") + writeTestSkill(t, projectRoot, "Assets/Editor/node_modules/pkg/Skill", "---\nname: uloop-excluded\n---\n") + writeDispatcherTestFile(t, filepath.Join(projectRoot, "Assets", "Editor", "Empty", "Skill", "notes.md"), "no skill file") + writeTestSkill(t, projectRoot, "Assets/Editor/Unsafe/Skill", "---\nname: bad/name\n---\n") + + skills, err := collectSkillDefinitions(projectRoot) + if err != nil { + t.Fatalf("collectSkillDefinitions failed: %v", err) + } + if names := skillNames(skills); !reflect.DeepEqual(names, []string{"uloop-loose"}) { + t.Fatalf("skill names mismatch: %v", names) + } +} + +func TestCollectSkillDefinitionsReportsUnreadableSources(t *testing.T) { + // Verifies an unreadable folder or SKILL.md under an Editor folder fails discovery instead of silently dropping skills. + cases := []struct { + name string + setup func(t *testing.T, projectRoot string) + wantMessage string + }{ + { + name: "unreadable folder", + setup: func(t *testing.T, projectRoot string) { + lockSkillsTestDirectory(t, filepath.Join(projectRoot, "Assets", "Editor", "Locked"), 0o000) + }, + wantMessage: filepath.Join("Editor", "Locked") + ": permission denied", + }, + { + name: "unreadable SKILL.md in Skill folder", + wantMessage: filepath.Join("Tool", "Skill", "SKILL.md") + ": permission denied", + setup: func(t *testing.T, projectRoot string) { + writeTestSkill(t, projectRoot, "Assets/Editor/Tool/Skill", sampleSkillContent) + lockSkillsTestFile(t, filepath.Join(projectRoot, "Assets", "Editor", "Tool", "Skill", "SKILL.md")) + }, + }, + { + name: "unreadable loose SKILL.md", + wantMessage: filepath.Join("Loose", "SKILL.md") + ": permission denied", + setup: func(t *testing.T, projectRoot string) { + writeTestSkill(t, projectRoot, "Assets/Editor/Loose", sampleSkillContent) + lockSkillsTestFile(t, filepath.Join(projectRoot, "Assets", "Editor", "Loose", "SKILL.md")) + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := t.TempDir() + testCase.setup(t, projectRoot) + + _, err := collectSkillDefinitions(projectRoot) + if err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("expected error containing %q, got %v", testCase.wantMessage, err) + } + }) + } +} + +func TestCollectV3MigrationSkillDefinitionReportsMissingOrUnreadableSource(t *testing.T) { + // Verifies a package root without a readable migration SKILL.md is reported. + t.Run("missing", func(t *testing.T) { + projectRoot := t.TempDir() + writePackageRootMarker(t, projectRoot) + _, err := collectV3MigrationSkillDefinition(projectRoot) + if err == nil || !strings.Contains(err.Error(), "skill source was not found") { + t.Fatalf("expected a not-found error, got %v", err) + } + }) + t.Run("unreadable", func(t *testing.T) { + projectRoot := t.TempDir() + writeV3MigrationSkillFixture(t, projectRoot, "---\nname: v3-cli-invocation-migration\n---\n") + lockSkillsTestFile(t, filepath.Join(projectRoot, "Packages", "src", "TemporarySkills~", v3MigrationSkillName, "Skill", "SKILL.md")) + _, err := collectV3MigrationSkillDefinition(projectRoot) + if err == nil || !strings.Contains(err.Error(), "SKILL.md: permission denied") { + t.Fatalf("expected a read error, got %v", err) + } + }) +} + +func v3MigrationSkillWithSource(t *testing.T) skillDefinition { + t.Helper() + sourceDir := filepath.Join(t.TempDir(), "Skill") + content := "---\nname: " + v3MigrationSkillName + "\n---\n" + writeSkillFile(t, sourceDir, content) + return skillDefinition{name: v3MigrationSkillName, content: []byte(content), sourceDirectory: sourceDir} +} + +func TestRunV3MigrationSkillCommandsReportTargetErrors(t *testing.T) { + // Verifies migration install and uninstall exit with code 1 when the target directory cannot be used. + cases := []struct { + name string + run func(t *testing.T, projectRoot string, stderr *bytes.Buffer) int + wantMessage string + }{ + {name: "install with unreadable target", wantMessage: "SKILL.md: not a directory", run: func(t *testing.T, projectRoot string, stderr *bytes.Buffer) int { + blockSkillsDirWithFile(t, projectRoot, ".claude") + return runV3MigrationSkillInstall(projectRoot, []skillDefinition{v3MigrationSkillWithSource(t)}, skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, stderr) + }}, + {name: "uninstall with unreadable target", wantMessage: v3MigrationSkillName + ": not a directory", run: func(t *testing.T, projectRoot string, stderr *bytes.Buffer) int { + blockSkillsDirWithFile(t, projectRoot, ".claude") + return runV3MigrationSkillUninstall(projectRoot, skillCommandOptions{targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, stderr) + }}, + {name: "install without home", wantMessage: "home unavailable", run: func(t *testing.T, projectRoot string, stderr *bytes.Buffer) int { + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + return runV3MigrationSkillInstall(projectRoot, []skillDefinition{v3MigrationSkillWithSource(t)}, skillCommandOptions{global: true, targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, stderr) + }}, + {name: "uninstall without home", wantMessage: "home unavailable", run: func(t *testing.T, projectRoot string, stderr *bytes.Buffer) int { + stubSkillsUserHomeDir(t, "", errors.New("home unavailable")) + return runV3MigrationSkillUninstall(projectRoot, skillCommandOptions{global: true, targets: []skillTarget{targetConfigs["claude"]}}, &bytes.Buffer{}, stderr) + }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + var stderr bytes.Buffer + if code := testCase.run(t, t.TempDir(), &stderr); code != 1 || !strings.Contains(stderr.String(), testCase.wantMessage) { + t.Fatalf("expected %q: code=%d stderr=%s", testCase.wantMessage, code, stderr.String()) + } + }) + } +} + +func TestInstallV3MigrationSkillForTargetReportsSyncFailures(t *testing.T) { + // Verifies a missing migration source or an unremovable alternate layout aborts the migration install. + t.Run("source missing", func(t *testing.T) { + skill := v3MigrationSkillWithSource(t) + skill.sourceDirectory = filepath.Join(t.TempDir(), "missing") + _, err := installV3MigrationSkillForTarget(t.TempDir(), targetConfigs["claude"], []skillDefinition{skill}, false, false) + if !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), skill.sourceDirectory) { + t.Fatalf("expected a sync failure, got %v", err) + } + }) + t.Run("alternate layout locked", func(t *testing.T) { + projectRoot := t.TempDir() + lockedDir := filepath.Join(projectRoot, ".claude", "skills", managedSkillsDir, v3MigrationSkillName, "locked") + writeSkillFile(t, lockedDir, "x") + lockSkillsTestDirectory(t, lockedDir, 0o555) + _, err := installV3MigrationSkillForTarget(projectRoot, targetConfigs["claude"], []skillDefinition{v3MigrationSkillWithSource(t)}, false, false) + if err == nil || !strings.HasPrefix(err.Error(), "unlinkat "+filepath.Join(lockedDir, "SKILL.md")) { + t.Fatalf("expected an alternate layout removal failure, got %v", err) + } + }) +} + +func TestUninstallV3MigrationSkillForTargetCountsMissingSkill(t *testing.T) { + // Verifies uninstalling a migration skill that is not installed reports it as not found. + removed, notFound, err := uninstallV3MigrationSkillForTarget(t.TempDir(), targetConfigs["claude"], false, false) + + if err != nil || removed != 0 || notFound != 1 { + t.Fatalf("unexpected result: removed=%d notFound=%d err=%v", removed, notFound, err) + } +} From d67a15ed15011178665aff49fc90dd38c7689215 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:54:21 +0900 Subject: [PATCH 14/68] chore: Dispatcher support packages now have tests for compile-check layouts, attestation fetching, and install paths (#3085) --- cli/dispatcher/attestation/fetcher_test.go | 179 +++++++++++++++++ .../attestation/verifier_options_test.go | 94 +++++++++ .../dispatchercontract/contract_test.go | 12 ++ .../compilecheck/compilecheck_edges_test.go | 161 +++++++++++++++ .../compilecheck/editor_layout_scan_test.go | 184 ++++++++++++++++++ .../compilecheck/result_cache_write_test.go | 102 ++++++++++ .../internal/compilecheck/session_run_test.go | 152 +++++++++++++++ .../internal/nativepath/path_test.go | 99 ++++++++++ .../internal/update/command_test.go | 102 ++++++++++ 9 files changed, 1085 insertions(+) create mode 100644 cli/dispatcher/attestation/fetcher_test.go create mode 100644 cli/dispatcher/attestation/verifier_options_test.go create mode 100644 cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go create mode 100644 cli/dispatcher/internal/compilecheck/editor_layout_scan_test.go create mode 100644 cli/dispatcher/internal/compilecheck/result_cache_write_test.go create mode 100644 cli/dispatcher/internal/compilecheck/session_run_test.go create mode 100644 cli/dispatcher/internal/update/command_test.go diff --git a/cli/dispatcher/attestation/fetcher_test.go b/cli/dispatcher/attestation/fetcher_test.go new file mode 100644 index 0000000000..61c5757793 --- /dev/null +++ b/cli/dispatcher/attestation/fetcher_test.go @@ -0,0 +1,179 @@ +package attestation + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// useGithubAPIBase points tag-ref lookups at baseURL for the duration of the test. +func useGithubAPIBase(t *testing.T, baseURL string) { + t.Helper() + original := githubAPIBase() + setGithubAPIBase(baseURL) + t.Cleanup(func() { setGithubAPIBase(original) }) +} + +// tagRefResponse is one expected tag-ref request path and the body it is answered with. +type tagRefResponse struct { + path string + body string +} + +// serveTagRefs answers each tag-ref request with the next response in order, after checking the +// request asks for the expected API path with the GitHub JSON headers. +func serveTagRefs(t *testing.T, responses ...tagRefResponse) *httptest.Server { + t.Helper() + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls >= len(responses) { + t.Errorf("unexpected extra call to %s", r.URL.EscapedPath()) + http.Error(w, "unexpected", http.StatusInternalServerError) + return + } + expected := responses[calls] + calls++ + if r.URL.EscapedPath() != expected.path { + t.Errorf("request %d path = %q, want %q", calls, r.URL.EscapedPath(), expected.path) + } + if r.Header.Get("Accept") != acceptHeaderGitHubJSON || r.Header.Get("X-GitHub-Api-Version") != apiVersionHeaderValue { + t.Errorf("request %d headers: Accept=%q X-GitHub-Api-Version=%q", calls, r.Header.Get("Accept"), r.Header.Get("X-GitHub-Api-Version")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintln(w, expected.body) + })) + t.Cleanup(server.Close) + useGithubAPIBase(t, server.URL) + return server +} + +// Verifies the bundle URL points at the release asset with the sigstore suffix, path-escaped. +func TestBundleAssetURLEscapesTheAssetName(t *testing.T) { + got := BundleAssetURL("owner/repo", "dispatcher-v3.1.0", "uloop darwin.tar.gz") + + want := "https://github.com/owner/repo/releases/download/dispatcher-v3.1.0/uloop%20darwin.tar.gz.sigstore.json" + if got != want { + t.Fatalf("BundleAssetURL = %q, want %q", got, want) + } +} + +// Verifies a successful bundle download returns the body as is. +func TestFetchBundleReturnsTheBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"bundle":true}`)) + })) + defer server.Close() + + body, err := FetchBundle(context.Background(), server.URL+"/asset.sigstore.json") + if err != nil || string(body) != `{"bundle":true}` { + t.Fatalf("body=%q err=%v", body, err) + } +} + +// Verifies each bundle download failure before a status is known fails closed with its own cause. +func TestFetchBundleFailsClosedOnTransportErrors(t *testing.T) { + t.Run("invalid URL", func(t *testing.T) { + _, err := FetchBundle(context.Background(), "http://bad host/asset") + if !errors.Is(err, ErrBundleFetch) || !strings.Contains(err.Error(), "build request") { + t.Fatalf("err = %v, want a build-request ErrBundleFetch", err) + } + }) + t.Run("connection refused", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + serverURL := server.URL + server.Close() + + _, err := FetchBundle(context.Background(), serverURL+"/asset") + if !errors.Is(err, ErrBundleFetch) || strings.Contains(err.Error(), "status") || strings.Contains(err.Error(), "build request") { + t.Fatalf("err = %v, want a transport ErrBundleFetch", err) + } + }) + t.Run("truncated body", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "100") + _, _ = w.Write([]byte("short")) + })) + defer server.Close() + + _, err := FetchBundle(context.Background(), server.URL+"/asset") + if !errors.Is(err, ErrBundleFetch) || !strings.Contains(err.Error(), "read body") { + t.Fatalf("err = %v, want a read-body ErrBundleFetch", err) + } + }) +} + +// tagRefPath is the git-ref API path for the tag the tests resolve; the tag holds a "/" so a +// missing path escape shows up as a different path. +const tagRefPath = "/repos/owner/repo/git/ref/tags/release%2Fv3.1.0" + +// Verifies tag resolution rejects a ref that does not end at a commit, and a commit SHA that is +// not 40 hex characters. +func TestFetchTagCommitSHARejectsUnexpectedRefs(t *testing.T) { + t.Run("tree object", func(t *testing.T) { + serveTagRefs(t, tagRefResponse{path: tagRefPath, body: `{"object":{"sha":"1eb1ebb9841b1bcb8fc7dec3fa282568a1c31a4f","type":"tree"}}`}) + + _, err := FetchTagCommitSHA(context.Background(), "owner/repo", "release/v3.1.0") + if !errors.Is(err, ErrTagRefFetch) || !strings.Contains(err.Error(), `unexpected object type "tree"`) { + t.Fatalf("err = %v", err) + } + }) + t.Run("short SHA", func(t *testing.T) { + serveTagRefs(t, tagRefResponse{path: tagRefPath, body: `{"object":{"sha":"1eb1ebb","type":"commit"}}`}) + + _, err := FetchTagCommitSHA(context.Background(), "owner/repo", "release/v3.1.0") + if !errors.Is(err, ErrTagRefFetch) || !strings.Contains(err.Error(), `bad commit SHA "1eb1ebb"`) { + t.Fatalf("err = %v", err) + } + }) + t.Run("annotated tag lookup fails", func(t *testing.T) { + serveTagRefs(t, + tagRefResponse{path: tagRefPath, body: `{"object":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","type":"tag"}}`}, + tagRefResponse{path: "/repos/owner/repo/git/tags/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", body: `not json`}) + + _, err := FetchTagCommitSHA(context.Background(), "owner/repo", "release/v3.1.0") + if !errors.Is(err, ErrTagRefFetch) || !strings.Contains(err.Error(), "decode payload") { + t.Fatalf("err = %v", err) + } + }) +} + +// Verifies each tag-ref lookup failure before a payload is decoded fails closed with its own cause. +func TestFetchTagCommitSHAFailsClosedOnTransportErrors(t *testing.T) { + t.Run("invalid base URL", func(t *testing.T) { + useGithubAPIBase(t, "http://bad host") + + _, err := FetchTagCommitSHA(context.Background(), "owner/repo", "dispatcher-v3.1.0") + if !errors.Is(err, ErrTagRefFetch) || !strings.Contains(err.Error(), "build request") { + t.Fatalf("err = %v, want a build-request ErrTagRefFetch", err) + } + }) + t.Run("connection refused", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + useGithubAPIBase(t, server.URL) + server.Close() + + _, err := FetchTagCommitSHA(context.Background(), "owner/repo", "dispatcher-v3.1.0") + if !errors.Is(err, ErrTagRefFetch) || strings.Contains(err.Error(), "status") || strings.Contains(err.Error(), "build request") { + t.Fatalf("err = %v, want a transport ErrTagRefFetch", err) + } + }) +} + +// Verifies the asset name is the last path element, without any query, for both URLs and bare +// paths, and falls back to the raw value when the URL cannot be parsed. +func TestAssetNameFromReleaseAsset(t *testing.T) { + cases := map[string]string{ + "https://github.com/owner/repo/releases/download/v1/uloop.tar.gz?token=x": "uloop.tar.gz", + "dist/uloop.zip": "uloop.zip", + "%zz/broken/uloop": "uloop", + } + for input, want := range cases { + if got := AssetNameFromReleaseAsset(input); got != want { + t.Fatalf("AssetNameFromReleaseAsset(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/cli/dispatcher/attestation/verifier_options_test.go b/cli/dispatcher/attestation/verifier_options_test.go new file mode 100644 index 0000000000..cbb2849893 --- /dev/null +++ b/cli/dispatcher/attestation/verifier_options_test.go @@ -0,0 +1,94 @@ +package attestation + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/sigstore/sigstore-go/pkg/verify" +) + +// Verifies Verify rejects incomplete options and malformed digests before it touches the bundle, +// each with its own message. +func TestVerifyRejectsIncompleteOptionsAndBadDigests(t *testing.T) { + f := loadHappyFixture(t) + trusted, err := LoadEmbeddedTrustedMaterial() + if err != nil { + t.Fatalf("load trusted root: %v", err) + } + valid := VerifyOptions{BundleData: f.bundle, AssetDigest: f.digest, ExpectedCommitSHA: f.commitSHA, Identity: f.identity} + cases := []struct { + name string + mutate func(*VerifyOptions) + wantErr string + }{ + {name: "missing digest", mutate: func(o *VerifyOptions) { o.AssetDigest = "" }, wantErr: "AssetDigest required"}, + {name: "missing bundle", mutate: func(o *VerifyOptions) { o.BundleData = nil }, wantErr: "BundleData required"}, + {name: "bad commit", mutate: func(o *VerifyOptions) { o.ExpectedCommitSHA = "abc" }, wantErr: "ExpectedCommitSHA must be 40-char hex"}, + {name: "missing repository", mutate: func(o *VerifyOptions) { o.Identity.Repository = "" }, wantErr: "at least one Ref required"}, + {name: "missing workflow path", mutate: func(o *VerifyOptions) { o.Identity.WorkflowPath = "" }, wantErr: "at least one Ref required"}, + {name: "missing refs", mutate: func(o *VerifyOptions) { o.Identity.Refs = nil }, wantErr: "at least one Ref required"}, + {name: "non-hex digest", mutate: func(o *VerifyOptions) { o.AssetDigest = "zz" }, wantErr: "asset digest must be hex sha256"}, + {name: "short digest", mutate: func(o *VerifyOptions) { o.AssetDigest = "abcd" }, wantErr: "asset digest must be 32 bytes (sha256), got 2"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + opts := valid + testCase.mutate(&opts) + + err := Verify(trusted, opts) + if !errors.Is(err, ErrVerificationFailed) || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("err = %v, want ErrVerificationFailed containing %q", err, testCase.wantErr) + } + }) + } +} + +// decodeVerificationResult builds a verification result from its JSON form, so the subject +// extraction can be tested without signing a new bundle. +func decodeVerificationResult(t *testing.T, raw string) *verify.VerificationResult { + t.Helper() + var result verify.VerificationResult + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatalf("decode verification result: %v", err) + } + return &result +} + +// Verifies subject extraction keeps only subjects with a sha256 digest and fails closed when a +// verified result has no statement or no usable subject. +func TestExtractVerifiedSubjects(t *testing.T) { + t.Run("keeps sha256 subjects only", func(t *testing.T) { + result := decodeVerificationResult(t, `{"statement":{"subject":[ + {"name":"uloop.tar.gz","digest":{"sha256":"abc123"}}, + {"name":"sha512-only","digest":{"sha512":"def456"}}, + {"name":"empty-sha256","digest":{"sha256":""}} + ]}}`) + result.Statement.Subject = append(result.Statement.Subject, nil) + + subjects, err := extractVerifiedSubjects(result) + if err != nil { + t.Fatalf("extractVerifiedSubjects failed: %v", err) + } + if len(subjects) != 1 || subjects["uloop.tar.gz"] != "abc123" { + t.Fatalf("subjects = %#v", subjects) + } + }) + t.Run("no statement", func(t *testing.T) { + for _, result := range []*verify.VerificationResult{nil, {}} { + _, err := extractVerifiedSubjects(result) + if !errors.Is(err, ErrVerificationFailed) || !strings.Contains(err.Error(), "verified bundle had no statement") { + t.Fatalf("err = %v", err) + } + } + }) + t.Run("no sha256 subject", func(t *testing.T) { + result := decodeVerificationResult(t, `{"statement":{"subject":[{"name":"a","digest":{"sha512":"x"}}]}}`) + + _, err := extractVerifiedSubjects(result) + if !errors.Is(err, ErrVerificationFailed) || !strings.Contains(err.Error(), "verified bundle had no sha256 subjects") { + t.Fatalf("err = %v", err) + } + }) +} diff --git a/cli/dispatcher/dispatchercontract/contract_test.go b/cli/dispatcher/dispatchercontract/contract_test.go index 16c85a3c4d..f991ef411c 100644 --- a/cli/dispatcher/dispatchercontract/contract_test.go +++ b/cli/dispatcher/dispatchercontract/contract_test.go @@ -20,3 +20,15 @@ func TestDispatcherContractDoesNotDeclareCliReleaseFields(t *testing.T) { clitest.RequireContractFieldMissing(t, fields, "dispatcherContractVersion") clitest.RequireContractFieldMissing(t, fields, "schemaVersion") } + +func TestRequireStringPanicsOnEmptyField(t *testing.T) { + // Verifies an empty contract field stops loading with a panic that names the field. + defer func() { + recovered := recover() + if recovered != "contract field dispatcherVersion must not be empty" { + t.Fatalf("recovered = %#v", recovered) + } + }() + + requireString("", "dispatcherVersion") +} diff --git a/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go b/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go new file mode 100644 index 0000000000..8b4d7f9d02 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/compilecheck_edges_test.go @@ -0,0 +1,161 @@ +package compilecheck + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" +) + +// Verifies a non-positive job count still compiles every unit, one at a time. +func TestCompileUnitsTreatsANonPositiveJobCountAsOne(t *testing.T) { + plan := newSchedulerPlan(nil, "A", "B") + recorder := newSchedulerRecorder(5 * time.Millisecond) + + outcome, err := compileUnits(context.Background(), newSchedulerCompiler(t, recorder), plan, 0) + if err != nil { + t.Fatalf("expected the units to compile, got error: %v", err) + } + + if len(compiledNames(outcome.Units)) != 2 || recorder.maxRunning != 1 { + t.Fatalf("compiled=%v maxRunning=%d, want both units compiled one at a time", compiledNames(outcome.Units), recorder.maxRunning) + } +} + +// Verifies a run whose output directory cannot be created fails before compiling anything. +func TestCompileUnitsReportsAnOutputDirectoryThatCannotBeCreated(t *testing.T) { + plan := newSchedulerPlan(nil, "A") + recorder := newSchedulerRecorder(0) + compiler := newSchedulerCompiler(t, recorder) + writeFileAt(t, filepath.Join(compiler.ProjectRoot, "Library"), "not a directory") + + _, err := compileUnits(context.Background(), compiler, plan, 1) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to create ") { + t.Fatalf("err = %v", err) + } + if recorder.maxRunning != 0 { + t.Fatalf("no unit may start, maxRunning = %d", recorder.maxRunning) + } +} + +// Verifies a plan reference to an assembly outside the plan does not hold the unit back. +func TestCompileUnitsIgnoresReferencesOutsideThePlan(t *testing.T) { + plan := newSchedulerPlan(map[string][]string{"A": {"Outside"}}, "A", "B") + + outcome, err := compileUnits(context.Background(), newSchedulerCompiler(t, newSchedulerRecorder(0)), plan, 2) + if err != nil { + t.Fatalf("expected the units to compile, got error: %v", err) + } + + assertStrings(t, "compiled", compiledNames(outcome.Units), []string{"A", "B"}) +} + +// Verifies a source the response file records but that is gone from disk is reported as an +// inspection failure naming the file. +func TestDetectSourceChangeReportsAMissingSource(t *testing.T) { + projectRoot := newPlanProject(t) + missing := "Assets/A/Gone.cs" + rsp := ResponseFile{AssemblyName: "A", Sources: []string{missing}} + + _, err := DetectSourceChange(projectRoot, rsp, []string{missing}, planDagDirectory) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to inspect ") || !strings.Contains(err.Error(), "Gone.cs") { + t.Fatalf("err = %v", err) + } +} + +// Verifies an assembly definition that cannot be inspected is reported instead of being treated as +// unchanged. +func TestContractComparisonNeededReportsAMissingAssemblyDefinition(t *testing.T) { + path := filepath.Join(t.TempDir(), "Gone.asmdef") + + _, err := contractComparisonNeeded(AssemblyDefinition{Path: path}, time.Now(), true) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to inspect "+path) { + t.Fatalf("err = %v", err) + } +} + +// Verifies a directory nobody owns is reported as unowned once the walk reaches the top. +func TestOwnerOfReportsADirectoryWithoutOwner(t *testing.T) { + index := assemblyOwnerIndex{referencesByDirectory: map[string]string{}, definitionDirectories: map[string]bool{}} + + if _, found := index.ownerOf(filepath.Join(t.TempDir(), "Assets", "Loose")); found { + t.Fatal("a directory without any .asmref or .asmdef above it must be unowned") + } +} + +// Verifies an .asmref that cannot be parsed fails the index with the file named. +func TestIndexAssemblyReferencesReportsAMalformedAsmref(t *testing.T) { + projectRoot := t.TempDir() + path := filepath.Join(projectRoot, "Assets", "Broken", "Broken.asmref") + writeFileAt(t, path, "not json") + + _, err := IndexAssemblyReferences(projectRoot) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to index assembly references under ") || !strings.Contains(err.Error(), "failed to read "+path) { + t.Fatalf("err = %v", err) + } +} + +// Verifies response file lines that are not "-name:value" or "/name:value" flags are not read as +// flags, so neither a path-valued nor an untracked-input flag is found in them. +func TestSplitFlagRejectsLinesThatAreNotFlags(t *testing.T) { + for _, line := range []string{"", "Assets/A.cs:3", "-nologo"} { + if name, value, ok := splitFlag(line); ok { + t.Fatalf("splitFlag(%q) = (%q, %q, true), want no flag", line, name, value) + } + if _, ok := pathValuedFlag(line); ok { + t.Fatalf("pathValuedFlag(%q) found a path", line) + } + if untrackedFileInputFlag(line) { + t.Fatalf("untrackedFileInputFlag(%q) = true", line) + } + } + if name, value, ok := splitFlag("/RuleSet:a.ruleset"); !ok || name != "ruleset" || value != "a.ruleset" { + t.Fatalf("splitFlag(/RuleSet:a.ruleset) = (%q, %q, %v)", name, value, ok) + } +} + +// Verifies an absolute response file path is used as is instead of being joined to the project root. +func TestProjectPathKeepsAbsolutePaths(t *testing.T) { + absolutePath := filepath.Join(t.TempDir(), "Shared.dll") + + if got := projectPath(filepath.Join(t.TempDir(), "project"), absolutePath); got != absolutePath { + t.Fatalf("projectPath = %q, want %q", got, absolutePath) + } +} + +// Verifies an .asmdef that cannot be parsed, or that declares no name, fails the index with the +// file named. +func TestIndexAssemblyDefinitionsReportsUnusableAsmdefs(t *testing.T) { + cases := map[string]string{ + "not json": "failed to read ", + `{"name":""}`: " declares no assembly name", + } + for content, wantMessage := range cases { + projectRoot := t.TempDir() + path := filepath.Join(projectRoot, "Assets", "Broken", "Broken.asmdef") + writeFileAt(t, path, content) + + _, err := IndexAssemblyDefinitions(projectRoot) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to index ") || !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), wantMessage) { + t.Fatalf("content %q: err = %v, want %q", content, err, wantMessage) + } + } +} + +// Verifies listing the sources of a directory that does not exist is reported with the directory. +func TestGlobAssemblySourcesReportsAMissingDirectory(t *testing.T) { + directory := filepath.Join(t.TempDir(), "Gone") + index := assemblyOwnerIndex{referencesByDirectory: map[string]string{}, definitionDirectories: map[string]bool{}} + + _, err := globAssemblySources(t.TempDir(), directory, index) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to list sources under "+directory) { + t.Fatalf("err = %v", err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/editor_layout_scan_test.go b/cli/dispatcher/internal/compilecheck/editor_layout_scan_test.go new file mode 100644 index 0000000000..7541ce8acb --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/editor_layout_scan_test.go @@ -0,0 +1,184 @@ +package compilecheck + +import ( + "path/filepath" + "strings" + "testing" +) + +// newMacEditorContents returns a fresh Unity.app/Contents path and the executable inside it. +func newMacEditorContents(t *testing.T) (string, string) { + t.Helper() + contentsPath := filepath.Join(t.TempDir(), "Unity.app", "Contents") + makeDirAt(t, filepath.Join(contentsPath, "MacOS")) + return contentsPath, filepath.Join(contentsPath, "MacOS", "Unity") +} + +// Verifies an Editor whose data holds no compiler layout is rejected with the layout message. +func TestResolveEditorCompilerPathsRejectsEditorWithoutCompiler(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + + _, err := ResolveEditorCompilerPaths(executablePath) + + if err == nil || err.Error() != "no Unity-bundled C# compiler layout found under "+contentsPath { + t.Fatalf("err = %v", err) + } +} + +// Verifies the breadth-first scan finds a compiler layout nested below the known locations, and +// gives up on one nested deeper than the scan depth limit. +func TestResolveEditorCompilerPathsScansForNestedLayouts(t *testing.T) { + t.Run("within the depth limit", func(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + // Why exactly the depth limit: a layout any shallower would still be found with a lower limit. + scriptingRootPath := filepath.Join(contentsPath, "a", "b", "c", "d") + writeCompilerFiles(t, filepath.Join(scriptingRootPath, dotNetSdkRoslynDirectoryName), "{}") + writeSharedRuntime(t, filepath.Join(scriptingRootPath, netCoreRuntimeDirectoryName), "6.0.21") + + paths, err := ResolveEditorCompilerPaths(executablePath) + if err != nil { + t.Fatalf("expected the nested layout to resolve, got error: %v", err) + } + if paths.CompilerDirectory != filepath.Join(scriptingRootPath, dotNetSdkRoslynDirectoryName) { + t.Fatalf("compiler directory = %s", paths.CompilerDirectory) + } + }) + t.Run("beyond the depth limit", func(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + scriptingRootPath := filepath.Join(contentsPath, "a", "b", "c", "d", "e") + writeCompilerFiles(t, filepath.Join(scriptingRootPath, dotNetSdkRoslynDirectoryName), "{}") + writeSharedRuntime(t, filepath.Join(scriptingRootPath, netCoreRuntimeDirectoryName), "6.0.21") + + _, err := ResolveEditorCompilerPaths(executablePath) + if err == nil || !strings.HasPrefix(err.Error(), "no Unity-bundled C# compiler layout found under ") { + t.Fatalf("err = %v, want the layout to stay unfound", err) + } + }) +} + +// Verifies a NetCoreRuntime without any installed shared framework is reported as the missing +// shared framework directory. +func TestResolveEditorCompilerPathsReportsMissingSharedFramework(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + writeCompilerFiles(t, filepath.Join(contentsPath, dotNetSdkRoslynDirectoryName), "{}") + writeFileAt(t, filepath.Join(contentsPath, netCoreRuntimeDirectoryName, hostFileName()), "") + + _, err := ResolveEditorCompilerPaths(executablePath) + + want := "required compiler file missing: " + + filepath.Join(contentsPath, netCoreRuntimeDirectoryName, sharedDirectoryName, sharedFrameworkName) + if err == nil || err.Error() != want { + t.Fatalf("err = %v, want %q", err, want) + } +} + +// Verifies an SDK-only layout (no NetCoreRuntime) counts as a compiler layout only when its SDK root +// ships a shared framework, and then compiles with the SDK's own runtime. +func TestResolveEditorCompilerPathsForSdkOnlyLayout(t *testing.T) { + writeSdkCompiler := func(t *testing.T, contentsPath string) string { + dotNetSdkRootPath := filepath.Join(contentsPath, "Resources", "Scripting", dotNetSdkDirectoryName) + writeCompilerFiles(t, filepath.Join( + dotNetSdkRootPath, sdkDirectoryName, "10.0.301", roslynDirectoryName, bincoreDirectoryName), + runtimeConfigWithVersion("10.0.9")) + return dotNetSdkRootPath + } + t.Run("with a shared framework", func(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + dotNetSdkRootPath := writeSdkCompiler(t, contentsPath) + writeSharedRuntime(t, dotNetSdkRootPath, "10.0.9") + + paths, err := ResolveEditorCompilerPaths(executablePath) + if err != nil { + t.Fatalf("expected the SDK-only layout to resolve, got error: %v", err) + } + if paths.DotnetHostPath != filepath.Join(dotNetSdkRootPath, hostFileName()) { + t.Fatalf("host path = %s", paths.DotnetHostPath) + } + }) + t.Run("without a shared framework", func(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + dotNetSdkRootPath := writeSdkCompiler(t, contentsPath) + writeFileAt(t, filepath.Join(dotNetSdkRootPath, hostFileName()), "") + + _, err := ResolveEditorCompilerPaths(executablePath) + if err == nil || !strings.HasPrefix(err.Error(), "no Unity-bundled C# compiler layout found under ") { + t.Fatalf("err = %v, want no layout", err) + } + }) +} + +// Verifies the resolver keeps NetCoreRuntime when the SDK runtime cannot satisfy the compiler: +// a missing SDK host or an SDK shared framework older than the required major. +func TestResolveEditorCompilerPathsFallsBackToNetCoreRuntime(t *testing.T) { + cases := []struct { + name string + writeSdk func(t *testing.T, dotNetSdkRootPath string) + }{ + {name: "missing SDK host", writeSdk: func(t *testing.T, dotNetSdkRootPath string) { + makeDirAt(t, filepath.Join(dotNetSdkRootPath, sharedDirectoryName, sharedFrameworkName, "10.0.9")) + }}, + {name: "SDK framework too old", writeSdk: func(t *testing.T, dotNetSdkRootPath string) { + writeSharedRuntime(t, dotNetSdkRootPath, "9.0.1") + }}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + contentsPath, executablePath := newMacEditorContents(t) + scriptingRootPath := filepath.Join(contentsPath, "Resources", "Scripting") + dotNetSdkRootPath := filepath.Join(scriptingRootPath, dotNetSdkDirectoryName) + writeCompilerFiles(t, filepath.Join( + dotNetSdkRootPath, sdkDirectoryName, "10.0.301", roslynDirectoryName, bincoreDirectoryName), + runtimeConfigWithVersion("10.0.9")) + writeSharedRuntime(t, filepath.Join(scriptingRootPath, netCoreRuntimeDirectoryName), "8.0.21") + testCase.writeSdk(t, dotNetSdkRootPath) + + paths, err := ResolveEditorCompilerPaths(executablePath) + if err != nil { + t.Fatalf("expected the layout to resolve, got error: %v", err) + } + if paths.DotnetHostPath != filepath.Join(scriptingRootPath, netCoreRuntimeDirectoryName, hostFileName()) { + t.Fatalf("host path = %s, want the NetCoreRuntime host", paths.DotnetHostPath) + } + }) + } +} + +// Verifies version-shaped directory names sort newest first and ahead of other names, other names +// sort in reverse ordinal order, and an absent version component counts as lower. +func TestCompareVersionDirectoryNames(t *testing.T) { + cases := []struct { + left string + right string + want int + }{ + {left: "9.0.1", right: "10.0.0", want: 1}, + {left: "10.0.0", right: "9.0.1", want: -1}, + {left: "8.0", right: "8.0.1", want: 1}, + {left: "8.0.1", right: "8.0", want: -1}, + {left: "8.0.1", right: "preview", want: -1}, + {left: "preview", right: "8.0.1", want: 1}, + {left: "alpha", right: "beta", want: 1}, + {left: "8.0", right: "8.0", want: 0}, + } + for _, testCase := range cases { + if got := compareVersionDirectoryNames(testCase.left, testCase.right); got != testCase.want { + t.Fatalf("compareVersionDirectoryNames(%q, %q) = %d, want %d", testCase.left, testCase.right, got, testCase.want) + } + } +} + +// Verifies the version parser rejects negative components the way the .NET Version parser does. +func TestParseVersionPartsRejectsNegativeComponents(t *testing.T) { + if parts, ok := parseVersionParts("8.-1"); ok { + t.Fatalf("parseVersionParts(8.-1) = %v, want rejection", parts) + } +} + +// Verifies the SDK-root walk stops with no result at the top of a relative path and for an empty path. +func TestFindDirectoryContainingSdkStopsWithoutMatch(t *testing.T) { + for _, start := range []string{filepath.Join("no-such-directory", "child"), ""} { + if got := findDirectoryContainingSdk(start); got != "" { + t.Fatalf("findDirectoryContainingSdk(%q) = %q, want empty", start, got) + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go new file mode 100644 index 0000000000..313f607e94 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go @@ -0,0 +1,102 @@ +package compilecheck + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// blockManifestPath puts a non-empty directory where a unit's manifest belongs, so renaming the +// finished manifest into place fails on every platform. +func blockManifestPath(t *testing.T, manifestFilePath string) { + t.Helper() + writeFileAt(t, filepath.Join(manifestFilePath, "occupied"), "") +} + +// Verifies a manifest that cannot be renamed into place is reported as a write failure and leaves +// no temporary file behind. +func TestWriteUnitResultManifestReportsAFailedRename(t *testing.T) { + outputDirectoryPath, unit := newManifestUnit(t) + blockManifestPath(t, manifestPath(outputDirectoryPath, unit)) + + err := writeUnitResultManifest(outputDirectoryPath, unit, "key", storedResult()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit)+":") { + t.Fatalf("err = %v", err) + } + matches, globErr := filepath.Glob(filepath.Join(outputDirectoryPath, "*"+resultManifestTempSuffix)) + if globErr != nil || len(matches) != 0 { + t.Fatalf("temporary manifests left behind: %v (glob error %v)", matches, globErr) + } +} + +// Verifies a manifest cannot be written into an output directory that does not exist, which is +// reported with the manifest path. +func TestWriteUnitResultManifestReportsAMissingOutputDirectory(t *testing.T) { + _, unit := newManifestUnit(t) + outputDirectoryPath := filepath.Join(t.TempDir(), "missing") + + err := writeUnitResultManifest(outputDirectoryPath, unit, "key", storedResult()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit)+":") { + t.Fatalf("err = %v", err) + } +} + +// Verifies an output that exists but cannot be read is reported instead of being treated as absent. +func TestHashUnitOutputsReportsAnUnreadableOutput(t *testing.T) { + outputDirectoryPath, unit := newManifestUnit(t) + unreadablePath := filepath.Join(outputDirectoryPath, "Alpha"+assemblyExtension) + if err := os.Remove(unreadablePath); err != nil { + t.Fatalf("failed to remove the assembly: %v", err) + } + makeDirAt(t, unreadablePath) + + _, complete, err := hashUnitOutputs(outputDirectoryPath, unit, true) + + if err == nil || complete || !strings.Contains(err.Error(), unreadablePath) { + t.Fatalf("complete=%v err=%v, want an error naming %s", complete, err, unreadablePath) + } +} + +// Verifies a unit without a reference assembly is hashed by its assembly alone. +func TestHashUnitOutputsSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { + outputDirectoryPath, unit := newManifestUnit(t) + unit.Assembly.RefOutputPath = "" + + outputs, complete, err := hashUnitOutputs(outputDirectoryPath, unit, true) + + if err != nil || !complete || len(outputs) != 1 || outputs["Alpha"+assemblyExtension] == "" { + t.Fatalf("outputs=%v complete=%v err=%v", outputs, complete, err) + } +} + +// Verifies a compile whose result cannot be recorded fails the run rather than reporting a result +// the next run could not reuse. +func TestCompileUnitsFailsWhenAResultCannotBeRecorded(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + recorder := newCachedSchedulerRecorder("Alpha") + compileRunner := recorder.runner() + compiler := Compiler{ + Paths: project.paths, + ProjectRoot: project.root, + Timeout: time.Minute, + // Why the block is placed during the compile: the compile removes any previous manifest before + // it starts, so only a path occupied after that point reaches the manifest write. + Run: func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) { + stdout, stderr, exitCode, err := compileRunner(ctx, cmd) + blockManifestPath(t, project.manifestPathOf("Alpha")) + return stdout, stderr, exitCode, err + }, + } + + _, err := compileUnits(context.Background(), compiler, project.plan, 1) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+project.manifestPathOf("Alpha")+":") { + t.Fatalf("err = %v, want the manifest write failure", err) + } +} diff --git a/cli/dispatcher/internal/compilecheck/session_run_test.go b/cli/dispatcher/internal/compilecheck/session_run_test.go new file mode 100644 index 0000000000..3b5d199859 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/session_run_test.go @@ -0,0 +1,152 @@ +package compilecheck + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const defaultRunHelperEnv = "ULOOP_COMPILECHECK_DEFAULT_RUN_HELPER" + +// writeFakeEditor lays out a minimal macOS-shaped Editor with a bundled compiler and returns its +// executable path. Nothing in it can run; it only has to resolve. +func writeFakeEditor(t *testing.T) string { + t.Helper() + contentsPath := filepath.Join(t.TempDir(), "Unity.app", "Contents") + writeCompilerFiles(t, filepath.Join(contentsPath, dotNetSdkRoslynDirectoryName), "{}") + writeSharedRuntime(t, filepath.Join(contentsPath, netCoreRuntimeDirectoryName), "6.0.21") + return filepath.Join(contentsPath, "MacOS", "Unity") +} + +// Verifies a run whose plan has nothing to compile reports every assembly as skipped and the dag +// it read, without starting the compiler. +func TestRunReportsAnUpToDateProjectAsSkipped(t *testing.T) { + projectRoot := newPlanProject(t) + + result, err := Run(context.Background(), Options{ProjectRoot: projectRoot, EditorExecutablePath: writeFakeEditor(t)}) + if err != nil { + t.Fatalf("Run failed: %v", err) + } + + if result.DagDir != filepath.FromSlash(planDagDirectory) || result.Skipped != 3 || len(result.Units) != 0 || len(result.Blocked) != 0 { + t.Fatalf("unexpected result: %#v", result) + } +} + +// Verifies a run stops with the compiler-layout error when the Editor path does not resolve. +func TestRunReportsAnUnresolvableEditor(t *testing.T) { + projectRoot := newPlanProject(t) + + _, err := Run(context.Background(), Options{ProjectRoot: projectRoot, EditorExecutablePath: filepath.Join(t.TempDir(), "Unity")}) + + if err == nil || !strings.HasPrefix(err.Error(), "unrecognized Unity Editor install layout for ") { + t.Fatalf("err = %v", err) + } +} + +// Verifies a run stops with the response-file error when the plan cannot be built. +func TestRunReportsAPlanThatCannotBeBuilt(t *testing.T) { + projectRoot := newDagProject(t, "aaaa.dag") + writeFileAt(t, filepath.Join(projectRoot, planDagDirectory, "Broken.rsp"), `"Assets/Broken.cs"`) + + _, err := Run(context.Background(), Options{ProjectRoot: projectRoot, EditorExecutablePath: writeFakeEditor(t)}) + + if err == nil || !strings.Contains(err.Error(), "has no -out flag") { + t.Fatalf("err = %v", err) + } +} + +// Verifies a run on a project Unity never built reports the missing build before taking the project +// lock, so it leaves no Library directory behind in the unbuilt project. +func TestRunReportsAProjectWithoutABuild(t *testing.T) { + projectRoot := t.TempDir() + + _, err := Run(context.Background(), Options{ProjectRoot: projectRoot}) + + var required UnityBuildRequiredError + if !errors.As(err, &required) { + t.Fatalf("err = %v, want UnityBuildRequiredError", err) + } + if _, statErr := os.Stat(filepath.Join(projectRoot, libraryDirectoryName)); !os.IsNotExist(statErr) { + t.Fatalf("the unbuilt project must not get a %s directory (stat error %v)", libraryDirectoryName, statErr) + } +} + +// Verifies the default compiler runner returns the process output and its non-zero exit code +// together with the exit error. +func TestDefaultRunReportsOutputAndExitCode(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestDefaultRunHelperProcess$") + cmd.Env = append(os.Environ(), defaultRunHelperEnv+"=1") + + stdout, stderr, exitCode, err := defaultRun(context.Background(), cmd) + + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitCode != 3 { + t.Fatalf("exitCode=%d err=%v, want exit code 3 with an ExitError", exitCode, err) + } + if !strings.Contains(stdout, "helper stdout") || !strings.Contains(stderr, "helper stderr") { + t.Fatalf("stdout=%q stderr=%q", stdout, stderr) + } +} + +// TestDefaultRunHelperProcess is not a test: it is the process TestDefaultRunReportsOutputAndExitCode +// starts, and it writes to both streams before exiting with code 3. +func TestDefaultRunHelperProcess(t *testing.T) { + if os.Getenv(defaultRunHelperEnv) == "" { + t.Skip("helper process for TestDefaultRunReportsOutputAndExitCode") + } + _, _ = os.Stdout.WriteString("helper stdout\n") + _, _ = os.Stderr.WriteString("helper stderr\n") + os.Exit(3) +} + +// Verifies the artifacts scan ignores entries that are not dag directories and reports a build as +// missing when none remain. +func TestResolveActiveDagDirIgnoresEntriesThatAreNotDagDirectories(t *testing.T) { + projectRoot := newDagProject(t, "other") + artifactsDirectory := filepath.Join(projectRoot, libraryDirectoryName, beeDirectoryName, beeArtifactsDirectoryName) + writeFileAt(t, filepath.Join(artifactsDirectory, "file.dag"), "") + + _, err := ResolveActiveDagDir(projectRoot) + + var required UnityBuildRequiredError + if !errors.As(err, &required) || !strings.Contains(required.Reason, "no Bee build artifacts found in") { + t.Fatalf("err = %v", err) + } +} + +// Verifies a Bee log or a script debug setting that fails to decode is ignored, leaving an +// ambiguous project unresolved instead of guessed. +func TestResolveActiveDagDirIgnoresMalformedLogAndSetting(t *testing.T) { + t.Run("Bee log", func(t *testing.T) { + projectRoot := newDagProject(t, "aaaa.dag", "aaaaDbg.dag") + // Why the duplicate key: the decoder keeps the first dagFile when the second one fails, so + // only the decode-error check keeps the half-read log from deciding the dag. + writeFileAt(t, filepath.Join(projectRoot, libraryDirectoryName, beeDirectoryName, tundraLogFileName), + `{"dagFile":"Library/Bee/aaaa.dag","dagFile":5}`) + + assertDagDirUnresolved(t, projectRoot) + }) + t.Run("script debug setting", func(t *testing.T) { + projectRoot := newDagProject(t, "aaaa.dag", "aaaaDbg.dag") + writeFileAt(t, filepath.Join(projectRoot, libraryDirectoryName, editorScriptingSettingsFile), + `{"m_ScriptDebugInfoEnabled":{"m_Value":false},"m_ScriptDebugInfoEnabled":5}`) + + assertDagDirUnresolved(t, projectRoot) + }) +} + +// assertDagDirUnresolved requires the dag resolution to refuse the project as ambiguous. +func assertDagDirUnresolved(t *testing.T, projectRoot string) { + t.Helper() + _, err := ResolveActiveDagDir(projectRoot) + + var required UnityBuildRequiredError + if !errors.As(err, &required) || !strings.Contains(required.Reason, "cannot tell which Bee build to check in") { + t.Fatalf("err = %v", err) + } +} diff --git a/cli/dispatcher/internal/nativepath/path_test.go b/cli/dispatcher/internal/nativepath/path_test.go index b107d7ef44..1723be0674 100644 --- a/cli/dispatcher/internal/nativepath/path_test.go +++ b/cli/dispatcher/internal/nativepath/path_test.go @@ -2,6 +2,7 @@ package nativepath import ( "errors" + "os" "strings" "testing" ) @@ -235,3 +236,101 @@ func TestCommandPathPreservesPosixRootDirectory(t *testing.T) { t.Fatalf("target path mismatch: %s", targetPath) } } + +func homeEnvironment(home string, homeErr error, values map[string]string) Environment { + return Environment{ + Getenv: func(name string) string { return values[name] }, + UserHomeDir: func() (string, error) { return home, homeErr }, + } +} + +func TestResolveInstallDirFallsBackToOSDefault(t *testing.T) { + // Verifies a missing explicit and environment directory falls back to the OS default under home. + installDir, err := ResolveInstallDir("linux", "", homeEnvironment("/home/", nil, nil)) + if err != nil { + t.Fatalf("ResolveInstallDir failed: %v", err) + } + if installDir != "/home//.local/bin" { + t.Fatalf("install dir mismatch: %s", installDir) + } +} + +func TestHomeBasedDirectoriesReportHomeLookupFailure(t *testing.T) { + // Verifies every home-based directory returns the home lookup error instead of a guessed path. + homeErr := errors.New("home lookup failed") + environment := homeEnvironment("", homeErr, nil) + if _, err := DefaultInstallDir("darwin", environment); !errors.Is(err, homeErr) { + t.Fatalf("DefaultInstallDir error = %v, want the home lookup error", err) + } + for _, goos := range []string{"darwin", "linux"} { + if _, err := CacheRoot(goos, environment); !errors.Is(err, homeErr) { + t.Fatalf("CacheRoot(%s) error = %v, want the home lookup error", goos, err) + } + } +} + +func TestCacheRootUsesOSDefaults(t *testing.T) { + // Verifies the cache root defaults to the macOS caches folder, LOCALAPPDATA on Windows, and + // ~/.cache on Linux when XDG_CACHE_HOME is unset. + cases := []struct { + goos string + values map[string]string + want string + }{ + {goos: "darwin", want: "/Users//Library/Caches/uloop"}, + {goos: "windows", values: map[string]string{LocalAppDataEnvName: `C:\Users\\AppData\Local`}, want: `C:\Users\\AppData\Local\uloop`}, + {goos: "linux", want: "/Users//.cache/uloop"}, + } + for _, testCase := range cases { + cacheRoot, err := CacheRoot(testCase.goos, homeEnvironment("/Users/", nil, testCase.values)) + if err != nil { + t.Fatalf("CacheRoot(%s) failed: %v", testCase.goos, err) + } + if cacheRoot != testCase.want { + t.Fatalf("CacheRoot(%s) = %q, want %q", testCase.goos, cacheRoot, testCase.want) + } + } +} + +func TestCacheRootRejectsMissingWindowsLocalAppData(t *testing.T) { + // Verifies Windows cache resolution fails with its own message when LOCALAPPDATA is missing. + _, err := CacheRoot("windows", homeEnvironment("/unused", nil, nil)) + if err == nil || err.Error() != "LOCALAPPDATA is required to resolve the uloop cache directory" { + t.Fatalf("err = %v", err) + } +} + +func TestCommandPathUsesWindowsSeparatorAndCommandName(t *testing.T) { + // Verifies Windows command paths use a backslash and the Windows command name. + commandPath := CommandPath("windows", `C:\Tools\uloop\`, "uloop", "uloop.exe") + if commandPath != `C:\Tools\uloop\uloop.exe` { + t.Fatalf("command path mismatch: %s", commandPath) + } +} + +func TestEnvironmentWithoutFunctionsUsesProcessDefaults(t *testing.T) { + // Verifies a zero Environment reads no variables and falls back to the process home directory. + t.Setenv(InstallDirEnvName, "/ignored/by/zero/environment") + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("process home directory unavailable: %v", err) + } + + installDir, err := ResolveInstallDir("linux", "", Environment{}) + if err != nil { + t.Fatalf("ResolveInstallDir failed: %v", err) + } + if installDir != Join("linux", home, ".local", "bin") { + t.Fatalf("install dir = %q, want it under the process home %q", installDir, home) + } +} + +func TestDefaultEnvironmentReadsTheProcessEnvironment(t *testing.T) { + // Verifies DefaultEnvironment reads variables from the current process. + t.Setenv(CacheDirEnvName, "/from/process/env") + + cacheRoot, err := CacheRoot("linux", DefaultEnvironment()) + if err != nil || cacheRoot != "/from/process/env" { + t.Fatalf("cacheRoot=%q err=%v", cacheRoot, err) + } +} diff --git a/cli/dispatcher/internal/update/command_test.go b/cli/dispatcher/internal/update/command_test.go new file mode 100644 index 0000000000..0af31a55e1 --- /dev/null +++ b/cli/dispatcher/internal/update/command_test.go @@ -0,0 +1,102 @@ +package update + +import ( + "reflect" + "testing" +) + +// Verifies each supported OS gets its own shell and installer script, pinned to the current +// version's release assets and the matching update channel. +func TestCommandForOSBuildsTheInstallerCommandPerOS(t *testing.T) { + cases := []struct { + goos string + wantName string + wantScript string + }{ + {goos: "darwin", wantName: "sh", wantScript: PosixScriptName}, + {goos: "linux", wantName: "sh", wantScript: PosixScriptName}, + {goos: "windows", wantName: "powershell", wantScript: WindowsScriptName}, + } + for _, testCase := range cases { + t.Run(testCase.goos, func(t *testing.T) { + command, err := CommandForOS(testCase.goos, Options{CurrentVersion: "3.1.0"}) + if err != nil { + t.Fatalf("CommandForOS failed: %v", err) + } + + assetURL := "https://github.com/hatayama/unity-cli-loop/releases/download/dispatcher-v3.1.0/" + testCase.wantScript + want := Command{ + Name: testCase.wantName, + Env: []string{"ULOOP_VERSION=" + LatestStable}, + InstallerName: testCase.wantScript, + InstallerURL: assetURL, + InstallerChecksumURL: assetURL + ".sha256", + ReleaseTag: "dispatcher-v3.1.0", + } + if !reflect.DeepEqual(command, want) { + t.Fatalf("command = %#v, want %#v", command, want) + } + }) + } +} + +// Verifies an OS without a native installer is rejected with the unsupported-OS message. +func TestCommandForOSRejectsUnsupportedOS(t *testing.T) { + _, err := CommandForOS("plan9", Options{CurrentVersion: "3.1.0"}) + + if err == nil || err.Error() != UnsupportedOSMessage { + t.Fatalf("err = %v, want %q", err, UnsupportedOSMessage) + } +} + +// Verifies an explicit target version replaces the current version for both the installer assets +// and the update selector, which then names that exact dispatcher release. +func TestCommandForOSUsesTheTargetVersion(t *testing.T) { + command, err := CommandForOS("linux", Options{CurrentVersion: "3.0.0-beta.2", TargetVersion: "v3.2.0"}) + if err != nil { + t.Fatalf("CommandForOS failed: %v", err) + } + + if command.ReleaseTag != "dispatcher-v3.2.0" { + t.Fatalf("ReleaseTag = %q, want dispatcher-v3.2.0", command.ReleaseTag) + } + if !reflect.DeepEqual(command.Env, []string{"ULOOP_VERSION=dispatcher-v3.2.0"}) { + t.Fatalf("Env = %#v", command.Env) + } +} + +// Verifies a beta current version selects the beta channel when no target version is given. +func TestSelectorUsesTheBetaChannelForBetaVersions(t *testing.T) { + if selector := Selector(Options{CurrentVersion: "3.0.0-beta.2"}); selector != LatestBeta { + t.Fatalf("Selector = %q, want %q", selector, LatestBeta) + } +} + +// Verifies target version normalization strips each accepted release-tag prefix, case-insensitively, +// and leaves surrounding whitespace out. +func TestNormalizeTargetVersionStripsReleasePrefixes(t *testing.T) { + cases := map[string]string{ + " 3.2.0 ": "3.2.0", + "v3.2.0": "3.2.0", + "V3.2.0": "3.2.0", + "dispatcher-v3.2.0": "3.2.0", + "Dispatcher-V3.2.0": "3.2.0", + "uloop-project-runner-v3.2.0": "3.2.0", + "ULOOP-PROJECT-RUNNER-V3.2.0": "3.2.0", + } + for input, want := range cases { + if got := NormalizeTargetVersion(input); got != want { + t.Fatalf("NormalizeTargetVersion(%q) = %q, want %q", input, got, want) + } + } +} + +// Verifies the target version check accepts semantic versions and rejects anything else. +func TestIsValidTargetVersion(t *testing.T) { + if !IsValidTargetVersion("3.2.0-beta.1") { + t.Fatal("a semantic version must be valid") + } + if IsValidTargetVersion("latest") { + t.Fatal("a channel name must not be a valid target version") + } +} From 963647bcee11debf473bd24f04188d380277cf5f Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 04:54:41 +0900 Subject: [PATCH 15/68] chore: The release-automation legacy-probe failure test no longer flakes under load (#3090) --- .../automation/contract_file_at_ref_test.go | 62 ++++++++++++++----- .../mock_cli_executable_for_tests_test.go | 2 + .../automation/mock_git_for_tests_test.go | 9 +++ .../automation/testdata/mockcli/main.go | 9 +++ 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/cli/release-automation/internal/automation/contract_file_at_ref_test.go b/cli/release-automation/internal/automation/contract_file_at_ref_test.go index 0835b76ddd..9ba16d40cc 100644 --- a/cli/release-automation/internal/automation/contract_file_at_ref_test.go +++ b/cli/release-automation/internal/automation/contract_file_at_ref_test.go @@ -284,35 +284,45 @@ func TestContractFileAtRefWithLegacyFallback_WhenAllPathsAbsentAtExistingRef_Ret } // Verifies that when execution reaches the legacy loop and the first legacy -// probe fails as an execution error (its git process is killed by a context -// timeout while the primary show and probe plus rev-parse have already +// probe fails as an execution error (its git process is killed by context +// cancellation after the primary show and probe plus rev-parse have // completed), the returned error names the failing legacy path AND still // carries the original primary show error text so operators keep both signals. func TestContractFileAtRefWithLegacyFallback_WhenLegacyChainProbeFails_PreservesOriginalShowError(t *testing.T) { workDir, binDir := setupMockGitBin(t) + probeStartedMarker := filepath.Join(workDir, "legacy-probe-started") - // Only the first legacy probe blocks; every other invocation (primary - // show/probe, rev-parse verify) returns immediately so the control flow - // deterministically reaches the legacy loop before the timeout fires. + // Only the first legacy probe blocks, and it announces itself first. The + // context is cancelled on that announcement rather than on a timer, so + // slow process start-up for the earlier git calls cannot end the run before + // the legacy loop is reached. writeExistenceMockGit(t, binDir, mockGitExistenceFixture{ refResolves: true, paths: map[string]mockGitPathBehavior{ "generation/primary.json": {exists: false, showOK: false, showStderr: "fatal: primary missing"}, - "generation/first.json": {probeSleeps: true}, + "generation/first.json": {probeSleeps: true, probeStartedMarkerPath: probeStartedMarker}, "generation/second.json": {exists: false, showOK: false, showStderr: "fatal: second missing"}, }, }) - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() + resultErrors := make(chan error, 1) + go func() { + _, err := contractFileAtRefWithLegacyFallback( + ctx, + workDir, + "some-ref", + "generation/primary.json", + "generation/first.json", + "generation/second.json") + resultErrors <- err + }() + + waitForFile(t, probeStartedMarker, resultErrors) + cancel() + err := <-resultErrors - _, err := contractFileAtRefWithLegacyFallback( - ctx, - workDir, - "some-ref", - "generation/primary.json", - "generation/first.json", - "generation/second.json") if err == nil { t.Fatal("expected an error when the mid-chain probe is killed") } @@ -325,11 +335,33 @@ func TestContractFileAtRefWithLegacyFallback_WhenLegacyChainProbeFails_Preserves if !strings.Contains(err.Error(), "some-ref:generation/primary.json") { t.Fatalf("expected the primary path to appear in the preserved show error, got: %v", err) } - if !errors.Is(err, context.DeadlineExceeded) { + if !errors.Is(err, context.Canceled) { t.Fatalf("expected the context error to be wrapped, got: %v", err) } } +// waitForFile blocks until path exists. It fails the test if the call under +// test returns first (the awaited step was never reached) or if the generous +// bound passes, so a broken mock cannot hang the suite. +func waitForFile(t *testing.T, path string, earlyResult <-chan error) { + t.Helper() + deadline := time.After(30 * time.Second) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(path); err == nil { + return + } + select { + case err := <-earlyResult: + t.Fatalf("returned before %s was created: %v", filepath.Base(path), err) + case <-deadline: + t.Fatalf("timed out waiting for %s", filepath.Base(path)) + case <-ticker.C: + } + } +} + // Verifies anyOfFilesExistsAtRef returns true as soon as a later path in the // list is present, so the classifier does not need to know which generation // won. diff --git a/cli/release-automation/internal/automation/mock_cli_executable_for_tests_test.go b/cli/release-automation/internal/automation/mock_cli_executable_for_tests_test.go index 4eb0a82821..b44b114060 100644 --- a/cli/release-automation/internal/automation/mock_cli_executable_for_tests_test.go +++ b/cli/release-automation/internal/automation/mock_cli_executable_for_tests_test.go @@ -41,6 +41,7 @@ type mockCliPathConfig struct { ShowContentPath string `json:"showContentPath,omitempty"` ShowStderr string `json:"showStderr,omitempty"` ProbeSleeps bool `json:"probeSleeps,omitempty"` + ProbeMarkerPath string `json:"probeMarkerPath,omitempty"` } func TestMain(m *testing.M) { @@ -135,6 +136,7 @@ func existenceMockCliConfig(fixture mockGitExistenceFixture) mockCliExecutableCo ShowContentPath: behavior.showContentPath, ShowStderr: behavior.showStderr, ProbeSleeps: behavior.probeSleeps, + ProbeMarkerPath: behavior.probeStartedMarkerPath, } } diff --git a/cli/release-automation/internal/automation/mock_git_for_tests_test.go b/cli/release-automation/internal/automation/mock_git_for_tests_test.go index 0e6e44c0a2..99bf4ebac9 100644 --- a/cli/release-automation/internal/automation/mock_git_for_tests_test.go +++ b/cli/release-automation/internal/automation/mock_git_for_tests_test.go @@ -32,6 +32,10 @@ type mockGitPathBehavior struct { // caller with a short context timeout can force a probe execution failure // mid-chain. Other paths on the same fixture stay deterministic. probeSleeps bool + // probeStartedMarkerPath, when set with probeSleeps, is created just before + // the probe blocks, so a test can cancel once execution has provably reached + // this probe instead of racing a wall-clock timeout against the calls before it. + probeStartedMarkerPath string } // mockGitExistenceFixture drives the shared existence-probe mock git script. @@ -103,6 +107,11 @@ func buildExistenceMockGitScript(fixture mockGitExistenceFixture) string { // A long sleep lets a caller with a short context timeout kill // this specific probe, exercising the mid-chain wrap branch // without affecting other paths on the same fixture. + if behavior.probeStartedMarkerPath != "" { + catFileCases.WriteString(" : > ") + catFileCases.WriteString(shellSingleQuote(behavior.probeStartedMarkerPath)) + catFileCases.WriteString("\n") + } catFileCases.WriteString(" sleep 10\n") catFileCases.WriteString(" exit 0\n") } else if behavior.exists { diff --git a/cli/release-automation/internal/automation/testdata/mockcli/main.go b/cli/release-automation/internal/automation/testdata/mockcli/main.go index 475cbe4080..8c227fd48b 100644 --- a/cli/release-automation/internal/automation/testdata/mockcli/main.go +++ b/cli/release-automation/internal/automation/testdata/mockcli/main.go @@ -36,6 +36,7 @@ type mockCliPathConfig struct { ShowContentPath string `json:"showContentPath,omitempty"` ShowStderr string `json:"showStderr,omitempty"` ProbeSleeps bool `json:"probeSleeps,omitempty"` + ProbeMarkerPath string `json:"probeMarkerPath,omitempty"` } func main() { @@ -155,6 +156,14 @@ func runExistenceGit(config mockCliConfig, args []string) int { } behavior := config.Paths[key] if behavior.ProbeSleeps { + // Mirrors the shell mock: announce that this probe started before + // blocking, so the test can cancel on that event. + if behavior.ProbeMarkerPath != "" { + if err := os.WriteFile(behavior.ProbeMarkerPath, nil, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "mockcli: failed to write probe marker: %v\n", err) + return 1 + } + } time.Sleep(10 * time.Second) return 0 } From 93ba78e4eb34bbdb68e85698146e97099e69ab74 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 05:06:51 +0900 Subject: [PATCH 16/68] chore: Repository checks in the release-automation CLI now have tests for failure handling (#3087) --- .../internal/automation/asmdef_policy_test.go | 117 +++++++++++++++ .../automation/codeql_sarif_guard_test.go | 43 ++++++ .../internal/automation/file_length_test.go | 106 +++++++++++++ .../automation/pr_title_guard_test.go | 26 +++- .../automation/release_pr_check_runs_test.go | 115 ++++++++++++++ .../automation/release_pr_lookup_test.go | 60 ++++++++ .../internal/automation/skill_size_test.go | 29 ++++ .../automation/tool_catalog_shape_test.go | 35 +++++ .../automation/tool_docs_json_editor_test.go | 74 +++++++++ .../automation/tool_docs_sync_test.go | 141 ++++++++++++++++++ 10 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 cli/release-automation/internal/automation/release_pr_check_runs_test.go create mode 100644 cli/release-automation/internal/automation/release_pr_lookup_test.go create mode 100644 cli/release-automation/internal/automation/tool_docs_json_editor_test.go diff --git a/cli/release-automation/internal/automation/asmdef_policy_test.go b/cli/release-automation/internal/automation/asmdef_policy_test.go index f36498a619..8850907f76 100644 --- a/cli/release-automation/internal/automation/asmdef_policy_test.go +++ b/cli/release-automation/internal/automation/asmdef_policy_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -410,3 +411,119 @@ func TestRunAsmdefPolicyCheckRejectsMalformedAllowlist(t *testing.T) { t.Fatalf("stderr did not explain the malformed entry: %q", stderr.String()) } } + +func writeAsmdefRawFile(t *testing.T, root string, relativePath string, content string) { + t.Helper() + absolutePath := filepath.Join(root, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// Verifies an unparsable .asmdef, an .asmdef without a name, and a .meta without a guid line each fail loading with their own error. +func TestLoadAsmdefAssembliesRejectsMalformedFiles(t *testing.T) { + cases := []struct { + name string + asmdef string + meta string + wantErr string + }{ + {"unparsable asmdef", "{", "guid: " + fixtureGUIDDomain + "\n", "parse "}, + {"asmdef without name", `{"references":[]}`, "guid: " + fixtureGUIDDomain + "\n", "has no assembly name"}, + {"meta without guid", `{"name":"UnityCLILoop.Domain"}`, "fileFormatVersion: 2\n", "Broken.asmdef.meta has no guid line"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + root := t.TempDir() + writeAsmdefRawFile(t, root, "Packages/src/Editor/Broken.asmdef", testCase.asmdef) + writeAsmdefRawFile(t, root, "Packages/src/Editor/Broken.asmdef.meta", testCase.meta) + + _, err := LoadAsmdefAssemblies(root) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// Verifies the check fails with its prefix when assemblies cannot be loaded, when an assembly has no policy category, and when the allowlist is missing or unparsable. +func TestRunAsmdefPolicyCheckReportsUnusableInputs(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, root string) string + wantErr string + }{ + {"unloadable assembly", func(t *testing.T, root string) string { + writeAsmdefRawFile(t, root, "Packages/src/Editor/Broken.asmdef", "{") + return "" + }, "check-asmdef-policy: parse "}, + {"assembly without category", func(t *testing.T, root string) string { + writeAsmdefFixture(t, root, asmdefFixture{dir: "Editor/Mystery", name: "UnityCLILoop.Mystery", guid: fixtureGUIDDomain}) + return "" + }, "matches no assembly category"}, + {"missing allowlist", func(t *testing.T, root string) string { + writePolicyFixtureRepository(t, root, []string{guidReference(fixtureGUIDToolContracts)}) + return filepath.Join(root, "missing-allowlist.json") + }, "check-asmdef-policy: read allowlist"}, + {"unparsable allowlist", func(t *testing.T, root string) string { + writePolicyFixtureRepository(t, root, []string{guidReference(fixtureGUIDToolContracts)}) + writeAsmdefRawFile(t, root, "allowlist.json", "{") + return filepath.Join(root, "allowlist.json") + }, "check-asmdef-policy: parse allowlist"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + root := t.TempDir() + allowlistPath := testCase.setup(t, root) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunAsmdefPolicyCheck(&stdout, &stderr, AsmdefPolicyCheckOptions{Root: root, AllowlistPath: allowlistPath}) + + if exitCode != 1 || !strings.Contains(stderr.String(), testCase.wantErr) { + t.Fatalf("exit code = %d, expected stderr containing %q, got %q", exitCode, testCase.wantErr, stderr.String()) + } + }) + } +} + +// Verifies the check reads the repository's default allowlist when no allowlist path is given. +func TestRunAsmdefPolicyCheckUsesTheDefaultAllowlist(t *testing.T) { + root := t.TempDir() + writePolicyFixtureRepository(t, root, []string{guidReference(fixtureGUIDToolB)}) + defaultAllowlistPath := filepath.Join(root, filepath.FromSlash(DefaultAsmdefPolicyAllowlistPath)) + if err := os.MkdirAll(filepath.Dir(defaultAllowlistPath), 0o755); err != nil { + t.Fatal(err) + } + writeAsmdefAllowlist(t, defaultAllowlistPath, []asmdefAllowedReference{ + {From: toolName("A"), To: toolName("B"), Reason: "pending extraction"}, + }) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + exitCode := RunAsmdefPolicyCheck(&stdout, &stderr, AsmdefPolicyCheckOptions{Root: root}) + + if exitCode != 0 { + t.Fatalf("expected the default allowlist to cover the violation, got exit %d (stdout %q, stderr %q)", exitCode, stdout.String(), stderr.String()) + } +} + +// Verifies a package source root whose parent is a file fails loading instead of being treated as an absent package. +func TestLoadAsmdefAssembliesRejectsAnUnreadableSourceRoot(t *testing.T) { + // Windows reports a path through a file as not found, which loading rightly treats as an absent package. + if runtime.GOOS == "windows" { + t.Skip("a path through a file is reported as not found on Windows") + } + root := t.TempDir() + writeAsmdefRawFile(t, root, "Packages", "x") + + _, err := LoadAsmdefAssemblies(root) + + if err == nil || !strings.Contains(err.Error(), "stat ") { + t.Fatalf("expected the stat error, got %v", err) + } +} diff --git a/cli/release-automation/internal/automation/codeql_sarif_guard_test.go b/cli/release-automation/internal/automation/codeql_sarif_guard_test.go index 63824b329f..c511e1532c 100644 --- a/cli/release-automation/internal/automation/codeql_sarif_guard_test.go +++ b/cli/release-automation/internal/automation/codeql_sarif_guard_test.go @@ -288,3 +288,46 @@ func TestIntJSONFormatsThreeDigitValues(t *testing.T) { func intJSON(value int) string { return strconv.Itoa(value) } + +// Verifies the file entry point validates what it reads: a valid report on disk passes with the same drift warnings as the in-memory check. +func TestValidateCodeQLSARIFFileValidatesTheFileContent(t *testing.T) { + data := validCodeQLSARIF(`"CodeQL"`, true, 55, 70) + path := filepath.Join(t.TempDir(), "codeql.sarif") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write SARIF: %v", err) + } + + result, err := ValidateCodeQLSARIFFile(path) + if err != nil { + t.Fatalf("ValidateCodeQLSARIFFile failed: %v", err) + } + if len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0], "below the 2026-07-15 PoC baseline") { + t.Fatalf("warnings = %v", result.Warnings) + } +} + +// Verifies malformed JSON, a run count other than one, an unreadable quality diagnostic, overflowing quality figures, and too few lines of code each fail with their own error. +func TestValidateCodeQLSARIFRejectsMalformedReports(t *testing.T) { + valid := string(validCodeQLSARIF(`"CodeQL"`, true, 55, 70)) + cases := []struct { + name string + data string + wantErr string + }{ + {"invalid JSON", "{", "invalid SARIF JSON"}, + {"no runs", `{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","version":"2.1.0","runs":[]}`, "expected exactly one SARIF run, got 0"}, + {"unsupported quality format", strings.Replace(valid, "Percentage of calls with call target: 55 %", "Calls resolved: 55", 1), "CodeQL database quality diagnostic has an unsupported format"}, + {"call target overflow", strings.Replace(valid, "call target: 55 %", "call target: 99999999999999999999 %", 1), "parse CodeQL call target quality"}, + {"known type overflow", strings.Replace(valid, "known type: 70 %", "known type: 99999999999999999999 %", 1), "parse CodeQL known type quality"}, + {"lines of code below floor", strings.Replace(valid, `"value":75000`, `"value":10`, 1), "CodeQL extracted lines of code 10 is below the approved floor"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := ValidateCodeQLSARIF([]byte(testCase.data)) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} diff --git a/cli/release-automation/internal/automation/file_length_test.go b/cli/release-automation/internal/automation/file_length_test.go index 0cc507262b..0ae30f18ad 100644 --- a/cli/release-automation/internal/automation/file_length_test.go +++ b/cli/release-automation/internal/automation/file_length_test.go @@ -2,8 +2,10 @@ package automation import ( "bytes" + "fmt" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -248,3 +250,107 @@ func TestCountSLOCHandlesDoubledQuotesInInterpolatedVerbatimStrings(t *testing.T } } } + +// Verifies a non-positive limit falls back to the default, files with other extensions or another root's language are ignored, and upper-case extensions still count. +func TestRunFileLengthCheckAppliesTheDefaultLimitAndExtensionFilter(t *testing.T) { + root := t.TempDir() + writeRepoFile(t, root, "cli/notes.txt", goLines(DefaultMaxFileLength+1)) + writeRepoFile(t, root, "cli/Other.cs", csharpLines(DefaultMaxFileLength+1)) + writeRepoFile(t, root, "Packages/src/Upper.CS", csharpLines(DefaultMaxFileLength+1)) + writeRepoFile(t, root, "Packages/src/AtLimit.cs", csharpLines(DefaultMaxFileLength)) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := RunFileLengthCheck(stdout, stderr, FileLengthCheckOptions{Root: root, FailOnExceeded: true}) + + if exitCode != 1 { + t.Fatalf("expected exit 1, got %d (stdout %q)", exitCode, stdout.String()) + } + want := fmt.Sprintf("=== File length (SLOC, max %d) ===\nPackages/src/Upper.CS: %d SLOC (limit %d)\n1 files exceeded", DefaultMaxFileLength, DefaultMaxFileLength+1, DefaultMaxFileLength) + if !strings.HasPrefix(stdout.String(), want) { + t.Fatalf("stdout = %q, want prefix %q", stdout.String(), want) + } +} + +// Verifies a tree with no file over the limit reports success and exits 0 even in fail mode. +func TestRunFileLengthCheckPassesWhenNothingExceeds(t *testing.T) { + root := t.TempDir() + writeRepoFile(t, root, "cli/short.go", goLines(2)) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := RunFileLengthCheck(stdout, stderr, FileLengthCheckOptions{Root: root, MaxLength: 2, FailOnExceeded: true}) + + if exitCode != 0 || !strings.Contains(stdout.String(), "No files exceeded the file-length limit.") { + t.Fatalf("exit code = %d, stdout = %q", exitCode, stdout.String()) + } +} + +// Verifies a production root that is a file, or whose parent is a file, fails the check instead of being skipped as absent. +func TestRunFileLengthCheckRejectsUnusableSourceRoots(t *testing.T) { + cases := []struct { + name string + filePath string + wantErr string + }{ + {"root is a file", "cli", "check-file-length: production source root cli is not a directory"}, + {"parent is a file", "Packages", "check-file-length: stat "}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + // Windows reports a path through a file as not found, which the scan rightly treats as an absent root. + if testCase.name == "parent is a file" && runtime.GOOS == "windows" { + t.Skip("a path through a file is reported as not found on Windows") + } + root := t.TempDir() + writeRepoFile(t, root, testCase.filePath, "x") + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + exitCode := RunFileLengthCheck(stdout, stderr, FileLengthCheckOptions{Root: root}) + + if exitCode != 1 || !strings.Contains(stderr.String(), testCase.wantErr) { + t.Fatalf("exit code = %d, expected stderr containing %q, got %q", exitCode, testCase.wantErr, stderr.String()) + } + }) + } +} + +// Verifies the exclusion list matches Tests and testdata trees at the start of a path as well as nested, and _test.go files. +func TestIsExcludedFromFileLength(t *testing.T) { + cases := map[string]bool{ + "Tests/Sample.cs": true, + "Packages/src/Tests/Sample.cs": true, + "testdata/sample.go": true, + "cli/x/testdata/sample.go": true, + "cli/x/sample_test.go": true, + "cli/x/sample.go": false, + "Packages/src/TestsHelper.cs": false, + } + for path, want := range cases { + if got := isExcludedFromFileLength(path); got != want { + t.Fatalf("isExcludedFromFileLength(%q) = %t, want %t", path, got, want) + } + } +} + +// Verifies C# constructs that change how later lines are read: a lone slash is code, braces nested in an interpolation hole keep the hole open, and escapes and doubled braces stay inside the string. +func TestCountSLOCTracksCSharpStringAndHoleBoundaries(t *testing.T) { + cases := []struct { + name string + source string + want int + }{ + {"division on its own line", "var x = 4\n/ 2;\n", 2}, + {"nested braces in a hole across lines", "var s = $\"{new { A = 1 }\n// comment inside the hole\n.A}\";\n", 2}, + {"nested braces in a hole", "var s = $\"{new { A = 1 }.A}\";\n// c\n", 1}, + {"doubled quote in a verbatim string", "var s = @\"a\"\"\\\";\n// c\n", 1}, + {"escaped quote in an interpolated string", "var s = $\"\\\\\\\" /* \";\nint x = 1;\n// */\n", 2}, + {"doubled brace in an interpolated string", "var s = $\"{{ /* \";\nint x = 1;\n// */\n", 2}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + assertSLOC(t, testCase.source, LanguageCSharp, testCase.want) + }) + } +} diff --git a/cli/release-automation/internal/automation/pr_title_guard_test.go b/cli/release-automation/internal/automation/pr_title_guard_test.go index f7254ba3cb..5777386a38 100644 --- a/cli/release-automation/internal/automation/pr_title_guard_test.go +++ b/cli/release-automation/internal/automation/pr_title_guard_test.go @@ -1,6 +1,9 @@ package automation -import "testing" +import ( + "strings" + "testing" +) // Verifies conventional commit titles with common types and forms pass validation. func TestCheckPRTitleAcceptsValidConventionalCommitTitles(t *testing.T) { @@ -64,3 +67,24 @@ func TestCheckPRTitleRejectsUppercaseType(t *testing.T) { t.Fatalf("expected %q to be rejected", title) } } + +// Verifies the guard prints the violation to stderr with exit code 1 for a bad title and the pass line to stdout for a good one. +func TestRunPRTitleGuardReportsTheOutcome(t *testing.T) { + stdout := strings.Builder{} + stderr := strings.Builder{} + if exitCode := RunPRTitleGuard(&stdout, &stderr, "Update things"); exitCode != 1 { + t.Fatalf("expected exit code 1 for a bad title, got %d", exitCode) + } + if !strings.Contains(stderr.String(), `PR title "Update things" is not a conventional commit header.`) || stdout.Len() != 0 { + t.Fatalf("stdout = %q, stderr = %q", stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if exitCode := RunPRTitleGuard(&stdout, &stderr, "fix: handle empty input"); exitCode != 0 { + t.Fatalf("expected exit code 0 for a good title, got %d", exitCode) + } + if stdout.String() != "PR title guard passed.\n" || stderr.Len() != 0 { + t.Fatalf("stdout = %q, stderr = %q", stdout.String(), stderr.String()) + } +} diff --git a/cli/release-automation/internal/automation/release_pr_check_runs_test.go b/cli/release-automation/internal/automation/release_pr_check_runs_test.go new file mode 100644 index 0000000000..17153c7159 --- /dev/null +++ b/cli/release-automation/internal/automation/release_pr_check_runs_test.go @@ -0,0 +1,115 @@ +package automation + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +var releasePRCheckRunDispatchedAt = time.Date(2026, 9, 8, 1, 0, 0, 0, time.UTC) + +// releasePRCheckRunLookup answers successive gh run list calls in order and records how long each sleep asked to wait. +type releasePRCheckRunLookup struct { + answers []string + err error + sleepErr error + calls []string + sleeps []time.Duration +} + +func (lookup *releasePRCheckRunLookup) deps() releasePRCheckDeps { + return releasePRCheckDeps{ + sleep: func(_ context.Context, duration time.Duration) error { + lookup.sleeps = append(lookup.sleeps, duration) + return lookup.sleepErr + }, + runOutput: func(_ context.Context, name string, args ...string) (string, error) { + lookup.calls = append(lookup.calls, strings.Join(append([]string{name}, args...), " ")) + if lookup.err != nil { + return "", lookup.err + } + index := len(lookup.calls) - 1 + if index >= len(lookup.answers) { + index = len(lookup.answers) - 1 + } + return lookup.answers[index], nil + }, + } +} + +func findReleasePRCheckRunForTest(lookup *releasePRCheckRunLookup, attempts int) (releaseWorkflowRun, error) { + config := releasePRCheckConfig{repository: "owner/repository", lookupAttempts: attempts, lookupIntervalSeconds: 7} + releasePR := releasePullRequest{HeadRefName: "release-branch", HeadRefOID: "head123"} + return findDispatchedReleasePRCheckRun(context.Background(), config, "ci.yml", releasePR, releasePRCheckRunDispatchedAt, lookup.deps()) +} + +// Verifies the newest run created at or after the dispatch is chosen even when gh lists it before an older one, and runs created before the dispatch are ignored. +func TestFindDispatchedReleasePRCheckRunPicksTheNewestRunSinceDispatch(t *testing.T) { + lookup := &releasePRCheckRunLookup{answers: []string{`[ + {"databaseId":1,"headSha":"head123","createdAt":"2026-09-08T00:59:59Z"}, + {"databaseId":2,"headSha":"head123","createdAt":"2026-09-08T01:00:05Z"}, + {"databaseId":3,"headSha":"head123","createdAt":"2026-09-08T01:00:09Z"}, + {"databaseId":5,"headSha":"head123","createdAt":"2026-09-08T01:00:07Z"} + ]`}} + + run, err := findReleasePRCheckRunForTest(lookup, 1) + if err != nil { + t.Fatalf("findDispatchedReleasePRCheckRun failed: %v", err) + } + if run.DatabaseID != 3 { + t.Fatalf("expected run 3, got %+v", run) + } + wantCall := "gh run list --repo owner/repository --workflow ci.yml --branch release-branch --event workflow_dispatch" + if !strings.HasPrefix(lookup.calls[0], wantCall) { + t.Fatalf("call = %q, want prefix %q", lookup.calls[0], wantCall) + } +} + +// Verifies a lookup that only sees runs from before the dispatch waits the lookup interval, retries, and returns the run that appears. +func TestFindDispatchedReleasePRCheckRunRetriesUntilTheRunAppears(t *testing.T) { + lookup := &releasePRCheckRunLookup{answers: []string{ + `[{"databaseId":1,"headSha":"head123","createdAt":"2026-09-08T00:59:59Z"}]`, + `[{"databaseId":4,"headSha":"head123","createdAt":"2026-09-08T01:00:01Z"}]`, + }} + + run, err := findReleasePRCheckRunForTest(lookup, 2) + if err != nil { + t.Fatalf("findDispatchedReleasePRCheckRun failed: %v", err) + } + if run.DatabaseID != 4 || len(lookup.calls) != 2 { + t.Fatalf("run = %+v after %d calls", run, len(lookup.calls)) + } + if len(lookup.sleeps) != 1 || lookup.sleeps[0] != 7*time.Second { + t.Fatalf("sleeps = %v, want one 7s wait", lookup.sleeps) + } +} + +// Verifies a failing gh call, an unparsable listing, an invalid createdAt, an interrupted wait, and running out of attempts each fail with their own error. +func TestFindDispatchedReleasePRCheckRunReportsFailures(t *testing.T) { + onlyOlderRun := `[{"databaseId":1,"headSha":"head123","createdAt":"2026-09-08T00:59:59Z"}]` + cases := []struct { + name string + lookup releasePRCheckRunLookup + attempts int + wantErr string + }{ + {"gh failure", releasePRCheckRunLookup{err: errors.New("gh run list failed")}, 1, "gh run list failed"}, + {"listing JSON", releasePRCheckRunLookup{answers: []string{"{"}}, 1, "failed to parse workflow runs"}, + {"createdAt", releasePRCheckRunLookup{answers: []string{`[{"databaseId":1,"createdAt":"yesterday"}]`}}, 1, `failed to parse workflow run createdAt "yesterday"`}, + {"interrupted wait", releasePRCheckRunLookup{answers: []string{onlyOlderRun}, sleepErr: errors.New("lookup wait interrupted")}, 2, "lookup wait interrupted"}, + {"attempts exhausted", releasePRCheckRunLookup{answers: []string{onlyOlderRun}}, 1, "could not find dispatched ci.yml workflow run for head123"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + lookup := testCase.lookup + + _, err := findReleasePRCheckRunForTest(&lookup, testCase.attempts) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} diff --git a/cli/release-automation/internal/automation/release_pr_lookup_test.go b/cli/release-automation/internal/automation/release_pr_lookup_test.go new file mode 100644 index 0000000000..ebf0188a85 --- /dev/null +++ b/cli/release-automation/internal/automation/release_pr_lookup_test.go @@ -0,0 +1,60 @@ +package automation + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// Verifies which titles count as release-please release titles: plain and scoped "release" titles match, while other prefixes and a scope that never closes do not. +func TestReleasePRCheckTitleMatches(t *testing.T) { + cases := map[string]bool{ + "chore: release": true, + "chore: release 3.6.0": true, + "chore(main): release dispatcher": true, + "chore(main): release": true, + "chore(main): releases": false, + "chore(main release 3.6.0": false, + "feat: release 3.6.0": false, + "feat(main): release 3.6.0": false, + } + for title, want := range cases { + if got := releasePRCheckTitleMatches(title); got != want { + t.Fatalf("releasePRCheckTitleMatches(%q) = %t, want %t", title, got, want) + } + } +} + +// Verifies a matching release pull request without a head SHA fails the lookup instead of being checked against an empty commit. +func TestFindReleasePRCheckPullRequestsRejectsMissingHeadSHA(t *testing.T) { + deps := releasePRCheckDeps{runOutput: func(context.Context, string, ...string) (string, error) { + return `[{"number":7,"headRefName":"release-please--branches--main","headRefOid":"","title":"chore(main): release 3.6.0"}]`, nil + }} + + _, err := findReleasePRCheckPullRequests(context.Background(), releasePRCheckConfig{repository: "owner/repository", targetBranch: "main"}, deps) + + if err == nil || !strings.Contains(err.Error(), "release PR #7 has no head SHA") { + t.Fatalf("expected a missing head SHA error, got %v", err) + } +} + +// Verifies an interrupted wait between lookups fails with the sleep error instead of reporting that no release pull request exists. +func TestFindReleasePRCheckPullRequestsWithRetryStopsOnInterruptedWait(t *testing.T) { + calls := 0 + deps := releasePRCheckDeps{ + runOutput: func(context.Context, string, ...string) (string, error) { + calls++ + return "[]", nil + }, + sleep: func(context.Context, time.Duration) error { return errors.New("lookup wait interrupted") }, + } + config := releasePRCheckConfig{repository: "owner/repository", targetBranch: "main", lookupAttempts: 3, lookupIntervalSeconds: 1} + + _, err := findReleasePRCheckPullRequestsWithRetry(context.Background(), config, deps) + + if err == nil || err.Error() != "lookup wait interrupted" || calls != 1 { + t.Fatalf("expected the wait error after one lookup, got %v after %d lookups", err, calls) + } +} diff --git a/cli/release-automation/internal/automation/skill_size_test.go b/cli/release-automation/internal/automation/skill_size_test.go index 3c39da82e5..6d6ee46799 100644 --- a/cli/release-automation/internal/automation/skill_size_test.go +++ b/cli/release-automation/internal/automation/skill_size_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -129,3 +130,31 @@ func TestRunSkillSizeCheckPassesWhenAllSkillsFit(t *testing.T) { t.Fatalf("expected exit code 0, got %d (stderr: %s)", exitCode, stderr.String()) } } + +// Verifies a skill root that is a file, and a skill root whose parent is a file, fail the scan instead of being skipped as absent. +func TestScanSkillFileSizesRejectsUnusableSkillRoots(t *testing.T) { + t.Run("root is a file", func(t *testing.T) { + root := t.TempDir() + writeSkillFile(t, root, ".claude/skills", 1) + + _, err := ScanSkillFileSizes(root, MaxSkillFileBytes) + + if err == nil || !strings.Contains(err.Error(), "skill root .claude/skills is not a directory") { + t.Fatalf("expected a not-a-directory error, got %v", err) + } + }) + t.Run("parent is a file", func(t *testing.T) { + // Windows reports a path through a file as not found, which the scan rightly treats as an absent root. + if runtime.GOOS == "windows" { + t.Skip("a path through a file is reported as not found on Windows") + } + root := t.TempDir() + writeSkillFile(t, root, ".claude", 1) + + _, err := ScanSkillFileSizes(root, MaxSkillFileBytes) + + if err == nil || !strings.Contains(err.Error(), "stat ") { + t.Fatalf("expected the stat error, got %v", err) + } + }) +} diff --git a/cli/release-automation/internal/automation/tool_catalog_shape_test.go b/cli/release-automation/internal/automation/tool_catalog_shape_test.go index 8c7fb65b24..7d88e63764 100644 --- a/cli/release-automation/internal/automation/tool_catalog_shape_test.go +++ b/cli/release-automation/internal/automation/tool_catalog_shape_test.go @@ -241,3 +241,38 @@ func assertDifferentShapeDigest(t *testing.T, left string, right string) { t.Fatalf("expected different shape digests, both were %q", leftDigest) } } + +// Verifies an unparsable base catalog fails the comparison instead of being treated as a shape change. +func TestToolCatalogShapeChangedRejectsInvalidBase(t *testing.T) { + changed, err := ToolCatalogShapeChanged([]byte(`{"tools":[`), []byte(`{"tools":[]}`)) + + if err == nil || !strings.Contains(err.Error(), "invalid tool catalog JSON") { + t.Fatalf("expected an invalid JSON error, got changed=%t err=%v", changed, err) + } +} + +// Verifies catalogs with unexpected structure still hash: descriptions that can be reached are ignored, and every other value still counts as shape. +func TestToolCatalogShapeDigestHandlesUnexpectedStructure(t *testing.T) { + cases := []struct { + name string + left string + right string + wantSame bool + }{ + {"tools is not an array", `{"tools":"a"}`, `{"tools":"b"}`, false}, + {"tool entry is not an object", `{"tools":["a"]}`, `{"tools":["b"]}`, false}, + {"schema is not an object", `{"tools":[{"name":"x","description":"a","inputSchema":"s"}]}`, `{"tools":[{"name":"x","description":"b","inputSchema":"s"}]}`, true}, + {"properties is not an object", `{"tools":[{"name":"x","description":"a","inputSchema":{"properties":[1]}}]}`, `{"tools":[{"name":"x","description":"b","inputSchema":{"properties":[1]}}]}`, true}, + {"property is not an object", `{"tools":[{"name":"x","inputSchema":{"properties":{"A":"a","B":{"description":"a"}}}}]}`, `{"tools":[{"name":"x","inputSchema":{"properties":{"A":"a","B":{"description":"b"}}}}]}`, true}, + {"non-object property value", `{"tools":[{"name":"x","inputSchema":{"properties":{"A":"a"}}}]}`, `{"tools":[{"name":"x","inputSchema":{"properties":{"A":"b"}}}]}`, false}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if testCase.wantSame { + assertSameShapeDigest(t, testCase.left, testCase.right) + return + } + assertDifferentShapeDigest(t, testCase.left, testCase.right) + }) + } +} diff --git a/cli/release-automation/internal/automation/tool_docs_json_editor_test.go b/cli/release-automation/internal/automation/tool_docs_json_editor_test.go new file mode 100644 index 0000000000..88fde8a4fe --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_json_editor_test.go @@ -0,0 +1,74 @@ +package automation + +import ( + "strings" + "testing" +) + +// Verifies only tool and inputSchema/parameterSchema property descriptions are rewritten: a description outside "tools", one under another schema key, and escaped quotes around the old value leave every other byte untouched. +func TestReplaceCatalogDescriptionsRewritesOnlyCatalogDescriptions(t *testing.T) { + content := `{"description":"root","other":[{"description":"keep"}],"tools":[{"description":"say \"hi\" \\","parameterSchema":{"properties":{"P":{"description":"old p"}}},"otherSchema":{"properties":{"P":{"description":"keep"}}},"name":"t"}]}` + replacements := map[descriptionKey]string{ + {Tool: "t"}: "new ", + {Tool: "t", Property: "P"}: "new p", + } + + edited, err := replaceCatalogDescriptions([]byte(content), replacements) + if err != nil { + t.Fatalf("replaceCatalogDescriptions failed: %v", err) + } + want := `{"description":"root","other":[{"description":"keep"}],"tools":[{"description":"new ","parameterSchema":{"properties":{"P":{"description":"new p"}}},"otherSchema":{"properties":{"P":{"description":"keep"}}},"name":"t"}]}` + if string(edited) != want { + t.Fatalf("edited =\n%s\nwant\n%s", edited, want) + } +} + +// Verifies malformed JSON and a tool with no name field fail instead of producing a partially edited catalog. +func TestReplaceCatalogDescriptionsRejectsUnusableCatalogs(t *testing.T) { + cases := []struct { + name string + content string + wantErr string + }{ + {"truncated object", `{"tools":[{"description":"d"`, "EOF"}, + {"truncated value", `{"tools":[{"description":`, "EOF"}, + {"invalid token", `{"tools":[{"description":"d",]}`, "invalid character"}, + {"tool without name", `{"tools":[{"description":"d"}]}`, "tool at index 0 has no name field"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := replaceCatalogDescriptions([]byte(testCase.content), map[descriptionKey]string{}) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// Verifies the literal range is found only when the offset ends a string, and an opening quote preceded by an odd number of backslashes is skipped as escaped. +func TestStringLiteralRange(t *testing.T) { + cases := []struct { + name string + content string + endOffset int + wantStart int + wantOK bool + }{ + {"zero offset", `"a"`, 0, 0, false}, + {"offset past the end", `"a"`, 4, 0, false}, + {"offset not after a quote", `"a"x`, 4, 0, false}, + {"no opening quote", `a"`, 2, 0, false}, + {"escaped quote inside", `x"a\"b"`, 7, 1, true}, + {"escaped backslash before the closing quote", `x"a\\"`, 6, 1, true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + start, end, ok := stringLiteralRange([]byte(testCase.content), testCase.endOffset) + + if ok != testCase.wantOK || (ok && (start != testCase.wantStart || end != testCase.endOffset)) { + t.Fatalf("stringLiteralRange = (%d, %d, %t), want (%d, %d, %t)", start, end, ok, testCase.wantStart, testCase.endOffset, testCase.wantOK) + } + }) + } +} diff --git a/cli/release-automation/internal/automation/tool_docs_sync_test.go b/cli/release-automation/internal/automation/tool_docs_sync_test.go index c05940eacc..c3223ed517 100644 --- a/cli/release-automation/internal/automation/tool_docs_sync_test.go +++ b/cli/release-automation/internal/automation/tool_docs_sync_test.go @@ -284,3 +284,144 @@ func TestRunSyncToolDocsWritesThenPassesCheck(t *testing.T) { t.Fatalf("check mode failed right after writing: %s", stderr.String()) } } + +const fixtureRunTestsCatalogEntry = `{ + "name": "run-tests", + "description": "Stale run-tests description", + "inputSchema": { + "type": "object", + "properties": { + "TestMode": { + "type": "string", + "description": "Stale mode description" + } + } + } + }, + ` + +const fixtureRunTestsSkill = `--- +name: uloop-run-tests +toolName: run-tests +description: "Run Unity tests." +--- + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--test-mode`" + ` | string | - | EditMode or PlayMode | +| ` + "`--skip-compile`" + ` | boolean | - | Parsed by the native runner | +` + +// Verifies a table row for an option the native runner parses itself is accepted even though no schema property backs it. +func TestGenerateCatalogAcceptsCliOnlyTableRows(t *testing.T) { + catalog := strings.Replace(fixtureCatalogContent, `"tools": [ + `, `"tools": [ + `+fixtureRunTestsCatalogEntry, 1) + skills := defaultGeneratorSkills() + skills["FirstPartyTools/RunTests"] = fixtureRunTestsSkill + repositoryRoot := writeGeneratorFixture(t, skills, catalog) + + generated, err := GenerateCatalogWithSkillDescriptions([]byte(catalog), repositoryRoot) + if err != nil { + t.Fatalf("generation failed: %v", err) + } + if !strings.Contains(string(generated), `"EditMode or PlayMode"`) { + t.Fatalf("the run-tests property description was not replaced:\n%s", generated) + } +} + +// Verifies an unparsable catalog, a root with no skills, a catalog missing the table-less tool, a catalog of only table-less tools, a skill without a tool description, and a parameterless tool not on the table-less list each stop generation with their own error. +func TestGenerateCatalogRejectsInconsistentInputs(t *testing.T) { + withoutDescription := strings.Replace(fixtureFocusWindowSkill, "description: \"Bring the Unity Editor window to front.\"\n", "", 1) + parameterless := strings.Replace(fixtureCatalogContent, `"Key": { + "type": "string", + "description": "Stale key description" + }, + "Action": { + "type": "string", + "description": "Stale action description", + "enum": [ + "Press", + "ReleaseAll" + ], + "default": "Press" + }, + "Duration": { + "type": "number", + "description": "Stale duration description", + "default": 0 + }, + `, "", 1) + cases := []struct { + name string + catalog string + skills map[string]string + noRepo bool + wantErr string + }{ + {"unparsable catalog", "{", defaultGeneratorSkills(), false, "failed to parse " + CatalogRelativePath}, + {"no skills", fixtureCatalogContent, defaultGeneratorSkills(), true, "no skills were found under"}, + {"table-less tool missing", `{"tools":[{"name":"simulate-keyboard","description":"d","inputSchema":{"type":"object","properties":{}}}]}`, defaultGeneratorSkills(), false, `"focus-window" is allowed to have no parameter table but is not in the catalog`}, + {"only table-less tools", `{"tools":[{"name":"focus-window","description":"d","inputSchema":{"type":"object","properties":{}}}]}`, defaultGeneratorSkills(), false, "the catalog holds 1 tools, which cannot all be table-less"}, + {"skill without tool description", fixtureCatalogContent, map[string]string{"FirstPartyTools/SimulateKeyboard": fixtureKeyboardSkill, "CliOnlyTools~/FocusWindow": withoutDescription}, false, "focus-window has a skill with no tool description"}, + {"parameterless tool not table-less", parameterless, defaultGeneratorSkills(), false, "simulate-keyboard accepts no visible parameters but is not listed as table-less"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, testCase.skills, testCase.catalog) + if testCase.noRepo { + repositoryRoot = t.TempDir() + } + + _, err := GenerateCatalogWithSkillDescriptions([]byte(testCase.catalog), repositoryRoot) + + if err == nil || !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("expected error containing %q, got %v", testCase.wantErr, err) + } + }) + } +} + +// Verifies the command fails without writing when the catalog is missing or generation fails, and a second write run reports the catalog as already up to date. +func TestRunSyncToolDocsReportsEachOutcome(t *testing.T) { + t.Run("missing catalog", func(t *testing.T) { + stdout := strings.Builder{} + stderr := strings.Builder{} + + code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: t.TempDir()}) + + if code != 1 || !strings.Contains(stderr.String(), "failed to read "+CatalogRelativePath) { + t.Fatalf("code = %d, stderr = %q", code, stderr.String()) + } + }) + t.Run("generation failure", func(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, map[string]string{"CliOnlyTools~/FocusWindow": fixtureFocusWindowSkill}, fixtureCatalogContent) + stdout := strings.Builder{} + stderr := strings.Builder{} + + code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot}) + + if code != 1 || !strings.Contains(stderr.String(), "simulate-keyboard has no skill") { + t.Fatalf("code = %d, stderr = %q", code, stderr.String()) + } + content, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(CatalogRelativePath))) + if err != nil || string(content) != fixtureCatalogContent { + t.Fatalf("the catalog must stay untouched, err = %v", err) + } + }) + t.Run("already up to date", func(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + stdout := strings.Builder{} + stderr := strings.Builder{} + if code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot}); code != 0 { + t.Fatalf("first write failed: %s", stderr.String()) + } + stdout.Reset() + + code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot}) + + if code != 0 || !strings.Contains(stdout.String(), CatalogRelativePath+" is already up to date.") { + t.Fatalf("code = %d, stdout = %q", code, stdout.String()) + } + }) +} From 68ac5667a9b159f685134b91059a937cede3129a Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 05:17:17 +0900 Subject: [PATCH 17/68] chore: File length checks now count C# lines correctly after a brace inside an interpolation hole (#3092) --- .../internal/automation/file_length_sloc.go | 20 +++++++++++++------ .../internal/automation/file_length_test.go | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/cli/release-automation/internal/automation/file_length_sloc.go b/cli/release-automation/internal/automation/file_length_sloc.go index 208a7d48ea..8494c02b7a 100644 --- a/cli/release-automation/internal/automation/file_length_sloc.go +++ b/cli/release-automation/internal/automation/file_length_sloc.go @@ -70,8 +70,13 @@ func (s *slocScanner) scanCode(stopOnUnmatchedBrace bool) { if s.tryComment() { continue } - if stopOnUnmatchedBrace && s.handleInterpolationBrace(current, &braceDepth) { - return + // A brace inside a hole is consumed whole and the scan continues, so the character after it is read + // like any other instead of being taken as plain code. + if stopOnUnmatchedBrace && isBrace(current) { + if s.consumeInterpolationBrace(current, &braceDepth) { + return + } + continue } if s.tryString() { continue @@ -81,10 +86,9 @@ func (s *slocScanner) scanCode(stopOnUnmatchedBrace bool) { } } -func (s *slocScanner) handleInterpolationBrace(current rune, braceDepth *int) bool { - if current != '{' && current != '}' { - return false - } +// consumeInterpolationBrace consumes a brace inside an interpolation hole and reports whether it is the +// unmatched closing brace that ends the hole. +func (s *slocScanner) consumeInterpolationBrace(current rune, braceDepth *int) bool { s.markCode() s.nextRune() if current == '{' { @@ -399,6 +403,10 @@ func (s *slocScanner) hasPrefix(prefix string) bool { return true } +func isBrace(value rune) bool { + return value == '{' || value == '}' +} + func isNewline(value rune) bool { return value == '\n' || value == '\r' } diff --git a/cli/release-automation/internal/automation/file_length_test.go b/cli/release-automation/internal/automation/file_length_test.go index 0ae30f18ad..abdc8e6450 100644 --- a/cli/release-automation/internal/automation/file_length_test.go +++ b/cli/release-automation/internal/automation/file_length_test.go @@ -354,3 +354,23 @@ func TestCountSLOCTracksCSharpStringAndHoleBoundaries(t *testing.T) { }) } } + +// Verifies the character right after a brace inside an interpolation hole is scanned like any other: a closing +// brace there still ends the hole, so a later comment line is not counted as string content, and a newline after +// an opening or a closing brace still ends the line. +func TestCountSLOCScansTheCharacterAfterAHoleBrace(t *testing.T) { + cases := []struct { + name string + source string + want int + }{ + {"hole closed right after a nested brace", "var s = $\"{new { A = 1 }}\";\n// c\n", 1}, + {"newline right after a nested brace", "var s = $\"{new { A = 1 }\n.A}\";\n", 2}, + {"newline right after an opening brace", "var s = $\"{new {\nA = 1 }.A}\";\n", 2}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + assertSLOC(t, testCase.source, LanguageCSharp, testCase.want) + }) + } +} From 4d13ca02746de860903d91f486050a5a268b5393 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 05:27:35 +0900 Subject: [PATCH 18/68] test(dispatcher): Cover package, launch, compile-check, help, and attestation paths (#3088) --- .../dispatcher/attestation_release_test.go | 161 ++++++++++++ .../dispatcher/attestation_verify_test.go | 117 +++++++++ .../internal/dispatcher/command_help_test.go | 82 ++++++ .../internal/dispatcher/compile_check_test.go | 114 +++++++++ .../dispatcher/launch_process_unix_test.go | 27 ++ .../launch_startup_timeout_error_test.go | 22 ++ .../internal/dispatcher/launch_test.go | 237 ++++++++++++++++++ .../internal/dispatcher/launch_unity_test.go | 49 ++++ .../dispatcher/launch_v2_lockfile_test.go | 87 +++++++ .../dispatcher/launch_v2_server_ready_test.go | 41 +++ .../dispatcher/package_manifest_json_test.go | 81 ++++++ .../dispatcher/package_manifest_test.go | 99 ++++++++ .../dispatcher/package_registry_test.go | 35 +++ .../internal/dispatcher/package_test.go | 177 +++++++++++++ 14 files changed, 1329 insertions(+) create mode 100644 cli/dispatcher/internal/dispatcher/command_help_test.go create mode 100644 cli/dispatcher/internal/dispatcher/launch_startup_timeout_error_test.go create mode 100644 cli/dispatcher/internal/dispatcher/launch_unity_test.go create mode 100644 cli/dispatcher/internal/dispatcher/package_manifest_json_test.go diff --git a/cli/dispatcher/internal/dispatcher/attestation_release_test.go b/cli/dispatcher/internal/dispatcher/attestation_release_test.go index 5903b756fd..9d33490111 100644 --- a/cli/dispatcher/internal/dispatcher/attestation_release_test.go +++ b/cli/dispatcher/internal/dispatcher/attestation_release_test.go @@ -4,11 +4,15 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" + "reflect" + "strconv" "strings" "testing" + "github.com/hatayama/unity-cli-loop/dispatcher/attestation" "github.com/hatayama/unity-cli-loop/dispatcher/internal/githubapi" sharedupdate "github.com/hatayama/unity-cli-loop/dispatcher/internal/update" ) @@ -197,3 +201,160 @@ func TestFetchDispatcherReleasePageOmitsAuthorizationWithoutToken(t *testing.T) t.Fatalf("expected no authorization header, got %q", recorded) } } + +// installPagedReleaseServer serves the given pages in order and records each request's +// path and query so tests can assert the pagination contract. +func installPagedReleaseServer(t *testing.T, pages [][]githubReleaseListEntry, requested *[]string) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *requested = append(*requested, r.URL.RequestURI()) + page, err := strconv.Atoi(r.URL.Query().Get("page")) + entries := []githubReleaseListEntry{} + if err == nil && page >= 1 && page <= len(pages) { + entries = pages[page-1] + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entries) + })) + t.Cleanup(server.Close) + previousBase := dispatcherAPIBaseURL + dispatcherAPIBaseURL = server.URL + t.Cleanup(func() { dispatcherAPIBaseURL = previousBase }) +} + +func fullReleasePage(tagName string, draft bool) []githubReleaseListEntry { + entries := make([]githubReleaseListEntry, 100) + for index := range entries { + entries[index] = githubReleaseListEntry{TagName: tagName, Draft: draft} + } + return entries +} + +func TestResolveDispatcherLatestReleaseTagFollowsFullPages(t *testing.T) { + // Verifies a full page without a match moves on to the next page, and drafts are never selected. + var requested []string + installPagedReleaseServer(t, [][]githubReleaseListEntry{ + fullReleasePage("dispatcher-v9.0.0", true), + {{TagName: "dispatcher-v3.0.0"}}, + }, &requested) + + tag, err := resolveDispatcherLatestReleaseTag(context.Background(), false) + + if err != nil || tag != "dispatcher-v3.0.0" { + t.Fatalf("unexpected result: tag=%q err=%v", tag, err) + } + wantRequests := []string{ + "/repos/" + dispatcherReleaseRepository + "/releases?per_page=100&page=1", + "/repos/" + dispatcherReleaseRepository + "/releases?per_page=100&page=2", + } + if !reflect.DeepEqual(requested, wantRequests) { + t.Fatalf("requests mismatch: %v", requested) + } +} + +func TestResolveDispatcherLatestReleaseTagStopsAtEmptyPage(t *testing.T) { + // Verifies an empty page ends the search with a channel-specific error instead of requesting more pages. + var requested []string + installPagedReleaseServer(t, [][]githubReleaseListEntry{fullReleasePage("uloop-project-runner-v1.0.0", false)}, &requested) + + _, err := resolveDispatcherLatestReleaseTag(context.Background(), false) + + if err == nil || err.Error() != "no stable dispatcher release available" { + t.Fatalf("unexpected error: %v", err) + } + if len(requested) != 2 { + t.Fatalf("expected the search to stop at the empty second page: %v", requested) + } +} + +func TestFetchDispatcherReleasePageReportsResponseFailures(t *testing.T) { + // Verifies a non-2xx status and an undecodable body are reported with their own messages. + cases := []struct { + name string + statusCode int + body string + wantPrefix string + }{ + {name: "server error", statusCode: http.StatusInternalServerError, body: "{}", wantPrefix: "list releases: status 500 Internal Server Error"}, + {name: "undecodable body", statusCode: http.StatusOK, body: "{", wantPrefix: "decode releases: "}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(testCase.statusCode) + _, _ = io.WriteString(w, testCase.body) + })) + t.Cleanup(server.Close) + previousBase := dispatcherAPIBaseURL + dispatcherAPIBaseURL = server.URL + t.Cleanup(func() { dispatcherAPIBaseURL = previousBase }) + + _, err := fetchDispatcherReleasePage(context.Background(), 1) + + if err == nil || !strings.HasPrefix(err.Error(), testCase.wantPrefix) { + t.Fatalf("expected %q, got %v", testCase.wantPrefix, err) + } + }) + } +} + +func TestResolveUpdateTargetVersionReportsListingFailure(t *testing.T) { + // Verifies an unbuildable release listing request is returned and leaves the target version unset. + previousBase := dispatcherAPIBaseURL + dispatcherAPIBaseURL = "://invalid" + t.Cleanup(func() { dispatcherAPIBaseURL = previousBase }) + + options, err := resolveUpdateTargetVersion(context.Background(), sharedupdate.Options{CurrentVersion: "3.0.0"}) + + if err == nil || !strings.Contains(err.Error(), "missing protocol scheme") || options.TargetVersion != "" { + t.Fatalf("unexpected result: options=%+v err=%v", options, err) + } +} + +func TestFetchAttestationSubjectManifestFailsClosed(t *testing.T) { + // Verifies an empty tag, a missing bundle, a failed tag lookup, and an unverifiable bundle all refuse to produce a manifest. + bundleURL := dispatcherReleaseBaseURL + "/dispatcher-v1.0.0/" + sharedupdate.PosixScriptName + ".sigstore.json" + tagURL := "https://api.github.com/repos/" + attestation.ReleaseRepository + "/git/ref/tags/dispatcher-v1.0.0" + cases := []struct { + name string + tag string + bundle []byte + tagStatusCode int + wantErr error + wantRequests []string + }{ + {name: "empty tag", wantErr: attestation.ErrVerificationFailed}, + {name: "bundle missing", tag: "dispatcher-v1.0.0", wantErr: attestation.ErrBundleFetch, wantRequests: []string{bundleURL}}, + {name: "tag lookup fails", tag: "dispatcher-v1.0.0", bundle: []byte("{}"), tagStatusCode: http.StatusNotFound, wantErr: attestation.ErrTagRefFetch, wantRequests: []string{bundleURL, tagURL}}, + {name: "bundle does not verify", tag: "dispatcher-v1.0.0", bundle: []byte("{}"), tagStatusCode: http.StatusOK, wantErr: attestation.ErrMalformedBundle, wantRequests: []string{bundleURL, tagURL}}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := stubAttestationHTTP(t, testCase.bundle, testCase.tagStatusCode) + + manifest, err := fetchAttestationSubjectManifest(context.Background(), testCase.tag) + + if manifest != "" || !errors.Is(err, testCase.wantErr) { + t.Fatalf("expected %v, got manifest=%q err=%v", testCase.wantErr, manifest, err) + } + if !reflect.DeepEqual(server.requested, testCase.wantRequests) { + t.Fatalf("requests mismatch:\n got %v\nwant %v", server.requested, testCase.wantRequests) + } + }) + } +} + +func TestFetchDispatcherReleasePageReportsTransportFailure(t *testing.T) { + // Verifies a registry that cannot be reached is reported instead of being treated as an empty page. + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + server.Close() + previousBase := dispatcherAPIBaseURL + dispatcherAPIBaseURL = server.URL + t.Cleanup(func() { dispatcherAPIBaseURL = previousBase }) + + entries, err := fetchDispatcherReleasePage(context.Background(), 1) + + if err == nil || entries != nil || !strings.Contains(err.Error(), server.Listener.Addr().String()) { + t.Fatalf("expected the transport error, got entries=%v err=%v", entries, err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/attestation_verify_test.go b/cli/dispatcher/internal/dispatcher/attestation_verify_test.go index 1c2fd64095..d95014e850 100644 --- a/cli/dispatcher/internal/dispatcher/attestation_verify_test.go +++ b/cli/dispatcher/internal/dispatcher/attestation_verify_test.go @@ -1,8 +1,14 @@ package dispatcher import ( + "bytes" "context" "errors" + "io" + "net/http" + "os" + "path/filepath" + "reflect" "strings" "testing" @@ -30,3 +36,114 @@ func TestDefaultVerifyReleaseAssetAttestationRejectsEmptyTag(t *testing.T) { t.Fatalf("expected error to mention the missing field, got %v", err) } } + +const attestationTestCommitSHA = "0123456789abcdef0123456789abcdef01234567" + +// attestationTestServer answers the bundle and git-ref lookups the attestation +// pipeline makes and records every requested URL, so tests can assert the +// release tag and asset were wired through instead of being dropped. +type attestationTestServer struct { + bundle []byte + tagStatusCode int + requested []string +} + +// stubAttestationHTTP routes every attestation fetch to an in-memory server, so no request reaches GitHub. +func stubAttestationHTTP(t *testing.T, bundle []byte, tagStatusCode int) *attestationTestServer { + t.Helper() + server := &attestationTestServer{bundle: bundle, tagStatusCode: tagStatusCode} + previous := attestation.DefaultHTTPClient + t.Cleanup(func() { + attestation.DefaultHTTPClient = previous + }) + attestation.DefaultHTTPClient = &http.Client{Transport: dispatcherRoundTripFunc(server.respond)} + return server +} + +func (server *attestationTestServer) respond(request *http.Request) (*http.Response, error) { + server.requested = append(server.requested, request.URL.String()) + statusCode := http.StatusNotFound + body := []byte{} + switch { + case strings.HasSuffix(request.URL.Path, ".sigstore.json") && server.bundle != nil: + statusCode = http.StatusOK + body = server.bundle + case strings.Contains(request.URL.Path, "/git/ref/tags/"): + statusCode = server.tagStatusCode + body = []byte(`{"object":{"sha":"` + attestationTestCommitSHA + `","type":"commit"}}`) + } + return &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{}, + }, nil +} + +func TestDefaultVerifyReleaseAssetAttestationReportsMissingAsset(t *testing.T) { + // Verifies an unreadable local asset fails before any bundle is fetched. + server := stubAttestationHTTP(t, []byte("{}"), http.StatusOK) + assetPath := filepath.Join(t.TempDir(), "missing") + + err := defaultVerifyReleaseAssetAttestation(context.Background(), "dispatcher-v1.0.0", "https://example.test/asset", assetPath, attestationDispatcherPublishWorkflowPath) + + if err == nil || !strings.HasPrefix(err.Error(), "compute asset digest for attestation:") || !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected the digest error, got %v", err) + } + if len(server.requested) != 0 { + t.Fatalf("no request may be made for a missing asset: %v", server.requested) + } +} + +func TestDefaultVerifyReleaseAssetAttestationFailsClosed(t *testing.T) { + // Verifies a missing bundle, a failed tag lookup, and a bundle that does not verify each reject the asset. + cases := []struct { + name string + bundle []byte + tagStatusCode int + wantErr error + wantRequests []string + }{ + { + name: "bundle missing", + wantErr: attestation.ErrBundleFetch, + wantRequests: []string{"https://example.test/asset.sigstore.json"}, + }, + { + name: "tag lookup fails", + bundle: []byte("{}"), + tagStatusCode: http.StatusNotFound, + wantErr: attestation.ErrTagRefFetch, + wantRequests: []string{ + "https://example.test/asset.sigstore.json", + "https://api.github.com/repos/" + dispatcherReleaseRepository + "/git/ref/tags/dispatcher-v1.0.0", + }, + }, + { + name: "bundle does not verify", + bundle: []byte("{}"), + tagStatusCode: http.StatusOK, + wantErr: attestation.ErrMalformedBundle, + wantRequests: []string{ + "https://example.test/asset.sigstore.json", + "https://api.github.com/repos/" + dispatcherReleaseRepository + "/git/ref/tags/dispatcher-v1.0.0", + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + server := stubAttestationHTTP(t, testCase.bundle, testCase.tagStatusCode) + assetPath := filepath.Join(t.TempDir(), "asset") + writeDispatcherTestFile(t, assetPath, "asset") + + err := defaultVerifyReleaseAssetAttestation(context.Background(), "dispatcher-v1.0.0", "https://example.test/asset", assetPath, attestationDispatcherPublishWorkflowPath) + + if !errors.Is(err, testCase.wantErr) { + t.Fatalf("expected %v, got %v", testCase.wantErr, err) + } + if !reflect.DeepEqual(server.requested, testCase.wantRequests) { + t.Fatalf("requests mismatch:\n got %v\nwant %v", server.requested, testCase.wantRequests) + } + }) + } +} diff --git a/cli/dispatcher/internal/dispatcher/command_help_test.go b/cli/dispatcher/internal/dispatcher/command_help_test.go new file mode 100644 index 0000000000..aa65e8d692 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/command_help_test.go @@ -0,0 +1,82 @@ +package dispatcher + +import ( + "bytes" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" +) + +func TestTryHandleCommandHelpRendersDispatcherOwnedCommandsWithoutProject(t *testing.T) { + // Verifies dispatcher-owned help needs no project, lists options only when the command has them, + // and shows global options only for commands that act on a project. + cases := []struct { + command string + wantOptions bool + wantGlobalOptions bool + }{ + {command: clicore.UninstallCommandName}, + {command: clicore.SkillsCommandName, wantGlobalOptions: true}, + {command: clicore.LaunchCommandName, wantOptions: true, wantGlobalOptions: true}, + {command: clicore.InstallCommandName, wantOptions: true}, + } + for _, testCase := range cases { + t.Run(testCase.command, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp(testCase.command, t.TempDir(), "", &stdout, &stderr) + + if !handled || code != 0 || stderr.Len() != 0 { + t.Fatalf("result mismatch: handled=%t code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + description, _ := nativeCommandDescription(testCase.command) + if !strings.HasPrefix(output, "Usage:\n uloop "+testCase.command) || !strings.Contains(output, description) { + t.Fatalf("missing usage or description: %s", output) + } + if got := strings.Contains(output, "Options:\n"); got != testCase.wantOptions { + t.Fatalf("options section presence = %t, want %t: %s", got, testCase.wantOptions, output) + } + if got := strings.Contains(output, "Global options:"); got != testCase.wantGlobalOptions { + t.Fatalf("global options presence = %t, want %t: %s", got, testCase.wantGlobalOptions, output) + } + }) + } +} + +func TestTryHandleCommandHelpReportsUnknownCommandInProject(t *testing.T) { + // Verifies help for a command the resolved project does not expose fails with UNKNOWN_COMMAND instead of empty help. + projectRoot := createDispatcherUnityProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("no-such-tool", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 1 || stdout.Len() != 0 { + t.Fatalf("result mismatch: handled=%t code=%d stdout=%s", handled, code, stdout.String()) + } + if !strings.Contains(stderr.String(), `"ErrorCode": "UNKNOWN_COMMAND"`) { + t.Fatalf("expected UNKNOWN_COMMAND: %s", stderr.String()) + } +} + +func TestPrintToolHelpOmitsOptionsMarkerForToolWithoutOptions(t *testing.T) { + // Verifies a tool with no visible options prints a bare usage line without the [options] marker or section. + var stdout bytes.Buffer + + printToolHelp(clicore.ToolDefinition{Name: "custom-tool"}, "", &stdout) + + output := stdout.String() + if !strings.HasPrefix(output, "Usage:\n uloop custom-tool\n") || strings.Contains(output, "Options:\n") { + t.Fatalf("unexpected help: %s", output) + } +} + +func TestNativeCommandDescriptionRejectsUnknownCommand(t *testing.T) { + // Verifies a name outside the native command registry has no description. + if description, ok := nativeCommandDescription("no-such-command"); ok || description != "" { + t.Fatalf("unexpected description: %q ok=%t", description, ok) + } +} diff --git a/cli/dispatcher/internal/dispatcher/compile_check_test.go b/cli/dispatcher/internal/dispatcher/compile_check_test.go index 98c8673899..251a42fff7 100644 --- a/cli/dispatcher/internal/dispatcher/compile_check_test.go +++ b/cli/dispatcher/internal/dispatcher/compile_check_test.go @@ -3,9 +3,12 @@ package dispatcher import ( "bytes" "context" + "path/filepath" + "runtime" "strings" "testing" + "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/dispatcher/internal/compilecheck" ) @@ -375,3 +378,114 @@ func TestBuildCompileCheckResponseSaysWhyBlockedAssembliesWereNotCompiled(t *tes t.Fatalf("unexpected summary, got %q", response.Message) } } + +// bogusCompileCheckEditorVersion names an Editor no machine has installed, so +// editor resolution always fails before any compiler could run. +const bogusCompileCheckEditorVersion = "0.0.0f0-uloop-test" + +func TestTryHandleCompileCheckRequestReportsInvalidOptions(t *testing.T) { + // Verifies an invalid compile-check option exits with code 1 before any project lookup. + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCompileCheckRequest(context.Background(), []string{clicore.CompileCheckCommandName, "--bogus"}, t.TempDir(), "", &stdout, &stderr) + + if !handled || code != 1 || stdout.Len() != 0 { + t.Fatalf("result mismatch: handled=%t code=%d stdout=%s", handled, code, stdout.String()) + } + if !strings.Contains(stderr.String(), "Unknown compile-check option: --bogus") { + t.Fatalf("missing option error: %s", stderr.String()) + } +} + +func TestParseCompileCheckOptionsRequiresOptionValues(t *testing.T) { + // Verifies each value-taking option reports its own missing value. + for _, flag := range []string{compileCheckEditorFlag, compileCheckMaxDepthFlag, compileCheckJobsFlag} { + _, err := parseCompileCheckOptions([]string{flag}, "") + if err == nil || !strings.Contains(err.Error(), flag+" requires a value") { + t.Fatalf("%s: expected a missing value error, got %v", flag, err) + } + } +} + +func TestRunCompileCheckReportsResolutionFailures(t *testing.T) { + // Verifies project, ProjectVersion.txt, and Editor resolution failures each stop the run with code 1 and their own message. + cases := []struct { + name string + setup func(t *testing.T) (string, compileCheckOptions) + wantMessage string + }{ + { + name: "project not found", + setup: func(t *testing.T) (string, compileCheckOptions) { + return t.TempDir(), compileCheckOptions{maxDepth: 0} + }, + wantMessage: "unity project not found", + }, + { + name: "project version missing", + setup: func(t *testing.T) (string, compileCheckOptions) { + return createDispatcherUnityProject(t), compileCheckOptions{} + }, + wantMessage: "ProjectVersion.txt", + }, + { + name: "editor version from ProjectVersion.txt not installed", + setup: func(t *testing.T) (string, compileCheckOptions) { + projectRoot := createDispatcherUnityProject(t) + writeDispatcherTestFile(t, filepath.Join(projectRoot, projectVersionFilePath), "m_EditorVersion: "+bogusCompileCheckEditorVersion+"\n") + return projectRoot, compileCheckOptions{} + }, + wantMessage: "unity " + bogusCompileCheckEditorVersion + " executable not found", + }, + { + name: "explicit editor version not installed", + setup: func(t *testing.T) (string, compileCheckOptions) { + return createDispatcherUnityProject(t), compileCheckOptions{editorVersion: bogusCompileCheckEditorVersion} + }, + wantMessage: "unity " + bogusCompileCheckEditorVersion + " executable not found", + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + isolateCompileCheckEditorLookup(t) + startPath, options := testCase.setup(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runCompileCheck(context.Background(), options, startPath, &stdout, &stderr) + + if code != 1 || stdout.Len() != 0 { + t.Fatalf("result mismatch: code=%d stdout=%s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), testCase.wantMessage) { + t.Fatalf("expected %q: %s", testCase.wantMessage, stderr.String()) + } + }) + } +} + +// isolateCompileCheckEditorLookup keeps Editor lookups away from the real home directory: +// on Linux the candidates live under HOME, so the test points HOME at an empty directory. +func isolateCompileCheckEditorLookup(t *testing.T) { + t.Helper() + if runtime.GOOS == "linux" { + t.Setenv("HOME", t.TempDir()) + } +} + +func TestTryHandleCompileCheckRequestRunsWithParsedOptions(t *testing.T) { + // Verifies parsed options reach the run: the explicit editor version is the one resolution reports missing. + isolateCompileCheckEditorLookup(t) + // No ProjectVersion.txt, so dropping the parsed version would fail on the missing file instead. + projectRoot := createDispatcherUnityProject(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + args := []string{clicore.CompileCheckCommandName, compileCheckEditorFlag, bogusCompileCheckEditorVersion} + handled, code := tryHandleCompileCheckRequest(context.Background(), args, projectRoot, "", &stdout, &stderr) + + if !handled || code != 1 || !strings.Contains(stderr.String(), "unity "+bogusCompileCheckEditorVersion+" executable not found") { + t.Fatalf("unexpected result: handled=%t code=%d stderr=%s", handled, code, stderr.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/launch_process_unix_test.go b/cli/dispatcher/internal/dispatcher/launch_process_unix_test.go index c4165ebd4a..2637930aff 100644 --- a/cli/dispatcher/internal/dispatcher/launch_process_unix_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_process_unix_test.go @@ -3,7 +3,9 @@ package dispatcher import ( + "errors" "os/exec" + "syscall" "testing" ) @@ -19,3 +21,28 @@ func TestConfigureDetachedUnityLaunchCommandStartsNewSession(t *testing.T) { t.Fatal("Unity launch command must start a new session so terminal interrupts do not close Unity") } } + +func TestKillUnityProcessStopsTheProcess(t *testing.T) { + // Verifies the Unix kill stops the given process, using a child this test started and owns. + command := exec.Command("/bin/sh", "-c", "read line") + stdin, err := command.StdinPipe() + if err != nil { + t.Fatalf("failed to open stdin: %v", err) + } + t.Cleanup(func() { _ = stdin.Close() }) + if err := command.Start(); err != nil { + t.Fatalf("failed to start child: %v", err) + } + + if err := killUnityProcess(command.Process.Pid); err != nil { + t.Fatalf("killUnityProcess failed: %v", err) + } + + // Closing stdin lets a surviving child exit normally, so a kill that did nothing fails fast instead of hanging. + _ = stdin.Close() + err = command.Wait() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.Sys().(syscall.WaitStatus).Signal() != syscall.SIGKILL { + t.Fatalf("expected the child to be killed, got %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error_test.go b/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error_test.go new file mode 100644 index 0000000000..fa874d1b11 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error_test.go @@ -0,0 +1,22 @@ +package dispatcher + +import ( + "errors" + "testing" + "time" +) + +func TestLaunchTimeoutErrorMessages(t *testing.T) { + // Verifies the startup timeout includes its cause only when one exists, and the exit timeout names the pid and duration. + if message := (launchStartupTimeoutError{}).Error(); message != "Unity startup did not finish before the launch timeout" { + t.Fatalf("unexpected message without cause: %s", message) + } + withCause := launchStartupTimeoutError{cause: errors.New("tools not ready")} + if message := withCause.Error(); message != "Unity startup did not finish before the launch timeout: tools not ready" { + t.Fatalf("unexpected message with cause: %s", message) + } + exitTimeout := launchProcessExitTimeoutError{pid: 42, timeout: 3 * time.Second} + if message := exitTimeout.Error(); message != "Unity process 42 did not exit within 3s" { + t.Fatalf("unexpected exit timeout message: %s", message) + } +} diff --git a/cli/dispatcher/internal/dispatcher/launch_test.go b/cli/dispatcher/internal/dispatcher/launch_test.go index ad668c3a97..6e424f22a5 100644 --- a/cli/dispatcher/internal/dispatcher/launch_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_test.go @@ -1766,3 +1766,240 @@ func TestLinuxUnityExecutableCandidatesAreEmptyWithoutHome(t *testing.T) { t.Fatalf("expected no candidates, got %#v", candidates) } } + +// isolatedLaunchTestDeps replaces every dependency that could touch a real Unity +// process, IPC endpoint, or Editor install with a harmless fake, so a regression +// in the branch under test cannot kill, focus, or start a real Editor. +func isolatedLaunchTestDeps(t *testing.T) launchDeps { + t.Helper() + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } + deps.focusUnityProcess = func(context.Context, int) error { return nil } + deps.killUnityProcess = func(pid int) error { + t.Fatalf("unexpected kill of pid %d", pid) + return nil + } + deps.resolveUnityExecutablePath = func(string) (string, error) { return fakeUnityExecutablePath(t), nil } + deps.waitForUnityProcessExit = func(context.Context, string, int, time.Duration, time.Duration) error { return nil } + deps.waitForUnityStartupMarker = func(context.Context, string, time.Duration, time.Duration) error { return nil } + deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { return nil } + deps.waitForV2ServerReady = func(context.Context, string, string, time.Duration, time.Duration) error { return nil } + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return nil } + deps.probeProjectIpcFallback = func(context.Context, string) error { return errors.New("no IPC in tests") } + deps.sleep = func(time.Duration) {} + return deps +} + +func createVersionedLaunchTestProject(t *testing.T) string { + t.Helper() + projectRoot := createLaunchTestProject(t) + writeDispatcherTestFile(t, filepath.Join(projectRoot, projectVersionFilePath), "m_EditorVersion: 2022.3.0f1\n") + return projectRoot +} + +func TestTryHandleLaunchRequestReportsInvalidOptions(t *testing.T) { + // Verifies an invalid launch option exits with code 1 before any project lookup or process scan. + var stdout bytes.Buffer + var stderr bytes.Buffer + deps := isolatedLaunchTestDeps(t) + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + t.Fatal("no process scan may run for invalid options") + return nil, nil + } + + handled, code := tryHandleLaunchRequestWithDeps(context.Background(), []string{clicore.LaunchCommandName, "--bogus"}, t.TempDir(), "", &stdout, &stderr, deps) + + if !handled || code != 1 || !strings.Contains(stderr.String(), "Unknown launch option: --bogus") { + t.Fatalf("unexpected result: handled=%t code=%d stderr=%s", handled, code, stderr.String()) + } +} + +func TestRunLaunchReportsSearchAndProjectFailures(t *testing.T) { + // Verifies an unlimited search is announced as such and a missing project stops the launch with code 1. + var stdout bytes.Buffer + var stderr bytes.Buffer + startPath := t.TempDir() + + code := runLaunchWithDeps(context.Background(), launchOptions{maxDepth: -1}, startPath, &stdout, &stderr, isolatedLaunchTestDeps(t)) + + if code != 1 || !strings.Contains(stderr.String(), "unity project not found") { + t.Fatalf("expected the project error: code=%d stderr=%s", code, stderr.String()) + } + if want := "Searching for Unity project under " + startPath + " (max-depth: unlimited)..."; !strings.Contains(stdout.String(), want) { + t.Fatalf("missing search line %q: %s", want, stdout.String()) + } +} + +func TestDeleteLaunchRecoveryIfRequested(t *testing.T) { + // Verifies --delete-recovery removes Assets/_Recovery, and a removal failure stops the launch. + projectRoot := createLaunchTestProject(t) + recoveryFile := filepath.Join(projectRoot, filepath.FromSlash(recoveryDirectoryPath), "scene.unity") + writeDispatcherTestFile(t, recoveryFile, "x") + + if !deleteLaunchRecoveryIfRequested(launchOptions{deleteRecovery: true}, projectRoot, io.Discard) { + t.Fatal("expected the recovery directory to be removed") + } + if fileExists(filepath.Dir(recoveryFile)) { + t.Fatal("Assets/_Recovery must be gone") + } + + writeDispatcherTestFile(t, recoveryFile, "x") + lockSkillsTestDirectory(t, filepath.Dir(recoveryFile), 0o555) + var stderr bytes.Buffer + if deleteLaunchRecoveryIfRequested(launchOptions{deleteRecovery: true}, projectRoot, &stderr) { + t.Fatal("a failed removal must stop the launch") + } + if !strings.Contains(stderr.String(), "scene.unity: permission denied") { + t.Fatalf("expected the removal error: %s", stderr.String()) + } +} + +func TestRunLaunchReportsRunningProcessFailures(t *testing.T) { + // Verifies a failed kill on restart and a failed readiness wait on an already running Editor each stop with code 1. + t.Run("kill fails", func(t *testing.T) { + deps := isolatedLaunchTestDeps(t) + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 4242}, nil + } + deps.killUnityProcess = func(int) error { return errors.New("kill denied") } + projectRoot := createVersionedLaunchTestProject(t) + var stderr bytes.Buffer + + code := runLaunchWithDeps(context.Background(), launchOptions{restart: true, projectPath: projectRoot}, projectRoot, io.Discard, &stderr, deps) + + if code != 1 || !strings.Contains(stderr.String(), "kill denied") { + t.Fatalf("expected the kill error: code=%d stderr=%s", code, stderr.String()) + } + }) + t.Run("existing editor never ready", func(t *testing.T) { + deps := isolatedLaunchTestDeps(t) + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 4242}, nil + } + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return errors.New("tools not ready") } + projectRoot := createVersionedLaunchTestProject(t) + var stderr bytes.Buffer + + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, io.Discard, &stderr, deps) + + if code != 1 || !strings.Contains(stderr.String(), "tools not ready") { + t.Fatalf("expected the readiness error: code=%d stderr=%s", code, stderr.String()) + } + }) +} + +func TestRunLaunchReportsStartFailures(t *testing.T) { + // Verifies each failure between resolving the Editor and readiness stops a fresh launch with its own error. + cases := []struct { + name string + noVersion bool + modify func(t *testing.T, deps *launchDeps, projectRoot string) + wantMessage string + }{ + {name: "project version missing", noVersion: true, modify: func(*testing.T, *launchDeps, string) {}, wantMessage: "ProjectVersion.txt"}, + {name: "editor not installed", modify: func(_ *testing.T, deps *launchDeps, _ string) { + // Echoing the received version proves the one from ProjectVersion.txt is what gets resolved. + deps.resolveUnityExecutablePath = func(version string) (string, error) { return "", errors.New("editor " + version + " missing") } + }, wantMessage: "editor 2022.3.0f1 missing"}, + {name: "editor cannot start", modify: func(t *testing.T, deps *launchDeps, _ string) { + missing := filepath.Join(t.TempDir(), "missing-unity") + deps.resolveUnityExecutablePath = func(string) (string, error) { return missing, nil } + }, wantMessage: "missing-unity"}, + {name: "startup marker never appears", modify: func(t *testing.T, deps *launchDeps, projectRoot string) { + deps.waitForUnityStartupMarker = func(_ context.Context, lockfilePath string, _ time.Duration, _ time.Duration) error { + if lockfilePath != unityLockfilePath(projectRoot) { + t.Errorf("startup marker wait got %q, want %q", lockfilePath, unityLockfilePath(projectRoot)) + } + return errors.New("no lockfile") + } + }, wantMessage: "no lockfile"}, + {name: "tools never ready", modify: func(_ *testing.T, deps *launchDeps, _ string) { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return errors.New("tools not ready") } + }, wantMessage: "tools not ready"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + deps := isolatedLaunchTestDeps(t) + projectRoot := createLaunchTestProject(t) + if !testCase.noVersion { + projectRoot = createVersionedLaunchTestProject(t) + } + testCase.modify(t, &deps, projectRoot) + var stderr bytes.Buffer + + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, io.Discard, &stderr, deps) + + if code != 1 || !strings.Contains(stderr.String(), testCase.wantMessage) { + t.Fatalf("expected %q: code=%d stderr=%s", testCase.wantMessage, code, stderr.String()) + } + }) + } +} + +func TestWaitForUnityProcessExitWithDeps(t *testing.T) { + // Verifies the exit wait returns once the process is gone, surfaces lookup errors, and times out on a process that stays. + projectRoot := t.TempDir() + deps := isolatedLaunchTestDeps(t) + + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 7}, nil + } + if err := waitForUnityProcessExitWithDeps(context.Background(), projectRoot, 8, time.Millisecond, time.Second, deps); err != nil { + t.Fatalf("a different pid means the old process exited: %v", err) + } + + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, errors.New("ps failed") } + if err := waitForUnityProcessExitWithDeps(context.Background(), projectRoot, 8, time.Millisecond, time.Second, deps); err == nil || err.Error() != "ps failed" { + t.Fatalf("expected the lookup error, got %v", err) + } + + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 8}, nil + } + err := waitForUnityProcessExitWithDeps(context.Background(), projectRoot, 8, time.Millisecond, time.Millisecond, deps) + var timeoutErr launchProcessExitTimeoutError + if !errors.As(err, &timeoutErr) || timeoutErr.pid != 8 { + t.Fatalf("expected the exit timeout error, got %v", err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitForUnityProcessExitWithDeps(canceled, projectRoot, 8, time.Millisecond, time.Second, deps); !errors.Is(err, context.Canceled) { + t.Fatalf("expected the caller's cancellation, got %v", err) + } +} + +func TestWaitForUnityStartupMarkerOrTimeout(t *testing.T) { + // Verifies the marker wait reports an uninspectable path and the caller's cancellation. + if runtime.GOOS == "windows" { + t.Skip("Windows reports a path below a file as not found rather than ENOTDIR.") + } + parentFile := filepath.Join(t.TempDir(), "file") + writeDispatcherTestFile(t, parentFile, "x") + err := waitForUnityStartupMarkerOrTimeout(context.Background(), filepath.Join(parentFile, "UnityLockfile"), time.Millisecond, time.Second) + if err == nil || !strings.HasSuffix(err.Error(), "not a directory") { + t.Fatalf("expected the stat error, got %v", err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + err = waitForUnityStartupMarkerOrTimeout(canceled, filepath.Join(t.TempDir(), "UnityLockfile"), time.Millisecond, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected the caller's cancellation, got %v", err) + } +} + +func TestResolveExistingUnityExecutablePathFindsCandidateOrRejectsPlatform(t *testing.T) { + // Verifies the first existing candidate is returned and an empty candidate list reports an unsupported platform. + missing := filepath.Join(t.TempDir(), "missing") + existing := filepath.Join(t.TempDir(), "Unity") + writeDispatcherTestFile(t, existing, "") + + path, err := resolveExistingUnityExecutablePath("2022.3.0f1", []string{missing, existing}) + if err != nil || path != existing { + t.Fatalf("unexpected result: path=%q err=%v", path, err) + } + if _, err := resolveExistingUnityExecutablePath("2022.3.0f1", nil); err == nil || err.Error() != "unity launch is not supported on "+runtime.GOOS { + t.Fatalf("expected the unsupported platform error, got %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/launch_unity_test.go b/cli/dispatcher/internal/dispatcher/launch_unity_test.go new file mode 100644 index 0000000000..e34ac9a193 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_unity_test.go @@ -0,0 +1,49 @@ +package dispatcher + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestReadUnityEditorVersionRejectsMissingOrEmptyVersion(t *testing.T) { + // Verifies a ProjectVersion.txt without an editor version line, or with an empty one, is reported instead of launching an unknown Editor. + cases := map[string]string{ + "no version line": "m_EditorVersionWithRevision: 2022.3.0f1 (abc)\n", + "empty version": "m_EditorVersion: \n", + } + wants := map[string]string{ + "no version line": "unity editor version not found in " + projectVersionFilePath, + "empty version": "unity editor version is empty in " + projectVersionFilePath, + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + projectRoot := t.TempDir() + writeDispatcherTestFile(t, filepath.Join(projectRoot, projectVersionFilePath), content) + + version, err := readUnityEditorVersion(projectRoot) + + if err == nil || err.Error() != wants[name] || version != "" { + t.Fatalf("expected %q, got version=%q err=%v", wants[name], version, err) + } + }) + } +} + +func TestWindowsUnityExecutableCandidatesSkipsUnsetBases(t *testing.T) { + // Verifies each set install base yields a Hub Editor path and unset bases are skipped, ending with the fixed default. + t.Setenv("ProgramFiles", "") + t.Setenv("ProgramFiles(x86)", "") + t.Setenv("LOCALAPPDATA", "") + + candidates := windowsUnityExecutableCandidates("2022.3.0f1") + + want := []string{ + filepath.Join("", "Unity", "Hub", "Editor", "2022.3.0f1", "Editor", "Unity.exe"), + filepath.Join("", "Unity", "Hub", "Editor", "2022.3.0f1", "Editor", "Unity.exe"), + filepath.Join(`C:\Program Files`, "Unity", "Hub", "Editor", "2022.3.0f1", "Editor", "Unity.exe"), + } + if !reflect.DeepEqual(candidates, want) { + t.Fatalf("candidates mismatch:\n got %v\nwant %v", candidates, want) + } +} diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go index 86438abc09..a625cfd1b2 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go @@ -1,12 +1,18 @@ package dispatcher import ( + "bytes" "context" "errors" + "io" "os" "path/filepath" + "strings" "testing" "time" + + "github.com/hatayama/unity-cli-loop/common/clicore" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" ) // Verifies V2 launch succeeds only after Unity creates or updates its lockfile after the new process starts. @@ -43,3 +49,84 @@ func TestWaitForFreshUnityLockfileRejectsStaleLockfile(t *testing.T) { t.Fatalf("expected V2 lockfile timeout, got %v", err) } } + +func TestWaitForFreshUnityLockfileReportsInspectionFailureAndCancellation(t *testing.T) { + // Verifies an uninspectable lockfile path is returned at once and the caller's cancellation wins over the timeout error. + canceled, cancel := context.WithCancel(context.Background()) + cancel() + err := waitForFreshUnityLockfile(canceled, filepath.Join(t.TempDir(), unityLockfileName), time.Now(), time.Millisecond, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected the caller's cancellation, got %v", err) + } + + skipDispatcherTestOnWindows(t, "Windows reports a path below a file as not found rather than ENOTDIR.") + parentFile := filepath.Join(t.TempDir(), "file") + writeDispatcherTestFile(t, parentFile, "x") + err = waitForFreshUnityLockfile(context.Background(), filepath.Join(parentFile, unityLockfileName), time.Now(), time.Millisecond, time.Second) + if err == nil || !strings.HasSuffix(err.Error(), "not a directory") { + t.Fatalf("expected the stat error, got %v", err) + } +} + +func TestV2LaunchLockfileTimeoutErrorDescribesTheLockfile(t *testing.T) { + // Verifies the lockfile timeout names the lockfile and maps to a retryable startup timeout envelope. + err := v2LaunchLockfileTimeoutError{lockfilePath: "/Temp/UnityLockfile"} + + if err.Error() != "timed out waiting for Unity to update /Temp/UnityLockfile" { + t.Fatalf("unexpected message: %s", err.Error()) + } + cliError := err.ToCLIError(clierrors.ErrorContext{ProjectRoot: "", Command: "launch"}) + if cliError.ErrorCode != clierrors.ErrorCodeUnityStartupTimeout || !cliError.Retryable || cliError.ProjectRoot != "" { + t.Fatalf("unexpected envelope: %+v", cliError) + } +} + +// createV2LaunchTestProject builds a project the launch flow detects as a V2 project. +func createV2LaunchTestProject(t *testing.T) string { + t.Helper() + projectRoot := createVersionedLaunchTestProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + if project, err := detectV2DispatcherProject(projectRoot); err != nil || !project.IsV2 { + t.Fatalf("precondition failed: fixture must be a V2 project, got project=%+v err=%v", project, err) + } + return projectRoot +} + +func TestRunLaunchReportsV2ReadinessFailures(t *testing.T) { + // Verifies a V2 project stops with code 1 when its fresh lockfile or its already running server never becomes ready. + t.Run("fresh launch lockfile", func(t *testing.T) { + deps := isolatedLaunchTestDeps(t) + projectRoot := createV2LaunchTestProject(t) + deps.waitForFreshUnityLockfile = func(_ context.Context, lockfilePath string, _ time.Time, _ time.Duration, _ time.Duration) error { + if lockfilePath != unityLockfilePath(projectRoot) { + t.Errorf("fresh lockfile wait got %q, want %q", lockfilePath, unityLockfilePath(projectRoot)) + } + return errors.New("lockfile never refreshed") + } + var stderr bytes.Buffer + + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, io.Discard, &stderr, deps) + + if code != 1 || !strings.Contains(stderr.String(), "lockfile never refreshed") { + t.Fatalf("expected the lockfile error: code=%d stderr=%s", code, stderr.String()) + } + }) + t.Run("existing editor server", func(t *testing.T) { + deps := isolatedLaunchTestDeps(t) + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 4242}, nil + } + deps.waitForV2ServerReady = func(context.Context, string, string, time.Duration, time.Duration) error { + return errors.New("v2 server not ready") + } + projectRoot := createV2LaunchTestProject(t) + var stderr bytes.Buffer + + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, io.Discard, &stderr, deps) + + if code != 1 || !strings.Contains(stderr.String(), "v2 server not ready") { + t.Fatalf("expected the server readiness error: code=%d stderr=%s", code, stderr.String()) + } + }) +} diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go index e00d190ace..be95a8ccba 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "testing" "time" @@ -381,3 +382,43 @@ func TestWaitForV2ServerReadyFollowsCustomPortChange(t *testing.T) { t.Fatalf("expected dials of old %d and new %d, dialed %v", oldPort, newPort, dialedPorts) } } + +func TestWaitForV2ServerReadyReturnsCallerCancellation(t *testing.T) { + // Verifies the caller's cancellation is returned instead of the readiness timeout error. + canceled, cancel := context.WithCancel(context.Background()) + cancel() + + err := waitForV2ServerReady(canceled, t.TempDir(), "", func(int) error { + t.Fatal("no dial may happen without server settings") + return nil + }, time.Millisecond, time.Second) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected the caller's cancellation, got %v", err) + } +} + +func TestV2ServerReadyTimeoutErrorNamesProject(t *testing.T) { + // Verifies the readiness timeout message names the project it waited on. + err := v2ServerReadyTimeoutError{projectRoot: "", timeout: time.Second} + + if err.Error() != "timed out waiting for V2 uloop server readiness in " { + t.Fatalf("unexpected message: %s", err.Error()) + } +} + +func TestReadPreviousV2ServerSessionIDLogsUnreadableSettings(t *testing.T) { + // Verifies corrupt V2 settings yield an empty session id and leave a warning in the CLI log. + enableCliVibeLog(t) + projectRoot := t.TempDir() + // The temp file is the last fallback read, so it must be corrupt too for the read error to surface. + writeDispatcherTestFile(t, filepath.Join(projectRoot, v2UserSettingsDirectoryName, v2ServerSettingsFileName), "{") + writeDispatcherTestFile(t, filepath.Join(projectRoot, v2UserSettingsDirectoryName, v2ServerSettingsTmpFileName), "{") + + if sessionID := readPreviousV2ServerSessionID(projectRoot); sessionID != "" { + t.Fatalf("expected an empty session id, got %q", sessionID) + } + if log := readOnlyCliVibeLog(t, projectRoot); !strings.Contains(log, "cli_launch_v2_previous_session_read_failed") { + t.Fatalf("missing warning in CLI log: %s", log) + } +} diff --git a/cli/dispatcher/internal/dispatcher/package_manifest_json_test.go b/cli/dispatcher/internal/dispatcher/package_manifest_json_test.go new file mode 100644 index 0000000000..0b444f752c --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/package_manifest_json_test.go @@ -0,0 +1,81 @@ +package dispatcher + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestParseOrderedJSONObjectBytesRejectsIncompleteObjects(t *testing.T) { + // Verifies empty, truncated, and value-less object input is rejected instead of yielding a partial object. + for _, input := range []string{"", `{"a":1`, `{"a":}`} { + object, err := parseOrderedJSONObjectBytes([]byte(input)) + if err == nil || len(object.keys) != 0 || len(object.values) != 0 { + t.Fatalf("%q: expected an error and no partial object, got object=%+v err=%v", input, object, err) + } + } +} + +func TestParseJSONRawArrayRejectsMalformedArrays(t *testing.T) { + // Verifies empty, truncated, invalid, and trailing-garbage arrays are rejected without partial elements. + for _, input := range []string{"", "[1", "[1,}", "[1] 2"} { + values, err := parseJSONRawArray([]byte(input)) + if err == nil || len(values) != 0 { + t.Fatalf("%q: expected an error and no elements, got values=%v err=%v", input, values, err) + } + } +} + +func TestEmitOrderedJSONObjectRejectsInconsistentObjects(t *testing.T) { + // Verifies emitting fails for a key without a value or with an unusable value instead of writing broken JSON. + cases := []struct { + name string + object orderedJSONObject + want string + }{ + {name: "missing value", object: orderedJSONObject{keys: []string{"a"}, values: map[string]json.RawMessage{}}, want: `missing value for key "a"`}, + {name: "blank value", object: orderedJSONObject{keys: []string{"a"}, values: map[string]json.RawMessage{"a": json.RawMessage(" ")}}, want: "empty JSON value"}, + {name: "invalid array value", object: orderedJSONObject{keys: []string{"a"}, values: map[string]json.RawMessage{"a": json.RawMessage("[1,")}}, want: "unexpected end of JSON input"}, + } + if _, err := emitOrderedJSONObject(orderedJSONObject{keys: []string{"a"}, values: map[string]json.RawMessage{"a": json.RawMessage(`{"b"`)}}, 0); err == nil { + t.Fatal("a truncated nested object must not be emitted") + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := emitOrderedJSONObject(testCase.object, 0) + if err == nil || err.Error() != testCase.want { + t.Fatalf("expected %q, got %v", testCase.want, err) + } + }) + } + if _, err := emitJSONRawArray([]json.RawMessage{json.RawMessage(" ")}, 0); err == nil || err.Error() != "empty JSON value" { + t.Fatalf("expected the array element error, got %v", err) + } +} + +func TestOrderedJSONObjectInsertAfterReplacesExistingKeyInPlace(t *testing.T) { + // Verifies inserting an existing key replaces its value without moving it, and a zero object gets a value map. + object := orderedJSONObject{keys: []string{"a", "b"}, values: map[string]json.RawMessage{"a": json.RawMessage("1"), "b": json.RawMessage("2")}} + object.insertAfter("a", json.RawMessage("3"), "b") + + if !reflect.DeepEqual(object.keys, []string{"a", "b"}) || string(object.values["a"]) != "3" { + t.Fatalf("unexpected object: %+v", object) + } + + empty := orderedJSONObject{} + empty.insertAfter("a", json.RawMessage("1"), "") + if !reflect.DeepEqual(empty.keys, []string{"a"}) || string(empty.values["a"]) != "1" { + t.Fatalf("unexpected object: %+v", empty) + } +} + +func TestSortStringsByteOrderSortsByBytes(t *testing.T) { + // Verifies scopes are sorted by byte order, so uppercase sorts before lowercase. + values := []string{"io.b", "com.a", "Z.upper", "io.a"} + + sortStringsByteOrder(values) + + if want := []string{"Z.upper", "com.a", "io.a", "io.b"}; !reflect.DeepEqual(values, want) { + t.Fatalf("got %v want %v", values, want) + } +} diff --git a/cli/dispatcher/internal/dispatcher/package_manifest_test.go b/cli/dispatcher/internal/dispatcher/package_manifest_test.go index 6dc4005179..2a102bfa73 100644 --- a/cli/dispatcher/internal/dispatcher/package_manifest_test.go +++ b/cli/dispatcher/internal/dispatcher/package_manifest_test.go @@ -378,3 +378,102 @@ func parseOrderedJSONObject(t *testing.T, content []byte) orderedJSONObject { } return object } + +func TestMergePackageManifestRejectsMalformedSections(t *testing.T) { + // Verifies each malformed section is reported with the path of the offending value instead of being overwritten. + registryEntry := `{"url":"` + openUPMRegistryURL + `"` + cases := []struct { + name string + manifest string + wantPrefix string + }{ + {name: "dependencies not an object", manifest: `{"dependencies":[]}`, wantPrefix: "dependencies: "}, + {name: "dependency not a string", manifest: `{"dependencies":{"` + dispatcherUnityPackageName + `":1}}`, wantPrefix: "dependency " + dispatcherUnityPackageName + ": "}, + {name: "registries not an array", manifest: `{"dependencies":{},"scopedRegistries":{}}`, wantPrefix: "scopedRegistries: "}, + {name: "registry entry not an object", manifest: `{"dependencies":{},"scopedRegistries":[1]}`, wantPrefix: "scopedRegistries[0]: "}, + {name: "registry url not a string", manifest: `{"dependencies":{},"scopedRegistries":[{"name":"x"},{"url":1}]}`, wantPrefix: "scopedRegistries[1].url: "}, + {name: "registry scopes not strings", manifest: `{"dependencies":{},"scopedRegistries":[` + registryEntry + `,"scopes":[1]}]}`, wantPrefix: "scopes: "}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + result, err := mergePackageManifest([]byte(testCase.manifest), "1.2.3") + + if err == nil || !strings.HasPrefix(err.Error(), testCase.wantPrefix) || result.Changed { + t.Fatalf("expected an error starting with %q, got result=%+v err=%v", testCase.wantPrefix, result, err) + } + }) + } +} + +func TestMergePackageManifestAddsMissingDependenciesSection(t *testing.T) { + // Verifies a manifest without dependencies gains a dependencies object holding only the package. + result, err := mergePackageManifest([]byte("{}\n"), "1.2.3") + + if err != nil || !result.DependencyAdded || !result.Changed { + t.Fatalf("unexpected result: %+v err=%v", result, err) + } + var manifest struct { + Dependencies map[string]string `json:"dependencies"` + } + if err := json.Unmarshal(result.Content, &manifest); err != nil { + t.Fatalf("emitted manifest is not JSON: %v\n%s", err, result.Content) + } + if len(manifest.Dependencies) != 1 || manifest.Dependencies[dispatcherUnityPackageName] != "1.2.3" { + t.Fatalf("unexpected dependencies: %v", manifest.Dependencies) + } + assertManifestHasOpenUPMRegistry(t, result.Content) +} + +func TestMergePackageManifestInsertsDependencyInAlphabeticalOrder(t *testing.T) { + // Verifies the package is inserted before the first dependency that sorts after it, not appended. + input := []byte(`{ + "dependencies": { + "com.unity.modules.ai": "1.0.0", + "org.example.tool": "2.0.0" + } +} +`) + + result, err := mergePackageManifest(input, "1.2.3") + if err != nil { + t.Fatalf("mergePackageManifest failed: %v", err) + } + content := string(result.Content) + packageIndex := strings.Index(content, dispatcherUnityPackageName) + if packageIndex < strings.Index(content, "com.unity.modules.ai") || packageIndex > strings.Index(content, "org.example.tool") { + t.Fatalf("package is not between its neighbors:\n%s", content) + } +} + +func TestMergePackageManifestAddsScopesToOpenUPMRegistryWithoutScopes(t *testing.T) { + // Verifies an OpenUPM registry entry that has no scopes key gains the package scope while keeping its other keys. + input := []byte(`{ + "dependencies": {}, + "scopedRegistries": [ + { + "name": "custom", + "url": "` + openUPMRegistryURL + `" + } + ] +} +`) + + result, err := mergePackageManifest(input, "1.2.3") + + if err != nil || !result.ScopeAdded || result.RegistryAdded { + t.Fatalf("unexpected result: %+v err=%v", result, err) + } + var manifest struct { + ScopedRegistries []struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + } `json:"scopedRegistries"` + } + if err := json.Unmarshal(result.Content, &manifest); err != nil { + t.Fatalf("emitted manifest is not JSON: %v\n%s", err, result.Content) + } + if len(manifest.ScopedRegistries) != 1 || manifest.ScopedRegistries[0].Name != "custom" || + len(manifest.ScopedRegistries[0].Scopes) != 1 || manifest.ScopedRegistries[0].Scopes[0] != dispatcherUnityPackageName { + t.Fatalf("unexpected registries: %+v", manifest.ScopedRegistries) + } +} diff --git a/cli/dispatcher/internal/dispatcher/package_registry_test.go b/cli/dispatcher/internal/dispatcher/package_registry_test.go index 5adb78f40b..f89de0bcfd 100644 --- a/cli/dispatcher/internal/dispatcher/package_registry_test.go +++ b/cli/dispatcher/internal/dispatcher/package_registry_test.go @@ -2,8 +2,10 @@ package dispatcher import ( "context" + "errors" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -80,3 +82,36 @@ func TestResolveLatestPackageVersionRejectsEmptyLatest(t *testing.T) { t.Fatal("expected empty latest error") } } + +func TestResolveLatestPackageVersionReportsRequestFailures(t *testing.T) { + // Verifies an unbuildable URL, an unreachable registry, and an undecodable body are each reported. + previousURL := openUPMRegistryBaseURL + previousClient := packageRegistryHTTPClient + t.Cleanup(func() { + openUPMRegistryBaseURL = previousURL + packageRegistryHTTPClient = previousClient + }) + + openUPMRegistryBaseURL = "://invalid" + if _, err := resolveLatestPackageVersion(context.Background()); err == nil || !strings.Contains(err.Error(), "missing protocol scheme") { + t.Fatalf("expected the URL error, got %v", err) + } + + openUPMRegistryBaseURL = "https://registry.invalid" + packageRegistryHTTPClient = &http.Client{Transport: dispatcherRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("registry unreachable") + })} + if _, err := resolveLatestPackageVersion(context.Background()); err == nil || !strings.Contains(err.Error(), "registry unreachable") { + t.Fatalf("expected the transport error, got %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("{")) + })) + t.Cleanup(server.Close) + openUPMRegistryBaseURL = server.URL + packageRegistryHTTPClient = server.Client() + if _, err := resolveLatestPackageVersion(context.Background()); err == nil || !strings.HasPrefix(err.Error(), "decode OpenUPM registry response: ") { + t.Fatalf("expected the decode error, got %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/package_test.go b/cli/dispatcher/internal/dispatcher/package_test.go index e3887cf62d..d5a8afdb50 100644 --- a/cli/dispatcher/internal/dispatcher/package_test.go +++ b/cli/dispatcher/internal/dispatcher/package_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -615,3 +616,179 @@ func TestResolveSkillsProjectRootFindsProjectInChildDirectory(t *testing.T) { t.Fatalf("project root mismatch: %s", resolved) } } + +func TestParsePackageOptionsRejectInvalidArguments(t *testing.T) { + // Verifies each invalid install or status argument is rejected with the message for that mistake. + installCases := []struct { + args []string + wantMessage string + }{ + {args: []string{"--bogus", "1"}, wantMessage: "Unknown package install option: --bogus"}, + {args: []string{"--version", ""}, wantMessage: "Empty value for --version"}, + {args: []string{"--version", "1.0.0", "--version", "2.0.0"}, wantMessage: "Duplicate package install option: --version"}, + {args: []string{"--version"}, wantMessage: "--version requires a value"}, + } + for _, testCase := range installCases { + if _, err := parsePackageInstallOptions(testCase.args); err == nil || !strings.Contains(err.Error(), testCase.wantMessage) { + t.Fatalf("%v: expected %q, got %v", testCase.args, testCase.wantMessage, err) + } + } + if err := parsePackageStatusOptions([]string{"--verbose"}); err == nil || err.Error() != "Unknown package status option: --verbose" { + t.Fatalf("expected the status option error, got %v", err) + } +} + +func TestTryHandlePackageRequestPrintsSubcommandHelp(t *testing.T) { + // Verifies --help after a subcommand prints package help without touching any project. + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandlePackageRequest(context.Background(), []string{"package", "install", "--help"}, t.TempDir(), "", &stdout, &stderr) + + if !handled || code != 0 || stderr.Len() != 0 || !strings.Contains(stdout.String(), "uloop package install [--version ]") { + t.Fatalf("unexpected help: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } +} + +// runPackageForTest runs a package subcommand against an explicit project and returns its exit code and output. +func runPackageForTest(t *testing.T, projectRoot string, args ...string) (int, string, string) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + _, code := tryHandlePackageRequest(context.Background(), append([]string{"package"}, args...), t.TempDir(), projectRoot, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestPackageCommandsReportFailures(t *testing.T) { + // Verifies install and status stop with code 1 and the error for each failure, leaving the manifest untouched. + cases := []struct { + name string + manifest string + args []string + wantMessage string + }{ + {name: "install invalid option", manifest: barePackageManifest(), args: []string{"install", "--bogus=1"}, wantMessage: "Unknown package install option: --bogus"}, + {name: "install invalid manifest", manifest: "{", args: []string{"install", "--version", "1.0.0"}, wantMessage: "PACKAGE_MANIFEST_INVALID"}, + {name: "status invalid option", manifest: barePackageManifest(), args: []string{"status", "--verbose"}, wantMessage: "Unknown package status option: --verbose"}, + {name: "status invalid manifest", manifest: "{", args: []string{"status"}, wantMessage: "PACKAGE_MANIFEST_INVALID"}, + {name: "status dependency not a string", manifest: `{"dependencies":{"` + dispatcherUnityPackageName + `":1}}`, args: []string{"status"}, wantMessage: "PACKAGE_MANIFEST_INVALID"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + projectRoot := createPackageTestProject(t, testCase.manifest) + + code, stdout, stderr := runPackageForTest(t, projectRoot, testCase.args...) + + if code != 1 || stdout != "" || !strings.Contains(stderr, testCase.wantMessage) { + t.Fatalf("unexpected result: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if manifest := readPackageManifest(t, projectRoot); manifest != testCase.manifest { + t.Fatalf("manifest must stay untouched: %s", manifest) + } + }) + } +} + +func TestPackageStatusReportsMissingManifestAndProject(t *testing.T) { + // Verifies status distinguishes a project without Packages/manifest.json from a path that is no Unity project. + projectRoot := createDispatcherUnityProject(t) + code, _, stderr := runPackageForTest(t, projectRoot, "status") + if code != 1 || !strings.Contains(stderr, "PACKAGE_MANIFEST_INVALID") { + t.Fatalf("expected the manifest error: code=%d stderr=%s", code, stderr) + } + + code, _, stderr = runPackageForTest(t, t.TempDir(), "status") + if code != 1 || strings.Contains(stderr, "PACKAGE_MANIFEST_INVALID") || !strings.Contains(stderr, "Unity project") { + t.Fatalf("expected the project resolution error: code=%d stderr=%s", code, stderr) + } +} + +func TestPackageInstallReportsRegistryFailure(t *testing.T) { + // Verifies a failed registry lookup is reported as PACKAGE_REGISTRY_UNAVAILABLE with its cause, without editing the manifest. + projectRoot := createPackageTestProject(t, barePackageManifest()) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(server.Close) + previousURL := openUPMRegistryBaseURL + previousClient := packageRegistryHTTPClient + openUPMRegistryBaseURL = server.URL + packageRegistryHTTPClient = server.Client() + t.Cleanup(func() { + openUPMRegistryBaseURL = previousURL + packageRegistryHTTPClient = previousClient + }) + + code, _, stderr := runPackageForTest(t, projectRoot, "install") + + if code != 1 || !strings.Contains(stderr, "PACKAGE_REGISTRY_UNAVAILABLE") || !strings.Contains(stderr, "OpenUPM registry returned HTTP 503") { + t.Fatalf("expected the registry error: code=%d stderr=%s", code, stderr) + } + if manifest := readPackageManifest(t, projectRoot); manifest != barePackageManifest() { + t.Fatalf("manifest must stay untouched: %s", manifest) + } +} + +func TestPackageInstallReportsManifestWriteFailure(t *testing.T) { + // Verifies an unwritable Packages directory fails the install and keeps the original manifest. + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permissions are required for this failure.") + } + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions.") + } + projectRoot := createPackageTestProject(t, barePackageManifest()) + packagesDir := filepath.Join(projectRoot, "Packages") + if err := os.Chmod(packagesDir, 0o555); err != nil { + t.Fatalf("failed to lock %s: %v", packagesDir, err) + } + t.Cleanup(func() { _ = os.Chmod(packagesDir, 0o755) }) + + code, _, stderr := runPackageForTest(t, projectRoot, "install", "--version", "1.0.0") + + if code != 1 || !strings.Contains(stderr, "manifest.json.tmp: permission denied") { + t.Fatalf("expected the write error: code=%d stderr=%s", code, stderr) + } + if manifest := readPackageManifest(t, projectRoot); manifest != barePackageManifest() { + t.Fatalf("manifest must stay untouched: %s", manifest) + } +} + +func TestPackageInstallReportsVersionUpgrade(t *testing.T) { + // Verifies installing a new version over an existing dependency reports the previous and new versions. + projectRoot := createPackageTestProject(t, installedPackageManifest("1.0.0")) + + code, stdout, stderr := runPackageForTest(t, projectRoot, "install", "--version", "2.0.0") + + if code != 0 || !strings.Contains(stdout, "Updated "+dispatcherUnityPackageName+" 1.0.0 -> 2.0.0 in Packages/manifest.json") { + t.Fatalf("unexpected result: code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if strings.Contains(stdout, "Added scoped registry") { + t.Fatalf("the existing registry must not be reported as added: %s", stdout) + } +} + +func TestInspectPackageManifestStatusRejectsMalformedEntries(t *testing.T) { + // Verifies malformed dependency or registry entries fail inspection instead of being read as not installed. + cases := map[string]string{ + "dependencies not an object": `{"dependencies":[]}`, + "registries not an array": `{"scopedRegistries":{}}`, + "registry entry not object": `{"scopedRegistries":[1]}`, + "registry url not a string": `{"scopedRegistries":[{"url":1}]}`, + "registry scopes not strings": `{"scopedRegistries":[{"url":"` + openUPMRegistryURL + `","scopes":[1]}]}`, + } + for name, manifest := range cases { + if _, err := inspectPackageManifestStatus([]byte(manifest)); err == nil { + t.Fatalf("%s: expected an inspection error", name) + } + } +} + +func TestInspectPackageManifestStatusIgnoresRegistryWithoutScopes(t *testing.T) { + // Verifies an OpenUPM registry entry without scopes does not count as installed. + status, err := inspectPackageManifestStatus([]byte(`{"scopedRegistries":[{"url":"` + openUPMRegistryURL + `"}]}`)) + + if err != nil || status.registryInstalled { + t.Fatalf("unexpected status: %+v err=%v", status, err) + } +} From ba15989ec7b0f2eee51ad9c5cf9889b2285fcdac Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 05:34:30 +0900 Subject: [PATCH 19/68] chore: Dispatcher help, platform errors, command routing, and version output now have regression tests (#3091) --- .../bootstrap_platform_errors_test.go | 54 +++++++++++++++++++ .../dispatcher/dispatcher_process_test.go | 43 +++++++++++++++ .../dispatcher_version_human_test.go | 28 ++++++++++ .../internal/dispatcher/run_help_test.go | 45 ++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 cli/dispatcher/internal/dispatcher/bootstrap_platform_errors_test.go create mode 100644 cli/dispatcher/internal/dispatcher/dispatcher_process_test.go create mode 100644 cli/dispatcher/internal/dispatcher/run_help_test.go diff --git a/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors_test.go b/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors_test.go new file mode 100644 index 0000000000..62d5ee6a4d --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors_test.go @@ -0,0 +1,54 @@ +package dispatcher + +import ( + "errors" + "testing" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" +) + +func TestUnsupportedPlatformErrorGivesCommandSpecificActions(t *testing.T) { + // Verifies each bootstrap command's unsupported-OS message maps to an invalid-argument error with its own manual fallback. + cases := map[string]string{ + updateUnsupportedOSMessage: "Install the latest uloop dispatcher manually on this platform.", + uninstallUnsupportedOSMessage: "Remove the uloop dispatcher binary manually on this platform.", + } + for message, wantManualAction := range cases { + cliError, ok := unsupportedPlatformError(message, clierrors.ErrorContext{Command: ""}) + if !ok || cliError.ErrorCode != clierrors.ErrorCodeInvalidArgument || cliError.Command != "" || cliError.Message != message { + t.Fatalf("%q: unexpected result ok=%t error=%+v", message, ok, cliError) + } + if len(cliError.NextActions) != 2 || cliError.NextActions[1] != wantManualAction { + t.Fatalf("%q: next actions mismatch: %v", message, cliError.NextActions) + } + } + if _, ok := unsupportedPlatformError("some other failure", clierrors.ErrorContext{}); ok { + t.Fatal("an unrelated message must not be classified as an unsupported platform") + } +} + +func TestWrapUnsupportedPlatformErrorKeepsNilAndMessage(t *testing.T) { + // Verifies a nil error stays nil and a wrapped unsupported-OS error still reports the original message. + if err := wrapUnsupportedPlatformError(nil); err != nil { + t.Fatalf("nil must stay nil, got %v", err) + } + + wrapped := wrapUnsupportedPlatformError(errors.New(uninstallUnsupportedOSMessage)) + + var platformErr unsupportedPlatformCommandError + if !errors.As(wrapped, &platformErr) || wrapped.Error() != uninstallUnsupportedOSMessage { + t.Fatalf("expected a wrapped platform error with the original message, got %T %v", wrapped, wrapped) + } +} + +func TestWrapUnsupportedPlatformErrorReturnsUnrelatedErrorsUnchanged(t *testing.T) { + // Verifies an error that does not report an unsupported OS is returned as is rather than reclassified. + original := errors.New("disk full") + + wrapped := wrapUnsupportedPlatformError(original) + + var platformErr unsupportedPlatformCommandError + if !errors.Is(wrapped, original) || errors.As(wrapped, &platformErr) { + t.Fatalf("expected the original error unchanged, got %T %v", wrapped, wrapped) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_process_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_process_test.go new file mode 100644 index 0000000000..466a46ad93 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/dispatcher_process_test.go @@ -0,0 +1,43 @@ +package dispatcher + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" +) + +func TestTryHandleDispatcherInfoRequestPrintsDispatcherHelpWithoutArgs(t *testing.T) { + // Verifies a bare invocation outside a project prints the dispatcher's own help. + var stdout bytes.Buffer + + handled, code := tryHandleDispatcherInfoRequest(nil, &stdout) + + if !handled || code != 0 || !strings.Contains(stdout.String(), "Dispatcher. Finds the Unity project, then dispatches live Unity tool commands.") { + t.Fatalf("unexpected result: handled=%t code=%d stdout=%s", handled, code, stdout.String()) + } +} + +func TestTryHandlePreConnectionRequestRoutesCompileCheckAndPassesThroughToolCommands(t *testing.T) { + // Verifies compile-check is handled before connecting, and a Unity tool command is left for the project runner. + deps := fakeDispatcherRunDeps(t) + deps.launch = isolatedLaunchTestDeps(t) + var stderr bytes.Buffer + + compileArgs := []string{clicore.CompileCheckCommandName, "--bogus"} + handled, code := tryHandlePreConnectionRequestWithDeps(context.Background(), compileArgs, compileArgs[0], compileArgs[1:], t.TempDir(), "", io.Discard, &stderr, deps) + if !handled || code != 1 || !strings.Contains(stderr.String(), "Unknown compile-check option: --bogus") { + t.Fatalf("compile-check was not routed: handled=%t code=%d stderr=%s", handled, code, stderr.String()) + } + + var stdout bytes.Buffer + stderr.Reset() + toolArgs := []string{"get-logs"} + handled, code = tryHandlePreConnectionRequestWithDeps(context.Background(), toolArgs, toolArgs[0], nil, t.TempDir(), "", &stdout, &stderr, deps) + if handled || code != 0 || stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("a tool command must pass through: handled=%t code=%d stdout=%s stderr=%s", handled, code, stdout.String(), stderr.String()) + } +} diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_version_human_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_version_human_test.go index f1680d4868..e5d49b7882 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_version_human_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_version_human_test.go @@ -5,6 +5,8 @@ import ( "context" "encoding/json" "io" + "os" + "path/filepath" "testing" "github.com/hatayama/unity-cli-loop/common/clicontract" @@ -166,3 +168,29 @@ func TestIsTerminalWriterRejectsNonFileWriter(t *testing.T) { t.Fatal("a bytes.Buffer must not be reported as a terminal") } } + +func TestDispatcherStdoutIsTerminalTreatsMissingProbeAndFilesAsNonTerminal(t *testing.T) { + // Verifies deps without a terminal probe, and a regular file as stdout, both count as non-terminal. + if dispatcherStdoutIsTerminal(io.Discard, dispatcherRunDeps{}) { + t.Fatal("deps without a probe must not count as a terminal") + } + file, err := os.Create(filepath.Join(t.TempDir(), "stdout.txt")) + if err != nil { + t.Fatalf("failed to create file: %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + if isTerminalWriter(file) { + t.Fatal("a regular file must not count as a terminal") + } +} + +func TestDispatcherV2VersionContextSentenceWithoutResolvedVersion(t *testing.T) { + // Verifies a V2 project whose package version is ambiguous points the user at the error from other commands. + sentence := dispatcherV2VersionContextSentence(dispatcherV2Project{IsV2: true, PackageVersionCandidates: []string{"2.1.0", "2.2.0"}}) + + want := "This Unity project uses the uloop V2 package, but its package version could not be resolved; " + + "see the error from any other uloop command here." + if sentence != want { + t.Fatalf("unexpected sentence: %s", sentence) + } +} diff --git a/cli/dispatcher/internal/dispatcher/run_help_test.go b/cli/dispatcher/internal/dispatcher/run_help_test.go new file mode 100644 index 0000000000..df726f8228 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/run_help_test.go @@ -0,0 +1,45 @@ +package dispatcher + +import ( + "bytes" + "strings" + "testing" + + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/tools" +) + +func TestPrintUnityToolCommandHelpReportsEmptyCache(t *testing.T) { + // Verifies a project cache with no tools tells the user to sync instead of printing an empty list. + var stdout bytes.Buffer + + printUnityToolCommandHelp(&stdout, clicore.ToolsCache{}, true) + + if !strings.Contains(stdout.String(), "No cached Unity tools found. Run `uloop sync` while Unity is running.") { + t.Fatalf("missing empty-cache line: %s", stdout.String()) + } + if strings.Contains(stdout.String(), "refresh this list") { + t.Fatalf("an empty cache must stop before the list footer: %s", stdout.String()) + } +} + +func TestPrintUnityToolCommandHelpSkipsNativeCommandsAndTruncates(t *testing.T) { + // Verifies cached tools that shadow native commands are left out and long descriptions are cut with an ellipsis. + longDescription := strings.Repeat("word ", 40) + cache := clicore.ToolsCache{Tools: []tools.ToolDefinition{ + {Name: clicore.LaunchCommandName, Description: "Shadowing tool that must not be listed."}, + {Name: "sample-tool", Description: longDescription}, + }} + var stdout bytes.Buffer + + printUnityToolCommandHelp(&stdout, cache, true) + + output := stdout.String() + if strings.Contains(output, "Shadowing tool") { + t.Fatalf("a cached tool named like a native command must be skipped: %s", output) + } + want := strings.TrimSpace(longDescription[:maxCommandListDescriptionLength-3]) + "..." + if !strings.Contains(output, "sample-tool") || !strings.Contains(output, want+"\n") { + t.Fatalf("missing truncated description %q: %s", want, output) + } +} From fef89062e5da9d46fab406263063ba0dbbae6644 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 05:38:14 +0900 Subject: [PATCH 20/68] fix: Unsupported-platform errors from install, update, and uninstall now list Linux as supported (#3094) --- .../dispatcher/bootstrap_platform_errors.go | 6 +++--- .../internal/dispatcher/error_envelope_test.go | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors.go b/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors.go index d001299dd2..cf3585691f 100644 --- a/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors.go +++ b/cli/dispatcher/internal/dispatcher/bootstrap_platform_errors.go @@ -13,7 +13,7 @@ func unsupportedPlatformError(message string, context clierrors.ErrorContext) (c message, context, []string{ - "Run `uloop update` on macOS or Windows.", + "Run `uloop update` on macOS, Linux, or Windows.", "Install the latest uloop dispatcher manually on this platform.", }), true case installUnsupportedOSMessage: @@ -21,7 +21,7 @@ func unsupportedPlatformError(message string, context clierrors.ErrorContext) (c message, context, []string{ - "Run `uloop install` on macOS or Windows.", + "Run `uloop install` on macOS, Linux, or Windows.", "Use the platform-specific installer for this system.", }), true case uninstallUnsupportedOSMessage: @@ -29,7 +29,7 @@ func unsupportedPlatformError(message string, context clierrors.ErrorContext) (c message, context, []string{ - "Run `uloop uninstall` on macOS or Windows.", + "Run `uloop uninstall` on macOS, Linux, or Windows.", "Remove the uloop dispatcher binary manually on this platform.", }), true default: diff --git a/cli/dispatcher/internal/dispatcher/error_envelope_test.go b/cli/dispatcher/internal/dispatcher/error_envelope_test.go index c7518c3167..1afb6f6814 100644 --- a/cli/dispatcher/internal/dispatcher/error_envelope_test.go +++ b/cli/dispatcher/internal/dispatcher/error_envelope_test.go @@ -76,12 +76,27 @@ func TestClassifyInstallUnsupportedOS(t *testing.T) { if cliErr.Phase != clierrors.ErrorPhaseExecution { t.Fatalf("phase mismatch: %#v", cliErr) } - expectedAction := "Run `uloop install` on macOS or Windows." + expectedAction := "Run `uloop install` on macOS, Linux, or Windows." if len(cliErr.NextActions) == 0 || cliErr.NextActions[0] != expectedAction { t.Fatalf("next actions mismatch: %#v", cliErr.NextActions) } } +func TestUnsupportedPlatformErrorPointsAtEverySupportedPlatform(t *testing.T) { + // Verifies install, update, and uninstall guidance names Linux too, matching the platforms the commands support. + cases := map[string]string{ + installUnsupportedOSMessage: "Run `uloop install` on macOS, Linux, or Windows.", + updateUnsupportedOSMessage: "Run `uloop update` on macOS, Linux, or Windows.", + uninstallUnsupportedOSMessage: "Run `uloop uninstall` on macOS, Linux, or Windows.", + } + for message, wantAction := range cases { + cliErr, ok := unsupportedPlatformError(message, clierrors.ErrorContext{}) + if !ok || len(cliErr.NextActions) == 0 || cliErr.NextActions[0] != wantAction { + t.Fatalf("%q: next actions mismatch: ok=%t actions=%#v", message, ok, cliErr.NextActions) + } + } +} + func TestClassifyServerBusyRPCError_WhenCompiling_IncludesEditorActivity(t *testing.T) { // Verifies dispatcher-side busy classification surfaces compile-specific editor activity guidance. cliErr := clierrors.ClassifyError( From fd7230a88d80ad5cc49f0fde5e279ec5bd097542 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 05:47:12 +0900 Subject: [PATCH 21/68] chore: Nightly runs now record Go and C# coverage in a tracking issue (#3086) --- .github/workflows/unity-editmode-tests.yml | 105 +++++ AGENTS.md | 5 +- .../Tests/Editor/SkillInstallLayoutTests.cs | 2 +- Packages/manifest.json | 1 + Packages/packages-lock.json | 17 + .../Settings.json | 5 + .../cmd/coverage-report/main.go | 5 + .../cmd/publish-coverage-trend/main.go | 12 + .../internal/automation/coverage_report.go | 78 +++- .../automation/coverage_report_csharp.go | 250 ++++++++++++ .../automation/coverage_report_csharp_test.go | 380 ++++++++++++++++++ .../automation/coverage_report_test.go | 35 ++ .../automation/publish_coverage_trend.go | 180 +++++++++ .../automation/publish_coverage_trend_test.go | 306 ++++++++++++++ coverage-baseline.json | 15 + docs/coverage.md | 62 ++- 16 files changed, 1444 insertions(+), 14 deletions(-) create mode 100644 ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json create mode 100644 cli/release-automation/cmd/publish-coverage-trend/main.go create mode 100644 cli/release-automation/internal/automation/coverage_report_csharp.go create mode 100644 cli/release-automation/internal/automation/coverage_report_csharp_test.go create mode 100644 cli/release-automation/internal/automation/publish_coverage_trend.go create mode 100644 cli/release-automation/internal/automation/publish_coverage_trend_test.go diff --git a/.github/workflows/unity-editmode-tests.yml b/.github/workflows/unity-editmode-tests.yml index 0009c7a1ce..7e81ac5893 100644 --- a/.github/workflows/unity-editmode-tests.yml +++ b/.github/workflows/unity-editmode-tests.yml @@ -2,6 +2,11 @@ name: Unity EditMode Tests on: workflow_dispatch: + inputs: + post-trend: + description: Post this run's coverage to the coverage-trend issue (scheduled runs always post) + type: boolean + default: false schedule: - cron: '0 18 * * *' @@ -40,6 +45,10 @@ jobs: - unity-version: 2022.3.62f3 test-filter: '' strict-compile-check: true + # Only the full-suite leg measures C# coverage: the other legs run the hot-reload + # suites alone, so their figures would not describe the package. The filters keep + # the shipped package assemblies and drop tests, dev tooling, and samples. + coverage-options: generateAdditionalMetrics;generateHtmlReport;assemblyFilters:+UnityCLILoop.*,-UnityCLILoop.Tests.*,-UnityCLILoop.Dev,-UnityCLILoop.CustomCommandSamples.* # Newer editors open the committed 2022.3 project through an ephemeral # in-place upgrade, so obsolete-API warnings are expected there; skip the # zero-warning gate and run only the hot-reload suites, which are the @@ -174,6 +183,18 @@ jobs: customParameters: ${{ matrix.test-filter != '' && format('-testFilter {0}', matrix.test-filter) || '' }} checkName: Unity Test Results (${{ matrix.unity-version }}) githubToken: ${{ secrets.GITHUB_TOKEN }} + coverageOptions: ${{ matrix.coverage-options }} + + - name: Upload C# coverage report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() && env.HAS_UNITY_LICENSE == 'true' && matrix.coverage-options != '' + with: + name: csharp-coverage + # Only the ReportGenerator output: the raw OpenCover XML next to it is tens of MB and the + # trend needs just Summary.json. upload-artifact stores the directory's contents, so + # Summary.json and index.htm sit at the artifact root. + path: CodeCoverage/Report + retention-days: 14 - name: Upload test results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a @@ -182,6 +203,90 @@ jobs: name: Unity test results (${{ matrix.unity-version }}) path: artifacts + # Records Go and C# coverage on the coverage-trend issue every night. It runs even when a Unity + # leg failed, so the Go trend is kept; a run without the C# artifact posts Go alone. + coverage-trend: + name: Coverage trend + needs: [editmode-tests] + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + # Serialized so a scheduled and a manual run cannot both find no issue and create two, which + # would make every later run fail on the several-issues check. + concurrency: + group: coverage-trend + cancel-in-progress: false + permissions: + actions: read + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + # Same history as the build-cli job, so the Go tests run under the conditions the gate measures. + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: cli/.go-version + cache: false + + - name: Measure Go test coverage + env: + GO_COVERAGE_DIR: ${{ runner.temp }}/go-coverage + run: | + set -eu + mkdir -p "$GO_COVERAGE_DIR" + for module in common dispatcher project-runner release-automation; do + (cd "cli/$module" && go test -coverprofile="$GO_COVERAGE_DIR/$module.out" ./...) + done + + # The artifact is missing when the Unity license is absent or the 2022.3 leg failed before + # its tests ran; the Go trend is still posted then. + - name: Download C# coverage report + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + continue-on-error: true + with: + name: csharp-coverage + path: ${{ runner.temp }}/csharp-coverage + + - name: Build coverage Markdown + working-directory: cli/release-automation + env: + GO_COVERAGE_DIR: ${{ runner.temp }}/go-coverage + CSHARP_SUMMARY: ${{ runner.temp }}/csharp-coverage/Summary.json + COVERAGE_MARKDOWN: ${{ runner.temp }}/coverage.md + run: | + set -eu + if [ -f "$CSHARP_SUMMARY" ]; then + go run ./cmd/coverage-report --mode report \ + --baseline ../../coverage-baseline.json \ + --go-coverage-dir "$GO_COVERAGE_DIR" \ + --csharp-summary "$CSHARP_SUMMARY" \ + --markdown-out "$COVERAGE_MARKDOWN" + else + go run ./cmd/coverage-report --mode report \ + --baseline ../../coverage-baseline.json \ + --go-coverage-dir "$GO_COVERAGE_DIR" \ + --markdown-out "$COVERAGE_MARKDOWN" + printf '%s\n' "C# coverage was not produced in this run." >> "$COVERAGE_MARKDOWN" + printf '%s\n' "C# coverage was not produced in this run." >> "$GITHUB_STEP_SUMMARY" + fi + + # Only the nightly figures belong in the trend; a dispatched branch run still shows its + # tables in the run summary, which coverage-report writes in the step above. + - name: Post coverage to the trend issue + if: ${{ github.event_name == 'schedule' || inputs.post-trend == true }} + working-directory: cli/release-automation + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + COVERAGE_MARKDOWN: ${{ runner.temp }}/coverage.md + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: go run ./cmd/publish-coverage-trend --body-file "$COVERAGE_MARKDOWN" --run-url "$RUN_URL" + resolve-latest-6000-7: name: Resolve latest 6000.7 editor runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index d66777664a..6330f360ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,8 +173,9 @@ figures in `coverage-baseline.json`; the Linux `build-cli` job fails the pull request otherwise. When your tests lift a module a point or more, the job summary suggests a new figure; raise it in the same pull request. Never lower a figure without a reason -in the pull request description. What is measured and how to run it locally: -`docs/coverage.md`. +in the pull request description. C# coverage is measured nightly in the Unity +2022.3 EditMode leg and posted to the `coverage-trend` issue; it warns but never +gates. What is measured and how to run it locally: `docs/coverage.md`. ## Nested Type Name Forms diff --git a/Assets/Tests/Editor/SkillInstallLayoutTests.cs b/Assets/Tests/Editor/SkillInstallLayoutTests.cs index afb93d5c52..d2ea652270 100644 --- a/Assets/Tests/Editor/SkillInstallLayoutTests.cs +++ b/Assets/Tests/Editor/SkillInstallLayoutTests.cs @@ -164,7 +164,7 @@ public void GetToolDescriptionsByToolName_WhenSkillHasDescription_MapsDescriptio IReadOnlyDictionary descriptions = SkillInstallLayout.GetToolDescriptionsByToolName(_projectRoot); Assert.That(descriptions["compile"], Does.StartWith("Compile the Unity project")); - Assert.That(descriptions[UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT], Does.StartWith("Pauses Unity playback")); + Assert.That(descriptions[UnityCliLoopConstants.SETTINGS_TOOL_NAME_PAUSE_POINT], Does.StartWith("Breakpoint-like: pause Unity")); } // Tests that duplicate skill names use the earlier source root across each precedence boundary. diff --git a/Packages/manifest.json b/Packages/manifest.json index a36601c881..0b19be6d8c 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -12,6 +12,7 @@ "com.unity.render-pipelines.universal": "14.0.12", "com.unity.terrain-tools": "5.0.6", "com.unity.test-framework": "1.3.9", + "com.unity.testtools.codecoverage": "1.2.6", "com.unity.ugui": "1.0.0", "com.unity.modules.animation": "1.0.0", "com.unity.modules.imageconversion": "1.0.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index bb3dfade07..d41611ea49 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -165,6 +165,13 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.settings-manager": { + "version": "2.1.0", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, "com.unity.shadergraph": { "version": "14.0.12", "depth": 1, @@ -195,6 +202,16 @@ }, "url": "https://packages.unity.com" }, + "com.unity.testtools.codecoverage": { + "version": "1.2.6", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.0.16", + "com.unity.settings-manager": "1.0.1" + }, + "url": "https://packages.unity.com" + }, "com.unity.ugui": { "version": "1.0.0", "depth": 0, diff --git a/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json b/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json new file mode 100644 index 0000000000..3c7b4c18bb --- /dev/null +++ b/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json @@ -0,0 +1,5 @@ +{ + "m_Dictionary": { + "m_DictionaryValues": [] + } +} \ No newline at end of file diff --git a/cli/release-automation/cmd/coverage-report/main.go b/cli/release-automation/cmd/coverage-report/main.go index 0cdc7c2192..ef24287c88 100644 --- a/cli/release-automation/cmd/coverage-report/main.go +++ b/cli/release-automation/cmd/coverage-report/main.go @@ -12,6 +12,8 @@ func main() { baseline := flag.String("baseline", "coverage-baseline.json", "path to the coverage baseline file") mode := flag.String("mode", "report", "gate exits 1 when a module falls below its baseline; report only warns") coverageDir := flag.String("go-coverage-dir", "", "directory of .out go test -coverprofile outputs") + csharpSummary := flag.String("csharp-summary", "", "ReportGenerator Summary.json to report C# coverage from; C# warns but never gates") + markdownOut := flag.String("markdown-out", "", "file to write the coverage Markdown to, without workflow commands") flag.Parse() profiles, err := automation.CollectGoCoverProfiles(*coverageDir) @@ -25,5 +27,8 @@ func main() { GoProfiles: profiles, Mode: *mode, SummaryPath: os.Getenv("GITHUB_STEP_SUMMARY"), + + CSharpSummaryPath: *csharpSummary, + MarkdownOutPath: *markdownOut, })) } diff --git a/cli/release-automation/cmd/publish-coverage-trend/main.go b/cli/release-automation/cmd/publish-coverage-trend/main.go new file mode 100644 index 0000000000..b55bf41e4b --- /dev/null +++ b/cli/release-automation/cmd/publish-coverage-trend/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "context" + "os" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + os.Exit(automation.RunPublishCoverageTrend(context.Background(), os.Stdout, os.Stderr, os.Args[1:])) +} diff --git a/cli/release-automation/internal/automation/coverage_report.go b/cli/release-automation/internal/automation/coverage_report.go index 87f1e6e8eb..5b712db88b 100644 --- a/cli/release-automation/internal/automation/coverage_report.go +++ b/cli/release-automation/internal/automation/coverage_report.go @@ -34,6 +34,12 @@ type CoverageReportOptions struct { GoProfiles map[string]string Mode string SummaryPath string + // CSharpSummaryPath is a ReportGenerator Summary.json; when empty, C# is neither read nor + // validated, so the pull-request Go gate does not depend on the csharp baseline section. + CSharpSummaryPath string + // MarkdownOutPath receives the step summary content without workflow commands, replacing any + // existing file. + MarkdownOutPath string } // GoCoverageTotals is the statement count of a profile after exclusions. @@ -48,6 +54,8 @@ type coverageBaseline struct { // Pointers so a null figure is told apart from 0 and rejected. Modules map[string]*float64 `json:"modules"` } `json:"go"` + // CSharp stays raw until a C# summary is given, so a malformed section cannot fail a Go-only run. + CSharp json.RawMessage `json:"csharp"` } type coverageModuleResult struct { @@ -83,20 +91,27 @@ func RunCoverageReport(stdout io.Writer, stderr io.Writer, options CoverageRepor return 2 } - table := formatCoverageTable(results) - _, _ = fmt.Fprint(stdout, table) - if err := appendCoverageSummary(options.SummaryPath, table); err != nil { - _, _ = fmt.Fprintf(stderr, "write step summary: %v\n", err) + csharp, err := measureOptionalCSharpCoverage(baseline, options.CSharpSummaryPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "measure C# coverage: %v\n", err) return 2 } + table := formatCoverageTable(results) below := modulesBelowBaseline(results) - if len(below) == 0 { - return 0 + writeCoverageStdout(stdout, options.Mode, table, below, csharp) + + markdown := "## Go test coverage\n\n" + table + "\n" + formatOptionalCSharpSection(csharp) + if err := appendCoverageSummary(options.SummaryPath, markdown); err != nil { + _, _ = fmt.Fprintf(stderr, "write step summary: %v\n", err) + return 2 + } + if err := writeCoverageMarkdown(options.MarkdownOutPath, markdown); err != nil { + _, _ = fmt.Fprintf(stderr, "write coverage markdown: %v\n", err) + return 2 } - if options.Mode == coverageModeReport { - _, _ = fmt.Fprintf(stdout, "::warning::Coverage fell below the baseline in: %s\n", strings.Join(below, ", ")) + if len(below) == 0 || options.Mode == coverageModeReport { return 0 } @@ -104,6 +119,42 @@ func RunCoverageReport(stdout io.Writer, stderr io.Writer, options CoverageRepor return 1 } +// measureOptionalCSharpCoverage returns nil when no C# summary was given. +func measureOptionalCSharpCoverage(baseline coverageBaseline, summaryPath string) (*csharpCoverageResult, error) { + if summaryPath == "" { + return nil, nil + } + result, err := measureCSharpCoverage(baseline.CSharp, summaryPath) + if err != nil { + return nil, err + } + return &result, nil +} + +// writeCoverageStdout prints the Go table and its report-mode warning exactly as before the C# +// report existed, then the C# section and its warning when C# was measured. The C# warning is +// printed in both modes because C# never gates. +func writeCoverageStdout(stdout io.Writer, mode string, table string, below []string, csharp *csharpCoverageResult) { + _, _ = fmt.Fprint(stdout, table) + if len(below) > 0 && mode == coverageModeReport { + _, _ = fmt.Fprintf(stdout, "::warning::Coverage fell below the baseline in: %s\n", strings.Join(below, ", ")) + } + if csharp == nil { + return + } + _, _ = fmt.Fprint(stdout, formatCSharpCoverageSection(*csharp)) + if csharp.belowBaseline() { + _, _ = fmt.Fprint(stdout, formatCSharpCoverageWarning(*csharp)) + } +} + +func formatOptionalCSharpSection(csharp *csharpCoverageResult) string { + if csharp == nil { + return "" + } + return formatCSharpCoverageSection(*csharp) +} + // ParseGoCoverProfile totals the statements of a `go test -coverprofile` file, leaving out blocks // whose file path contains any of the exclude patterns. func ParseGoCoverProfile(reader io.Reader, exclude []string) (GoCoverageTotals, error) { @@ -323,7 +374,7 @@ func floorToOneDecimal(value float64) float64 { return math.Floor(value*10+1e-9) / 10 } -func appendCoverageSummary(path string, table string) error { +func appendCoverageSummary(path string, markdown string) error { if path == "" { return nil } @@ -332,6 +383,13 @@ func appendCoverageSummary(path string, table string) error { if err != nil { return err } - _, writeErr := io.WriteString(file, "## Go test coverage\n\n"+table+"\n") + _, writeErr := io.WriteString(file, markdown) return errors.Join(writeErr, file.Close()) } + +func writeCoverageMarkdown(path string, markdown string) error { + if path == "" { + return nil + } + return os.WriteFile(path, []byte(markdown), 0o600) +} diff --git a/cli/release-automation/internal/automation/coverage_report_csharp.go b/cli/release-automation/internal/automation/coverage_report_csharp.go new file mode 100644 index 0000000000..c5093c4458 --- /dev/null +++ b/cli/release-automation/internal/automation/coverage_report_csharp.go @@ -0,0 +1,250 @@ +package automation + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" +) + +const csharpCoverageHeading = "## C# test coverage (EditMode, Unity 2022.3)" + +// csharpCoverageBaseline is the csharp section of the baseline file. Pointers tell a missing or +// null key apart from an empty list or a 0% figure, both of which are valid. +type csharpCoverageBaseline struct { + Exclude *[]string `json:"exclude"` + LineCoverage *float64 `json:"lineCoverage"` +} + +// csharpCoverageSummary is the part of a ReportGenerator Summary.json the report reads. The +// per-assembly percentage and the overall summary are ignored because the measured scope is +// recomputed from line counts after exclusions. +type csharpCoverageSummary struct { + Coverage struct { + Assemblies []csharpCoverageSummaryAssembly `json:"assemblies"` + } `json:"coverage"` +} + +// csharpCoverageSummaryAssembly uses pointers so a missing line count is rejected instead of read +// as 0, which would silently lower the scope figure. ReportGenerator always writes these keys, so +// a missing one means its format changed. +type csharpCoverageSummaryAssembly struct { + Name string `json:"name"` + Covered *int `json:"coveredlines"` + Coverable *int `json:"coverablelines"` +} + +type csharpCoverageAssembly struct { + Name string + Covered int + Coverable int +} + +// csharpCoverageResult is the measured C# scope against its baseline, with the assemblies split +// into the measured scope and the excluded ones, each sorted for the report. +type csharpCoverageResult struct { + Scoped []csharpCoverageAssembly + Excluded []csharpCoverageAssembly + Covered int + Coverable int + Percent float64 + Baseline float64 +} + +func (result csharpCoverageResult) belowBaseline() bool { + return result.Percent < result.Baseline-coverageRoundingTolerance +} + +// measureCSharpCoverage validates the csharp baseline section and the Summary.json, then totals the +// assemblies left after exclusions. The section is parsed here rather than with the Go baseline so +// a broken C# entry cannot fail a run that measures Go alone. +func measureCSharpCoverage(section json.RawMessage, summaryPath string) (csharpCoverageResult, error) { + baseline, err := parseCSharpCoverageBaseline(section) + if err != nil { + return csharpCoverageResult{}, err + } + assemblies, err := readCSharpCoverageSummary(summaryPath) + if err != nil { + return csharpCoverageResult{}, err + } + + result, err := splitCSharpAssemblies(assemblies, *baseline.Exclude) + if err != nil { + return csharpCoverageResult{}, err + } + if result.Coverable == 0 { + return csharpCoverageResult{}, errors.New("measured C# scope has no coverable lines") + } + result.Percent = float64(result.Covered) * 100 / float64(result.Coverable) + result.Baseline = *baseline.LineCoverage + return result, nil +} + +func parseCSharpCoverageBaseline(section json.RawMessage) (csharpCoverageBaseline, error) { + if len(section) == 0 || string(section) == "null" { + return csharpCoverageBaseline{}, errors.New("baseline has no csharp section") + } + + var baseline csharpCoverageBaseline + if err := json.Unmarshal(section, &baseline); err != nil { + return csharpCoverageBaseline{}, fmt.Errorf("parse csharp baseline: %w", err) + } + if baseline.Exclude == nil { + return csharpCoverageBaseline{}, errors.New("csharp baseline has no exclude list") + } + if baseline.LineCoverage == nil { + return csharpCoverageBaseline{}, errors.New("csharp baseline has no lineCoverage") + } + if *baseline.LineCoverage < 0 || *baseline.LineCoverage > 100 { + return csharpCoverageBaseline{}, fmt.Errorf("csharp lineCoverage is %v, outside 0 to 100", *baseline.LineCoverage) + } + return baseline, nil +} + +func readCSharpCoverageSummary(path string) ([]csharpCoverageAssembly, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read C# coverage summary: %w", err) + } + + var summary csharpCoverageSummary + if err := json.Unmarshal(content, &summary); err != nil { + return nil, fmt.Errorf("parse C# coverage summary: %w", err) + } + if len(summary.Coverage.Assemblies) == 0 { + return nil, errors.New("coverage summary lists no C# assemblies") + } + + assemblies := make([]csharpCoverageAssembly, 0, len(summary.Coverage.Assemblies)) + for index, entry := range summary.Coverage.Assemblies { + assembly, err := toCSharpCoverageAssembly(index, entry) + if err != nil { + return nil, err + } + if err := validateCSharpAssembly(assembly); err != nil { + return nil, err + } + assemblies = append(assemblies, assembly) + } + return assemblies, nil +} + +func toCSharpCoverageAssembly(index int, entry csharpCoverageSummaryAssembly) (csharpCoverageAssembly, error) { + if entry.Name == "" { + return csharpCoverageAssembly{}, fmt.Errorf("assembly %d in the coverage summary has no name", index) + } + if entry.Covered == nil { + return csharpCoverageAssembly{}, fmt.Errorf("assembly %q has no coveredlines", entry.Name) + } + if entry.Coverable == nil { + return csharpCoverageAssembly{}, fmt.Errorf("assembly %q has no coverablelines", entry.Name) + } + return csharpCoverageAssembly{Name: entry.Name, Covered: *entry.Covered, Coverable: *entry.Coverable}, nil +} + +func validateCSharpAssembly(assembly csharpCoverageAssembly) error { + if assembly.Coverable < 0 { + return fmt.Errorf("assembly %q has negative coverable lines", assembly.Name) + } + if assembly.Covered < 0 { + return fmt.Errorf("assembly %q has negative covered lines", assembly.Name) + } + if assembly.Covered > assembly.Coverable { + return fmt.Errorf("assembly %q covers %d of %d coverable lines", assembly.Name, assembly.Covered, assembly.Coverable) + } + return nil +} + +// splitCSharpAssemblies requires every excluded name to match an assembly, so a misspelled entry +// fails instead of silently leaving the assembly it meant to drop inside the measured scope. +func splitCSharpAssemblies(assemblies []csharpCoverageAssembly, exclude []string) (csharpCoverageResult, error) { + excluded := map[string]bool{} + for _, name := range exclude { + excluded[name] = true + } + + result := csharpCoverageResult{} + matched := map[string]bool{} + for _, assembly := range assemblies { + if excluded[assembly.Name] { + matched[assembly.Name] = true + result.Excluded = append(result.Excluded, assembly) + continue + } + result.Scoped = append(result.Scoped, assembly) + result.Covered += assembly.Covered + result.Coverable += assembly.Coverable + } + for _, name := range exclude { + if !matched[name] { + return csharpCoverageResult{}, fmt.Errorf("csharp exclude names %q, which the summary does not list", name) + } + } + + sortCSharpAssembliesByUncovered(result.Scoped) + sortCSharpAssembliesByUncovered(result.Excluded) + return result, nil +} + +func sortCSharpAssembliesByUncovered(assemblies []csharpCoverageAssembly) { + sort.SliceStable(assemblies, func(left int, right int) bool { + leftUncovered := assemblies[left].Coverable - assemblies[left].Covered + rightUncovered := assemblies[right].Coverable - assemblies[right].Covered + if leftUncovered != rightUncovered { + return leftUncovered > rightUncovered + } + return assemblies[left].Name < assemblies[right].Name + }) +} + +// formatCSharpCoverageSection renders the C# Markdown section. It carries no ::warning:: command +// because the same text becomes the trend issue comment; falling below shows in the first line. +func formatCSharpCoverageSection(result csharpCoverageResult) string { + shown := floorToOneDecimal(result.Percent) + var builder strings.Builder + builder.WriteString(csharpCoverageHeading + "\n\n") + _, _ = fmt.Fprintf( + &builder, + "Measured scope: %s%% (%d / %d lines), baseline %s%% (%s)", + formatCoveragePercent(shown), + result.Covered, + result.Coverable, + formatCoveragePercent(result.Baseline), + fmt.Sprintf("%+.1f", shown-result.Baseline)) + if result.belowBaseline() { + builder.WriteString(", below baseline") + } + builder.WriteString("\n\n") + builder.WriteString(formatCSharpAssemblyTable(result.Scoped)) + builder.WriteString("\n### Excluded from the measured scope\n\n") + builder.WriteString(formatCSharpAssemblyTable(result.Excluded)) + builder.WriteString("\n") + return builder.String() +} + +func formatCSharpAssemblyTable(assemblies []csharpCoverageAssembly) string { + var builder strings.Builder + builder.WriteString("| Assembly | Covered | Coverable | Coverage |\n|---|---|---|---|\n") + for _, assembly := range assemblies { + _, _ = fmt.Fprintf(&builder, "| %s | %d | %d | %s |\n", assembly.Name, assembly.Covered, assembly.Coverable, formatCSharpAssemblyPercent(assembly)) + } + return builder.String() +} + +// formatCSharpAssemblyPercent shows "-" for an assembly with no coverable lines rather than +// dividing zero by zero. +func formatCSharpAssemblyPercent(assembly csharpCoverageAssembly) string { + if assembly.Coverable == 0 { + return "-" + } + return formatCoveragePercent(floorToOneDecimal(float64(assembly.Covered)*100/float64(assembly.Coverable))) + "%" +} + +func formatCSharpCoverageWarning(result csharpCoverageResult) string { + return fmt.Sprintf( + "::warning::C# coverage fell below the baseline: %s%% against %s%%\n", + formatCoveragePercent(floorToOneDecimal(result.Percent)), + formatCoveragePercent(result.Baseline)) +} diff --git a/cli/release-automation/internal/automation/coverage_report_csharp_test.go b/cli/release-automation/internal/automation/coverage_report_csharp_test.go new file mode 100644 index 0000000000..b5e99e82cd --- /dev/null +++ b/cli/release-automation/internal/automation/coverage_report_csharp_test.go @@ -0,0 +1,380 @@ +package automation + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const csharpCoverageTestHeading = "## C# test coverage (EditMode, Unity 2022.3)\n\n" + +const csharpCoverageTestGoMarkdown = "## Go test coverage\n\n" + + "| Module | Coverage | Baseline | Note |\n|---|---|---|---|\n" + + "| common | 40.0% | 40.0% | |\n\n" + +type csharpTestAssembly struct { + Name string `json:"name"` + Covered int `json:"coveredlines"` + Coverable int `json:"coverablelines"` +} + +// csharpTestAssemblies is listed out of report order on purpose: the two assemblies tied on +// uncovered lines come in reverse name order, so the name tie-break is visible in the output. +func csharpTestAssemblies() []csharpTestAssembly { + return []csharpTestAssembly{ + {Name: "UnityCLILoop.Zeta", Covered: 0, Coverable: 5}, + {Name: "UnityCLILoop.Beta", Covered: 45, Coverable: 50}, + {Name: "UnityCLILoop.Empty", Covered: 0, Coverable: 0}, + {Name: "UnityCLILoop.Alpha", Covered: 10, Coverable: 100}, + {Name: "UnityCLILoop.Runtime", Covered: 1, Coverable: 4}, + {Name: "UnityCLILoop.Overlay", Covered: 0, Coverable: 9}, + } +} + +// csharpTestScopedTables is the two tables for csharpTestAssemblies with Runtime and Overlay +// excluded; the scope is 55 of 155 lines, 35.48%, shown truncated as 35.4. +const csharpTestScopedTables = "| Assembly | Covered | Coverable | Coverage |\n|---|---|---|---|\n" + + "| UnityCLILoop.Alpha | 10 | 100 | 10.0% |\n" + + "| UnityCLILoop.Beta | 45 | 50 | 90.0% |\n" + + "| UnityCLILoop.Zeta | 0 | 5 | 0.0% |\n" + + "| UnityCLILoop.Empty | 0 | 0 | - |\n\n" + + "### Excluded from the measured scope\n\n" + + "| Assembly | Covered | Coverable | Coverage |\n|---|---|---|---|\n" + + "| UnityCLILoop.Overlay | 0 | 9 | 0.0% |\n" + + "| UnityCLILoop.Runtime | 1 | 4 | 25.0% |\n\n" + +func csharpTestBaseline(lineCoverage float64) map[string]any { + return map[string]any{ + "exclude": []string{"UnityCLILoop.Runtime", "UnityCLILoop.Overlay"}, + "lineCoverage": lineCoverage, + } +} + +// writeBaselineWithCSharp rewrites the fixture baseline with one Go module at 40% and the given +// raw csharp section; a nil section leaves the key out. +func (fixture *coverageFixture) writeBaselineWithCSharp(csharp any) { + fixture.t.Helper() + baseline := map[string]any{ + "go": map[string]any{"exclude": []string{"/cmd/"}, "modules": map[string]float64{"common": 40.0}}, + } + if csharp != nil { + baseline["csharp"] = csharp + } + content, err := json.Marshal(baseline) + if err != nil { + fixture.t.Fatalf("marshal baseline: %v", err) + } + if err := os.WriteFile(fixture.baselinePath, content, 0o600); err != nil { + fixture.t.Fatalf("write baseline: %v", err) + } +} + +func (fixture *coverageFixture) writeCSharpSummary(assemblies any) { + fixture.t.Helper() + content, err := json.Marshal(map[string]any{ + "summary": map[string]any{"linecoverage": 99.9}, + "coverage": map[string]any{"assemblies": assemblies}, + }) + if err != nil { + fixture.t.Fatalf("marshal summary: %v", err) + } + fixture.writeRawCSharpSummary(string(content)) +} + +func (fixture *coverageFixture) writeRawCSharpSummary(content string) { + fixture.t.Helper() + fixture.csharpSummaryPath = filepath.Join(fixture.dir, "Summary.json") + if err := os.WriteFile(fixture.csharpSummaryPath, []byte(content), 0o600); err != nil { + fixture.t.Fatalf("write summary: %v", err) + } +} + +func newCSharpCoverageFixture(t *testing.T, csharp any) *coverageFixture { + t.Helper() + fixture := newCoverageFixture(t, map[string]float64{"common": 40.0}) + fixture.writeProfile("common", 4, 10) + fixture.writeBaselineWithCSharp(csharp) + fixture.writeCSharpSummary(csharpTestAssemblies()) + fixture.summaryPath = filepath.Join(fixture.dir, "step-summary.md") + fixture.markdownOutPath = filepath.Join(fixture.dir, "coverage.md") + return fixture +} + +func readCoverageTestFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(content) +} + +func TestRunCoverageReportAddsTheCSharpSectionToEveryOutput(t *testing.T) { + // Verifies the C# section sums only the scoped assemblies, sorts both tables by uncovered lines + // then name, shows "-" for an assembly with no coverable lines, and reaches stdout, the step + // summary, and the Markdown file after the Go table. + fixture := newCSharpCoverageFixture(t, csharpTestBaseline(30.0)) + + code, stdout, stderr := fixture.run(coverageModeReport) + + if code != 0 || stderr != "" { + t.Fatalf("expected exit 0 with no stderr, got %d: %s", code, stderr) + } + section := csharpCoverageTestHeading + + "Measured scope: 35.4% (55 / 155 lines), baseline 30.0% (+5.4)\n\n" + + csharpTestScopedTables + wantMarkdown := csharpCoverageTestGoMarkdown + section + if got := readCoverageTestFile(t, fixture.markdownOutPath); got != wantMarkdown { + t.Fatalf("markdown file:\nwant %q\ngot %q", wantMarkdown, got) + } + if got := readCoverageTestFile(t, fixture.summaryPath); got != wantMarkdown { + t.Fatalf("step summary:\nwant %q\ngot %q", wantMarkdown, got) + } + wantStdout := "| Module | Coverage | Baseline | Note |\n|---|---|---|---|\n| common | 40.0% | 40.0% | |\n" + section + if stdout != wantStdout { + t.Fatalf("stdout:\nwant %q\ngot %q", wantStdout, stdout) + } +} + +func TestRunCoverageReportWarnsOnStdoutOnlyWhenCSharpFallsBelowItsBaseline(t *testing.T) { + // Verifies a scope under its baseline adds a ::warning:: line to stdout, marks the section + // "below baseline" in the Markdown file without the warning line, and never changes the exit + // code in either mode. + for _, mode := range []string{coverageModeGate, coverageModeReport} { + t.Run(mode, func(t *testing.T) { + fixture := newCSharpCoverageFixture(t, csharpTestBaseline(35.6)) + + code, stdout, stderr := fixture.run(mode) + + if code != 0 { + t.Fatalf("expected C# to warn without gating, got %d: %s", code, stderr) + } + wantWarning := "::warning::C# coverage fell below the baseline: 35.4% against 35.6%\n" + if !strings.HasSuffix(stdout, csharpTestScopedTables+wantWarning) { + t.Fatalf("expected the C# warning after the section:\n%s", stdout) + } + markdown := readCoverageTestFile(t, fixture.markdownOutPath) + wantLine := csharpCoverageTestHeading + + "Measured scope: 35.4% (55 / 155 lines), baseline 35.6% (-0.2), below baseline\n\n" + if !strings.Contains(markdown, wantLine) || strings.Contains(markdown, "::warning::") { + t.Fatalf("expected a below-baseline line and no warning command:\n%s", markdown) + } + }) + } +} + +func TestRunCoverageReportToleratesCSharpRoundingJustUnderBaseline(t *testing.T) { + // Verifies a scope within 0.1 point under its baseline (35.48% against 35.5%) is not reported + // as below, matching the Go tolerance for one-decimal baselines. + fixture := newCSharpCoverageFixture(t, csharpTestBaseline(35.5)) + + _, stdout, _ := fixture.run(coverageModeReport) + + if strings.Contains(stdout, "::warning::") || strings.Contains(stdout, "below baseline") { + t.Fatalf("expected no below-baseline report:\n%s", stdout) + } +} + +func TestRunCoverageReportCSharpFailsClosedOnBadInputs(t *testing.T) { + // Verifies each malformed C# input fails both modes with a message only that check produces. + cases := map[string]struct { + arrange func(fixture *coverageFixture) + message string + }{ + "summary file missing": { + arrange: func(fixture *coverageFixture) { fixture.csharpSummaryPath = filepath.Join(fixture.dir, "absent.json") }, + message: "read C# coverage summary", + }, + "summary not JSON": { + arrange: func(fixture *coverageFixture) { fixture.writeRawCSharpSummary("{not json") }, + message: "parse C# coverage summary", + }, + "no assemblies": { + arrange: func(fixture *coverageFixture) { fixture.writeCSharpSummary([]csharpTestAssembly{}) }, + message: "coverage summary lists no C# assemblies", + }, + "assemblies key missing": { + arrange: func(fixture *coverageFixture) { fixture.writeRawCSharpSummary(`{"coverage":{}}`) }, + message: "coverage summary lists no C# assemblies", + }, + "negative coverable lines": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary(withCSharpTestAssembly(csharpTestAssembly{Name: "UnityCLILoop.Bad", Covered: 0, Coverable: -1})) + }, + message: `assembly "UnityCLILoop.Bad" has negative coverable lines`, + }, + "negative covered lines": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary(withCSharpTestAssembly(csharpTestAssembly{Name: "UnityCLILoop.Bad", Covered: -1, Coverable: 3})) + }, + message: `assembly "UnityCLILoop.Bad" has negative covered lines`, + }, + "covered above coverable": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary(withCSharpTestAssembly(csharpTestAssembly{Name: "UnityCLILoop.Bad", Covered: 4, Coverable: 3})) + }, + message: `assembly "UnityCLILoop.Bad" covers 4 of 3 coverable lines`, + }, + "assembly without a name": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary(withCSharpTestAssemblyJSON(map[string]any{"coveredlines": 1, "coverablelines": 2})) + }, + message: "assembly 6 in the coverage summary has no name", + }, + "assembly without coveredlines": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary(withCSharpTestAssemblyJSON(map[string]any{"name": "UnityCLILoop.Bad", "coverablelines": 2})) + }, + message: `assembly "UnityCLILoop.Bad" has no coveredlines`, + }, + "assembly without coverablelines": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary(withCSharpTestAssemblyJSON(map[string]any{"name": "UnityCLILoop.Bad", "coveredlines": 0})) + }, + message: `assembly "UnityCLILoop.Bad" has no coverablelines`, + }, + "baseline without csharp section": { + arrange: func(fixture *coverageFixture) { fixture.writeBaselineWithCSharp(nil) }, + message: "baseline has no csharp section", + }, + "exclude names an absent assembly": { + arrange: func(fixture *coverageFixture) { + fixture.writeBaselineWithCSharp(map[string]any{"exclude": []string{"UnityCLILoop.Runtime", "UnityCLILoop.Overlay", "UnityCLILoop.Typo"}, "lineCoverage": 30.0}) + }, + message: `csharp exclude names "UnityCLILoop.Typo", which the summary does not list`, + }, + "no coverable lines in scope": { + arrange: func(fixture *coverageFixture) { + fixture.writeCSharpSummary([]csharpTestAssembly{{Name: "UnityCLILoop.Empty"}, {Name: "UnityCLILoop.Runtime", Covered: 1, Coverable: 4}, {Name: "UnityCLILoop.Overlay", Coverable: 9}}) + }, + message: "measured C# scope has no coverable lines", + }, + "lineCoverage above 100": { + arrange: func(fixture *coverageFixture) { fixture.writeBaselineWithCSharp(csharpTestBaseline(100.1)) }, + message: "csharp lineCoverage is 100.1, outside 0 to 100", + }, + "lineCoverage below 0": { + arrange: func(fixture *coverageFixture) { fixture.writeBaselineWithCSharp(csharpTestBaseline(-0.1)) }, + message: "csharp lineCoverage is -0.1, outside 0 to 100", + }, + "lineCoverage missing": { + arrange: func(fixture *coverageFixture) { + fixture.writeBaselineWithCSharp(map[string]any{"exclude": []string{"UnityCLILoop.Runtime"}}) + }, + message: "csharp baseline has no lineCoverage", + }, + "lineCoverage null": { + arrange: func(fixture *coverageFixture) { + fixture.writeBaselineWithCSharp(map[string]any{"exclude": []string{"UnityCLILoop.Runtime"}, "lineCoverage": nil}) + }, + message: "csharp baseline has no lineCoverage", + }, + "exclude missing": { + arrange: func(fixture *coverageFixture) { fixture.writeBaselineWithCSharp(map[string]any{"lineCoverage": 30.0}) }, + message: "csharp baseline has no exclude list", + }, + } + for name, testCase := range cases { + for _, mode := range []string{coverageModeGate, coverageModeReport} { + t.Run(name+"/"+mode, func(t *testing.T) { + fixture := newCSharpCoverageFixture(t, csharpTestBaseline(30.0)) + testCase.arrange(fixture) + + code, _, stderr := fixture.run(mode) + + if code == 0 || !strings.Contains(stderr, testCase.message) { + t.Fatalf("expected a non-zero exit with %q, got %d: %s", testCase.message, code, stderr) + } + }) + } + } +} + +func withCSharpTestAssembly(extra csharpTestAssembly) []csharpTestAssembly { + return append(csharpTestAssemblies(), extra) +} + +// withCSharpTestAssemblyJSON appends a raw assembly entry so a test can leave out a key that the +// typed fixture would always write. +func withCSharpTestAssemblyJSON(extra map[string]any) []any { + assemblies := []any{} + for _, assembly := range csharpTestAssemblies() { + assemblies = append(assemblies, assembly) + } + return append(assemblies, extra) +} + +func TestRunCoverageReportIgnoresABrokenCSharpBaselineWithoutASummary(t *testing.T) { + // Verifies the Go gate neither reads nor validates the csharp section when no C# summary is + // given, so a broken C# baseline cannot change a pull request's Go result. + for name, csharp := range map[string]any{ + "wrong types": map[string]any{"exclude": "UnityCLILoop.Runtime", "lineCoverage": "high"}, + "missing keys": map[string]any{}, + "out of range": csharpTestBaseline(250), + "not an object": []int{1, 2}, + } { + t.Run(name, func(t *testing.T) { + fixture := newCoverageFixture(t, map[string]float64{"common": 40.0}) + fixture.writeProfile("common", 4, 10) + fixture.writeBaselineWithCSharp(csharp) + + code, stdout, stderr := fixture.run(coverageModeGate) + + want := "| Module | Coverage | Baseline | Note |\n|---|---|---|---|\n| common | 40.0% | 40.0% | |\n" + if code != 0 || stdout != want || stderr != "" { + t.Fatalf("expected the plain Go result, got %d:\n%s%s", code, stdout, stderr) + } + }) + } +} + +func TestRunCoverageReportWritesGoOnlyMarkdownWithoutACSharpSummary(t *testing.T) { + // Verifies the Markdown file holds just the Go section when no C# summary is given, and that an + // existing file is replaced rather than appended to. + fixture := newCoverageFixture(t, map[string]float64{"common": 40.0}) + fixture.writeProfile("common", 4, 10) + fixture.markdownOutPath = filepath.Join(fixture.dir, "coverage.md") + if err := os.WriteFile(fixture.markdownOutPath, []byte("stale content\n"), 0o600); err != nil { + t.Fatalf("write stale file: %v", err) + } + + if code, stdout, stderr := fixture.run(coverageModeReport); code != 0 { + t.Fatalf("expected exit 0, got %d\n%s%s", code, stdout, stderr) + } + + if got := readCoverageTestFile(t, fixture.markdownOutPath); got != csharpCoverageTestGoMarkdown { + t.Fatalf("markdown file:\nwant %q\ngot %q", csharpCoverageTestGoMarkdown, got) + } +} + +func TestRunCoverageReportFailsWhenTheMarkdownFileCannotBeWritten(t *testing.T) { + // Verifies a Markdown path that cannot be written fails instead of leaving the trend comment + // without content; a directory stands in for the path so the failure does not depend on the user. + fixture := newCoverageFixture(t, map[string]float64{"common": 40.0}) + fixture.writeProfile("common", 4, 10) + fixture.markdownOutPath = t.TempDir() + + code, _, stderr := fixture.run(coverageModeReport) + + if code == 0 || !strings.Contains(stderr, "write coverage markdown") { + t.Fatalf("expected a markdown write failure, got %d: %s", code, stderr) + } +} + +func TestRunCoverageReportPrintsTheCSharpSectionEvenWhenGoFailsTheGate(t *testing.T) { + // Verifies a Go module under its baseline still fails the gate with exit 1 while the C# section + // is reported, so the C# report neither hides nor overrides the Go result. + fixture := newCSharpCoverageFixture(t, csharpTestBaseline(30.0)) + fixture.writeProfile("common", 3, 10) + + code, stdout, stderr := fixture.run(coverageModeGate) + + if code != 1 || !strings.Contains(stderr, "Coverage fell below the baseline in: common") { + t.Fatalf("expected the Go gate to fail, got %d: %s", code, stderr) + } + if !strings.Contains(stdout, csharpCoverageTestHeading) { + t.Fatalf("expected the C# section on stdout:\n%s", stdout) + } +} diff --git a/cli/release-automation/internal/automation/coverage_report_test.go b/cli/release-automation/internal/automation/coverage_report_test.go index 7c64b58e99..0c54221036 100644 --- a/cli/release-automation/internal/automation/coverage_report_test.go +++ b/cli/release-automation/internal/automation/coverage_report_test.go @@ -262,6 +262,9 @@ type coverageFixture struct { baselinePath string profiles map[string]string summaryPath string + + csharpSummaryPath string + markdownOutPath string } func newCoverageFixture(t *testing.T, modules map[string]float64) *coverageFixture { @@ -308,6 +311,38 @@ func (fixture *coverageFixture) run(mode string) (int, string, string) { GoProfiles: fixture.profiles, Mode: mode, SummaryPath: fixture.summaryPath, + + CSharpSummaryPath: fixture.csharpSummaryPath, + MarkdownOutPath: fixture.markdownOutPath, }) return code, stdout.String(), stderr.String() } + +func TestRunCoverageReportGoOnlyOutputIsUnchangedByteForByte(t *testing.T) { + // Verifies stdout and the step summary keep their exact Go-only content when no C# summary is + // given, so the pull-request gate output is not disturbed by the C# report. + fixture := newCoverageFixture(t, map[string]float64{"common": 50.0, "dispatcher": 10.0}) + fixture.writeProfile("common", 4, 10) + fixture.writeProfile("dispatcher", 2, 3) + fixture.summaryPath = filepath.Join(fixture.dir, "summary.md") + + code, stdout, stderr := fixture.run(coverageModeReport) + + table := "| Module | Coverage | Baseline | Note |\n|---|---|---|---|\n" + + "| common | 40.0% | 50.0% | below baseline |\n" + + "| dispatcher | 66.6% | 10.0% | raise baseline to 66.6 |\n" + if code != 0 || stderr != "" { + t.Fatalf("expected exit 0 with no stderr, got %d: %s", code, stderr) + } + wantStdout := table + "::warning::Coverage fell below the baseline in: common\n" + if stdout != wantStdout { + t.Fatalf("stdout changed:\nwant %q\ngot %q", wantStdout, stdout) + } + summary, err := os.ReadFile(fixture.summaryPath) + if err != nil { + t.Fatalf("read summary: %v", err) + } + if wantSummary := "## Go test coverage\n\n" + table + "\n"; string(summary) != wantSummary { + t.Fatalf("step summary changed:\nwant %q\ngot %q", wantSummary, summary) + } +} diff --git a/cli/release-automation/internal/automation/publish_coverage_trend.go b/cli/release-automation/internal/automation/publish_coverage_trend.go new file mode 100644 index 0000000000..f07ef2bb18 --- /dev/null +++ b/cli/release-automation/internal/automation/publish_coverage_trend.go @@ -0,0 +1,180 @@ +package automation + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// coverageTrendIssueLabel identifies the single issue that collects the nightly coverage comments. +const ( + coverageTrendIssueLabel = "coverage-trend" + coverageTrendIssueLabelDescription = "Nightly test coverage trend" + coverageTrendIssueLabelColor = "0E8A16" + coverageTrendIssueTitle = "Test coverage trend" + coverageTrendIssueBody = "Each nightly Unity EditMode Tests run appends a comment with the Go and C# test coverage. Keep exactly one open issue with the coverage-trend label: the run fails when it finds several." +) + +// publishCoverageTrendOptions is one publish run: the repository, the Markdown to post, and the +// optional workflow run URL shown above it. +type publishCoverageTrendOptions struct { + Repository string + BodyFile string + RunURL string +} + +type publishCoverageTrendDeps struct { + runOutput func(context.Context, string, ...string) (string, error) + now func() time.Time +} + +type coverageTrendIssueListEntry struct { + Number int `json:"number"` +} + +// RunPublishCoverageTrend appends the coverage Markdown as a comment on the open coverage-trend +// issue, creating the label and the issue when none is open. +func RunPublishCoverageTrend(ctx context.Context, stdout io.Writer, stderr io.Writer, args []string) int { + options, err := parsePublishCoverageTrendOptions(args, os.LookupEnv) + if err != nil { + _, _ = fmt.Fprintln(stderr, "publish-coverage-trend:", err) + return 2 + } + return runPublishCoverageTrendWithDeps(ctx, stdout, stderr, options, publishCoverageTrendDeps{ + runOutput: runNotifyPendingReleaseApprovalsCommandOutput, + now: time.Now, + }) +} + +func parsePublishCoverageTrendOptions(args []string, lookupEnv func(string) (string, bool)) (publishCoverageTrendOptions, error) { + flags := flag.NewFlagSet("publish-coverage-trend", flag.ContinueOnError) + flags.SetOutput(io.Discard) + repository := flags.String("repo", "", "owner/name of the repository; defaults to GITHUB_REPOSITORY") + bodyFile := flags.String("body-file", "", "Markdown file to post as the comment body") + runURL := flags.String("run-url", "", "workflow run URL shown above the Markdown") + if err := flags.Parse(args); err != nil { + return publishCoverageTrendOptions{}, err + } + + if *repository == "" { + *repository, _ = lookupEnv("GITHUB_REPOSITORY") + } + if *repository == "" { + return publishCoverageTrendOptions{}, errors.New("--repo or GITHUB_REPOSITORY is required") + } + if *bodyFile == "" { + return publishCoverageTrendOptions{}, errors.New("--body-file is required") + } + return publishCoverageTrendOptions{Repository: *repository, BodyFile: *bodyFile, RunURL: *runURL}, nil +} + +func runPublishCoverageTrendWithDeps(ctx context.Context, stdout io.Writer, stderr io.Writer, options publishCoverageTrendOptions, deps publishCoverageTrendDeps) int { + issueNumber, err := publishCoverageTrend(ctx, options, deps) + if err != nil { + _, _ = fmt.Fprintln(stderr, "publish-coverage-trend:", err) + return 1 + } + _, _ = fmt.Fprintf(stdout, "Posted coverage to issue #%d.\n", issueNumber) + return 0 +} + +func publishCoverageTrend(ctx context.Context, options publishCoverageTrendOptions, deps publishCoverageTrendDeps) (int, error) { + markdown, err := os.ReadFile(options.BodyFile) + if err != nil { + return 0, fmt.Errorf("read body file: %w", err) + } + if strings.TrimSpace(string(markdown)) == "" { + return 0, errors.New("body file is empty") + } + + issueNumber, err := findOrCreateCoverageTrendIssue(ctx, options.Repository, deps) + if err != nil { + return 0, err + } + + comment := formatCoverageTrendComment(deps.now(), options.RunURL, string(markdown)) + if err := commentOnCoverageTrendIssue(ctx, options.Repository, issueNumber, comment, deps); err != nil { + return 0, err + } + return issueNumber, nil +} + +// findOrCreateCoverageTrendIssue refuses to choose between several labelled issues, because +// posting to an arbitrary one would split the trend across issues without anyone noticing. +func findOrCreateCoverageTrendIssue(ctx context.Context, repository string, deps publishCoverageTrendDeps) (int, error) { + output, err := deps.runOutput(ctx, "gh", "issue", "list", "--repo", repository, "--state", "open", "--label", coverageTrendIssueLabel, "--json", "number", "--limit", "100") + if err != nil { + return 0, fmt.Errorf("list coverage trend issues: %w", err) + } + + entries := []coverageTrendIssueListEntry{} + if err := json.Unmarshal([]byte(output), &entries); err != nil { + return 0, fmt.Errorf("parse coverage trend issue list: %w", err) + } + switch len(entries) { + case 0: + return createCoverageTrendIssue(ctx, repository, deps) + case 1: + return entries[0].Number, nil + } + + numbers := make([]string, 0, len(entries)) + for _, entry := range entries { + numbers = append(numbers, "#"+strconv.Itoa(entry.Number)) + } + return 0, fmt.Errorf("several open issues carry the %s label (%s); close all but one", coverageTrendIssueLabel, strings.Join(numbers, ", ")) +} + +func createCoverageTrendIssue(ctx context.Context, repository string, deps publishCoverageTrendDeps) (int, error) { + // --force makes the call succeed when the label already exists, so no existence check is needed. + if _, err := deps.runOutput(ctx, "gh", "label", "create", coverageTrendIssueLabel, "--repo", repository, "--description", coverageTrendIssueLabelDescription, "--color", coverageTrendIssueLabelColor, "--force"); err != nil { + return 0, fmt.Errorf("create %s label: %w", coverageTrendIssueLabel, err) + } + + output, err := deps.runOutput(ctx, "gh", "issue", "create", "--repo", repository, "--title", coverageTrendIssueTitle, "--label", coverageTrendIssueLabel, "--body", coverageTrendIssueBody) + if err != nil { + return 0, fmt.Errorf("create coverage trend issue: %w", err) + } + + issueURL := strings.TrimSpace(output) + number, err := strconv.Atoi(issueURL[strings.LastIndex(issueURL, "/")+1:]) + if err != nil || number <= 0 { + return 0, fmt.Errorf("read the issue number from the created issue URL %q", issueURL) + } + return number, nil +} + +func formatCoverageTrendComment(now time.Time, runURL string, markdown string) string { + header := "Nightly coverage — " + now.UTC().Format("2006-01-02") + if runURL != "" { + header += " — " + runURL + } + return header + "\n\n" + markdown +} + +// commentOnCoverageTrendIssue passes the comment through a file because the Markdown tables can +// exceed what is comfortable to pass as a single command-line argument. +func commentOnCoverageTrendIssue(ctx context.Context, repository string, issueNumber int, comment string, deps publishCoverageTrendDeps) error { + dir, err := os.MkdirTemp("", "coverage-trend-") + if err != nil { + return fmt.Errorf("create comment body directory: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + bodyPath := filepath.Join(dir, "comment.md") + if err := os.WriteFile(bodyPath, []byte(comment), 0o600); err != nil { + return fmt.Errorf("write comment body: %w", err) + } + if _, err := deps.runOutput(ctx, "gh", "issue", "comment", strconv.Itoa(issueNumber), "--repo", repository, "--body-file", bodyPath); err != nil { + return fmt.Errorf("comment on coverage trend issue #%d: %w", issueNumber, err) + } + return nil +} diff --git a/cli/release-automation/internal/automation/publish_coverage_trend_test.go b/cli/release-automation/internal/automation/publish_coverage_trend_test.go new file mode 100644 index 0000000000..2f7e8fd55d --- /dev/null +++ b/cli/release-automation/internal/automation/publish_coverage_trend_test.go @@ -0,0 +1,306 @@ +package automation + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const coverageTrendTestRepository = "example-owner/example-repo" + +// fakeCoverageTrendGh records every gh call and answers by the first two arguments, so a test can +// assert both the sequence of steps and the arguments each one received. +type fakeCoverageTrendGh struct { + t *testing.T + responses map[string]string + failures map[string]error + calls [][]string + // commentBody is the --body-file content as it was when gh issue comment ran. + commentBody string +} + +func newFakeCoverageTrendGh(t *testing.T, issueListOutput string) *fakeCoverageTrendGh { + return &fakeCoverageTrendGh{ + t: t, + responses: map[string]string{ + "issue list": issueListOutput, + "issue create": "https://github.com/" + coverageTrendTestRepository + "/issues/57\n", + }, + failures: map[string]error{}, + } +} + +func (fake *fakeCoverageTrendGh) runOutput(_ context.Context, name string, args ...string) (string, error) { + fake.t.Helper() + if name != "gh" || len(args) < 2 { + fake.t.Fatalf("unexpected command %s %v", name, args) + } + fake.calls = append(fake.calls, args) + step := args[0] + " " + args[1] + if err := fake.failures[step]; err != nil { + return "", err + } + if step == "issue comment" { + fake.commentBody = fake.readBodyFile(args) + } + return fake.responses[step], nil +} + +func (fake *fakeCoverageTrendGh) readBodyFile(args []string) string { + fake.t.Helper() + for index, arg := range args { + if arg == "--body-file" && index+1 < len(args) { + content, err := os.ReadFile(args[index+1]) + if err != nil { + fake.t.Fatalf("read comment body: %v", err) + } + return string(content) + } + } + fake.t.Fatalf("gh issue comment without --body-file: %v", args) + return "" +} + +func (fake *fakeCoverageTrendGh) joinedCalls() []string { + joined := make([]string, 0, len(fake.calls)) + for _, call := range fake.calls { + joined = append(joined, strings.Join(call, " ")) + } + return joined +} + +func writeCoverageTrendTestBody(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "coverage.md") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write body file: %v", err) + } + return path +} + +func runPublishCoverageTrendForTest(t *testing.T, fake *fakeCoverageTrendGh, options publishCoverageTrendOptions) (int, string, string) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPublishCoverageTrendWithDeps(context.Background(), &stdout, &stderr, options, publishCoverageTrendDeps{ + runOutput: fake.runOutput, + // 23:30 at UTC-5 is already the next day in UTC, so a local-date bug shows in the header. + now: func() time.Time { return time.Date(2026, 10, 2, 23, 30, 0, 0, time.FixedZone("UTC-5", -5*3600)) }, + }) + return code, stdout.String(), stderr.String() +} + +func coverageTrendTestOptions(t *testing.T) publishCoverageTrendOptions { + return publishCoverageTrendOptions{ + Repository: coverageTrendTestRepository, + BodyFile: writeCoverageTrendTestBody(t, "## Go test coverage\n\n| table |\n"), + RunURL: "https://github.com/" + coverageTrendTestRepository + "/actions/runs/123", + } +} + +const coverageTrendTestListCall = "issue list --repo " + coverageTrendTestRepository + " --state open --label coverage-trend --json number --limit 100" + +func TestPublishCoverageTrendCommentsOnTheSingleOpenIssue(t *testing.T) { + // Verifies one open labelled issue receives the comment, headed by the UTC date and run URL, + // without creating a label or an issue. + fake := newFakeCoverageTrendGh(t, `[{"number":41}]`) + + code, stdout, stderr := runPublishCoverageTrendForTest(t, fake, coverageTrendTestOptions(t)) + + if code != 0 || stdout != "Posted coverage to issue #41.\n" { + t.Fatalf("expected success on #41, got %d: %s%s", code, stdout, stderr) + } + calls := fake.joinedCalls() + if len(calls) != 2 || calls[0] != coverageTrendTestListCall || + !strings.HasPrefix(calls[1], "issue comment 41 --repo "+coverageTrendTestRepository+" --body-file ") { + t.Fatalf("unexpected gh calls: %q", calls) + } + want := "Nightly coverage — 2026-10-03 — https://github.com/" + coverageTrendTestRepository + "/actions/runs/123\n\n## Go test coverage\n\n| table |\n" + if fake.commentBody != want { + t.Fatalf("comment body:\nwant %q\ngot %q", want, fake.commentBody) + } +} + +func TestPublishCoverageTrendCreatesTheLabelAndIssueWhenNoneIsOpen(t *testing.T) { + // Verifies that with no open issue the label is created with --force, the issue is created with + // the label, and the comment goes to the number read from the created issue URL. + fake := newFakeCoverageTrendGh(t, `[]`) + + code, _, stderr := runPublishCoverageTrendForTest(t, fake, coverageTrendTestOptions(t)) + + if code != 0 { + t.Fatalf("expected success, got %d: %s", code, stderr) + } + calls := fake.joinedCalls() + wantPrefixes := []string{ + coverageTrendTestListCall, + "label create coverage-trend --repo " + coverageTrendTestRepository + " --description Nightly test coverage trend --color 0E8A16 --force", + "issue create --repo " + coverageTrendTestRepository + " --title Test coverage trend --label coverage-trend --body " + coverageTrendIssueBody, + "issue comment 57 --repo " + coverageTrendTestRepository + " --body-file ", + } + if len(calls) != len(wantPrefixes) { + t.Fatalf("expected %d gh calls, got %q", len(wantPrefixes), calls) + } + for index, prefix := range wantPrefixes { + if !strings.HasPrefix(calls[index], prefix) { + t.Fatalf("call %d:\nwant prefix %q\ngot %q", index, prefix, calls[index]) + } + } +} + +func TestPublishCoverageTrendOmitsTheRunURLWhenNoneIsGiven(t *testing.T) { + // Verifies the comment header is just the date when no run URL is given. + fake := newFakeCoverageTrendGh(t, `[{"number":41}]`) + options := coverageTrendTestOptions(t) + options.RunURL = "" + + if code, _, stderr := runPublishCoverageTrendForTest(t, fake, options); code != 0 { + t.Fatalf("expected success, got %d: %s", code, stderr) + } + + if !strings.HasPrefix(fake.commentBody, "Nightly coverage — 2026-10-03\n\n## Go test coverage") { + t.Fatalf("unexpected comment header: %q", fake.commentBody) + } +} + +func TestPublishCoverageTrendRefusesToChooseBetweenSeveralOpenIssues(t *testing.T) { + // Verifies several open labelled issues fail with their numbers and nothing is posted. + fake := newFakeCoverageTrendGh(t, `[{"number":41},{"number":44}]`) + + code, _, stderr := runPublishCoverageTrendForTest(t, fake, coverageTrendTestOptions(t)) + + if code == 0 || !strings.Contains(stderr, "several open issues carry the coverage-trend label (#41, #44)") { + t.Fatalf("expected a several-issues failure, got %d: %s", code, stderr) + } + if len(fake.calls) != 1 { + t.Fatalf("expected only the list call, got %q", fake.joinedCalls()) + } +} + +func TestPublishCoverageTrendReportsWhichGhStepFailed(t *testing.T) { + // Verifies each failing gh step fails the run with a message naming that step. + cases := map[string]struct { + issueList string + arrange func(fake *fakeCoverageTrendGh) + message string + }{ + "list fails": { + issueList: `[]`, + arrange: func(fake *fakeCoverageTrendGh) { fake.failures["issue list"] = errors.New("boom") }, + message: "list coverage trend issues: boom", + }, + "list is not JSON": { + issueList: `not json`, + arrange: func(*fakeCoverageTrendGh) {}, + message: "parse coverage trend issue list", + }, + "label create fails": { + issueList: `[]`, + arrange: func(fake *fakeCoverageTrendGh) { fake.failures["label create"] = errors.New("boom") }, + message: "create coverage-trend label: boom", + }, + "issue create fails": { + issueList: `[]`, + arrange: func(fake *fakeCoverageTrendGh) { fake.failures["issue create"] = errors.New("boom") }, + message: "create coverage trend issue: boom", + }, + "issue create prints no URL": { + issueList: `[]`, + arrange: func(fake *fakeCoverageTrendGh) { fake.responses["issue create"] = "created\n" }, + message: `read the issue number from the created issue URL "created"`, + }, + "comment fails": { + issueList: `[{"number":41}]`, + arrange: func(fake *fakeCoverageTrendGh) { fake.failures["issue comment"] = errors.New("boom") }, + message: "comment on coverage trend issue #41: boom", + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + fake := newFakeCoverageTrendGh(t, testCase.issueList) + testCase.arrange(fake) + + code, _, stderr := runPublishCoverageTrendForTest(t, fake, coverageTrendTestOptions(t)) + + if code == 0 || !strings.Contains(stderr, testCase.message) { + t.Fatalf("expected a failure with %q, got %d: %s", testCase.message, code, stderr) + } + }) + } +} + +func TestPublishCoverageTrendRejectsAnUnreadableOrEmptyBodyBeforeCallingGh(t *testing.T) { + // Verifies a missing or blank body file fails before any gh call, so no empty comment or + // stray issue is created. + cases := map[string]struct { + bodyFile func(t *testing.T) string + message string + }{ + "missing": { + bodyFile: func(t *testing.T) string { return filepath.Join(t.TempDir(), "absent.md") }, + message: "read body file", + }, + "blank": { + bodyFile: func(t *testing.T) string { return writeCoverageTrendTestBody(t, " \n") }, + message: "body file is empty", + }, + } + for name, testCase := range cases { + t.Run(name, func(t *testing.T) { + fake := newFakeCoverageTrendGh(t, `[{"number":41}]`) + options := coverageTrendTestOptions(t) + options.BodyFile = testCase.bodyFile(t) + + code, _, stderr := runPublishCoverageTrendForTest(t, fake, options) + + if code == 0 || !strings.Contains(stderr, testCase.message) || len(fake.calls) != 0 { + t.Fatalf("expected %q with no gh calls, got %d (%q): %s", testCase.message, code, fake.joinedCalls(), stderr) + } + }) + } +} + +func TestParsePublishCoverageTrendOptionsResolvesTheRepository(t *testing.T) { + // Verifies --repo wins over GITHUB_REPOSITORY, the variable fills in when --repo is absent, and + // a missing repository or body file is rejected with its own message. + environment := func(value string) func(string) (string, bool) { + return func(name string) (string, bool) { + if name == "GITHUB_REPOSITORY" && value != "" { + return value, true + } + return "", false + } + } + + options, err := parsePublishCoverageTrendOptions([]string{"--repo", "flag-owner/repo", "--body-file", "b.md", "--run-url", "u"}, environment("env-owner/repo")) + if err != nil || options != (publishCoverageTrendOptions{Repository: "flag-owner/repo", BodyFile: "b.md", RunURL: "u"}) { + t.Fatalf("expected the flag repository, got %+v, %v", options, err) + } + options, err = parsePublishCoverageTrendOptions([]string{"--body-file", "b.md"}, environment("env-owner/repo")) + if err != nil || options.Repository != "env-owner/repo" { + t.Fatalf("expected the environment repository, got %+v, %v", options, err) + } + if _, err := parsePublishCoverageTrendOptions([]string{"--body-file", "b.md"}, environment("")); err == nil || err.Error() != "--repo or GITHUB_REPOSITORY is required" { + t.Fatalf("expected a missing repository error, got %v", err) + } + if _, err := parsePublishCoverageTrendOptions([]string{"--repo", "o/r"}, environment("")); err == nil || err.Error() != "--body-file is required" { + t.Fatalf("expected a missing body file error, got %v", err) + } +} + +func TestRunPublishCoverageTrendRejectsAnUnknownFlag(t *testing.T) { + // Verifies the exported entry point stops at flag parsing for an unknown flag, before any gh call. + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunPublishCoverageTrend(context.Background(), &stdout, &stderr, []string{"--bogus"}) + + if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined: -bogus") { + t.Fatalf("expected an unknown flag error, got %d: %s", code, stderr.String()) + } +} diff --git a/coverage-baseline.json b/coverage-baseline.json index a3da00f041..e427502249 100644 --- a/coverage-baseline.json +++ b/coverage-baseline.json @@ -7,5 +7,20 @@ "project-runner": 84.3, "release-automation": 77.0 } + }, + "csharp": { + "exclude": [ + "UnityCLILoop.Runtime", + "UnityCLILoop.FirstPartyTools.Common.InputRecording.Editor", + "UnityCLILoop.FirstPartyTools.Common.InputSystem.Editor", + "UnityCLILoop.FirstPartyTools.SimulateKeyboard.Editor", + "UnityCLILoop.FirstPartyTools.SimulateMouseInput.Editor", + "UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor", + "UnityCLILoop.FirstPartyTools.Common.MouseUi.Editor", + "UnityCLILoop.FirstPartyTools.RecordInput.Editor", + "UnityCLILoop.FirstPartyTools.ReplayInput.Editor", + "UnityCLILoop.FirstPartyTools.Common.Overlay.Editor" + ], + "lineCoverage": 78.4 } } diff --git a/docs/coverage.md b/docs/coverage.md index b956dc4dbc..a95aef4c5d 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -1,6 +1,7 @@ # Test Coverage -The repository tracks statement coverage of the Go CLI modules and keeps it from falling. +The repository tracks statement coverage of the Go CLI modules and keeps it from falling, and +records C# line coverage of the Unity package every night. ## What is measured @@ -43,3 +44,62 @@ go run ./cmd/coverage-report --baseline ../../coverage-baseline.json --mode repo Use a fresh directory each time: the report reads every `.out` in it, so a profile left from an earlier run would be compared too. `GO_COVERAGE_DIR` must be absolute, because each module's tests run from that module's directory. Figures measured on macOS or Windows can differ slightly from the Linux gate. + +## C# coverage + +C# line coverage is measured only in the nightly `unity-editmode-tests.yml` run, in the Unity +2022.3 leg, which runs the full EditMode suite. The other legs run the hot-reload suites alone, +and PlayMode tests are not measured. The Unity Code Coverage package keeps the shipped +`UnityCLILoop.*` assemblies and drops tests, dev tooling, and samples. + +### The measured scope + +The figure compared with `csharp.lineCoverage` in `coverage-baseline.json` is the covered lines +divided by the coverable lines of every assembly except those listed under `csharp.exclude`, +truncated to one decimal. The excluded assemblies run only in Play Mode: the runtime assembly, +input recording and replay, keyboard and mouse simulation, and the Game View overlay. Their tests +live in the PlayMode suite, so the EditMode leg cannot measure them. They still appear in the +report, in a separate table, so their trend stays visible. + +`UnityCLILoop.Presentation` (the Editor UI) and the video recording tool stay in the scope, +because both have EditMode tests. View classes count toward the figure even though views are not +unit tested; the figure is for following the trend, not a target for views. + +An exclude entry that matches no assembly in the report fails the run, so a misspelled name +cannot leave the assembly it meant to drop inside the scope. + +### The trend issue and the baseline + +The `coverage-trend` job posts a comment every night on the open issue labelled +`coverage-trend`: the Go table, then the C# measured scope against its baseline, the scoped +assemblies sorted by uncovered lines, and the excluded ones. The job creates the label and the +issue when none is open, and fails when several are open; close all but one. When the 2022.3 leg +produced no coverage (no Unity license, or the leg failed before its tests ran), the comment +carries Go alone and says so. + +Only scheduled runs post by default. A manual `workflow_dispatch` run, for example on a branch +that adds C# tests, shows the same Go table and C# section in its run summary but leaves the +issue alone, so branch figures do not mix into the trend. Pass `-f post-trend=true` to +`gh workflow run unity-editmode-tests.yml` to post a dispatched run as well. + +C# coverage never gates. When the scope falls more than 0.1 point below `csharp.lineCoverage`, +the nightly run prints a warning and the comment marks it `below baseline`; pull requests do not +measure C# at all, and a broken `csharp` section does not affect the pull request Go gate. + +### The HTML report + +Each nightly run uploads the ReportGenerator output as the `csharp-coverage` artifact, kept for +14 days. Open `index.htm` at the artifact root for per-class and per-line detail. + +```sh +gh run download -n csharp-coverage -D +``` + +To render the report Markdown locally from a downloaded `Summary.json`: + +```sh +cd cli/release-automation +go run ./cmd/coverage-report --baseline ../../coverage-baseline.json --mode report \ + --go-coverage-dir "$GO_COVERAGE_DIR" --csharp-summary /Summary.json \ + --markdown-out +``` From 9c4bcddd2bf48c487b9de05f374b1195c76e2785 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 06:04:24 +0900 Subject: [PATCH 22/68] chore: Coverage baselines now hold the raised Go test coverage (#3095) --- coverage-baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coverage-baseline.json b/coverage-baseline.json index e427502249..c5e13357a6 100644 --- a/coverage-baseline.json +++ b/coverage-baseline.json @@ -2,10 +2,10 @@ "go": { "exclude": ["/cmd/", "/common/clitest"], "modules": { - "common": 75.0, - "dispatcher": 79.6, - "project-runner": 84.3, - "release-automation": 77.0 + "common": 94.8, + "dispatcher": 94.2, + "project-runner": 95.2, + "release-automation": 96.4 } }, "csharp": { From 7c7c60aa4d388fba6c0e415dac2982aaece2cf6b Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 07:02:33 +0900 Subject: [PATCH 23/68] fix: Tool migration no longer edits screenshot capture calls whose parentheses are not closed (#3098) --- ...ationUnclosedCaptureDeconstructionTests.cs | 56 +++++++++++++++++++ ...UnclosedCaptureDeconstructionTests.cs.meta | 11 ++++ ...lMigrationScreenshotDeconstructionRules.cs | 4 +- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs.meta diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs b/Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs new file mode 100644 index 0000000000..ff5b584699 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs @@ -0,0 +1,56 @@ +using System; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies screenshot deconstruction migration leaves a CaptureGameRenderingAsync call alone + /// when its argument list is never closed, because the rewrite cannot tell what the call returns. + /// + public sealed class ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests + { + private const string UnclosedDeconstruction = + "(Texture2D texture, int yOffset) = await " + + "io.github.hatayama.UnityCliLoop.FirstPartyTools.EditorWindowCaptureUtility" + + ".CaptureGameRenderingAsync(1.0f, timeout, ct"; + + /// + /// Verifies that an unclosed CaptureGameRenderingAsync deconstruction is not reported as needing migration. + /// + [Test] + public void ContainsCurrentCaptureGameRenderingDeconstructionMigration_WhenCallIsUnclosed_ReturnsFalse() + { + bool result = ThirdPartyToolMigrationScreenshotDeconstructionRules + .ContainsCurrentCaptureGameRenderingDeconstructionMigration( + UnclosedDeconstruction, + canUseBareCurrentFirstPartyTools: false, + currentFirstPartyToolsNamespaceAliases: Array.Empty(), + assemblyDeclaredTypeNames: Array.Empty()); + + Assert.That(result, Is.False); + } + + /// + /// Verifies that an unclosed CaptureGameRenderingAsync deconstruction gets no discard and is not counted. + /// + [Test] + public void AddDiscardToCaptureGameRenderingDeconstructionsInCode_WhenCallIsUnclosed_LeavesSourceUnchanged() + { + int replacementCount = 0; + + string content = ThirdPartyToolMigrationScreenshotDeconstructionRules + .AddDiscardToCaptureGameRenderingDeconstructionsInCode( + UnclosedDeconstruction, + canUseBareCurrentFirstPartyTools: false, + currentFirstPartyToolsNamespaceAliases: Array.Empty(), + assemblyDeclaredTypeNames: Array.Empty(), + ref replacementCount); + + Assert.That(content, Is.EqualTo(UnclosedDeconstruction)); + Assert.That(replacementCount, Is.EqualTo(0)); + } + } +} diff --git a/Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs.meta b/Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs.meta new file mode 100644 index 0000000000..5344044398 --- /dev/null +++ b/Assets/Tests/Editor/ThirdPartyToolMigrationUnclosedCaptureDeconstructionTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ffb2f852d52b481ea0b6c6dc6fc9bc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/Domain/ThirdPartyToolMigrationScreenshotDeconstructionRules.cs b/Packages/src/Editor/Domain/ThirdPartyToolMigrationScreenshotDeconstructionRules.cs index a03ffb5f6e..f43ed2a23d 100644 --- a/Packages/src/Editor/Domain/ThirdPartyToolMigrationScreenshotDeconstructionRules.cs +++ b/Packages/src/Editor/Domain/ThirdPartyToolMigrationScreenshotDeconstructionRules.cs @@ -102,7 +102,7 @@ public static string AddDiscardToCaptureGameRenderingDeconstructionsInCode( source, codeTextMask, openParenthesisIndex); - if (closingParenthesisIndex >= 0 && + if (closingParenthesisIndex < 0 || IsCaptureGameRenderingLegacyTupleProjection(source, closingParenthesisIndex + 1)) { continue; @@ -171,7 +171,7 @@ public static bool ContainsCurrentCaptureGameRenderingDeconstructionMigration( source, codeTextMask, openParenthesisIndex); - if (closingParenthesisIndex >= 0 && + if (closingParenthesisIndex < 0 || IsCaptureGameRenderingLegacyTupleProjection(source, closingParenthesisIndex + 1)) { continue; From 6f2fccdbf9fb71d6ebce94f2b6ca3dc8c03ff6d8 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sat, 3 Oct 2026 07:45:17 +0900 Subject: [PATCH 24/68] chore: Cover presentation-layer setup wizard and Settings presenters (#3099) --- ...etupWizardStartupFlowVersionChangeTests.cs | 448 +++++++++++++++ ...izardStartupFlowVersionChangeTests.cs.meta | 11 + .../SetupWizardWorkflowControllersTests.cs | 477 ++++++++++++++++ ...etupWizardWorkflowControllersTests.cs.meta | 11 + ...tyToolMigrationWizardWorkflowStateTests.cs | 520 ++++++++++++++++++ ...lMigrationWizardWorkflowStateTests.cs.meta | 11 + ...iLoopSettingsCliSetupPresenterFlowTests.cs | 399 ++++++++++++++ ...SettingsCliSetupPresenterFlowTests.cs.meta | 11 + .../UnityCliLoopSettingsModelStateTests.cs | 187 +++++++ ...nityCliLoopSettingsModelStateTests.cs.meta | 11 + ...liLoopSettingsSkillsPresenterStateTests.cs | 412 ++++++++++++++ ...pSettingsSkillsPresenterStateTests.cs.meta | 11 + ...iLoopSettingsToolSettingsPresenterTests.cs | 266 +++++++++ ...SettingsToolSettingsPresenterTests.cs.meta | 11 + 14 files changed, 2786 insertions(+) create mode 100644 Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs create mode 100644 Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs.meta create mode 100644 Assets/Tests/Editor/SetupWizardWorkflowControllersTests.cs create mode 100644 Assets/Tests/Editor/SetupWizardWorkflowControllersTests.cs.meta create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowStateTests.cs create mode 100644 Assets/Tests/Editor/ThirdPartyToolMigrationWizardWorkflowStateTests.cs.meta create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsCliSetupPresenterFlowTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsCliSetupPresenterFlowTests.cs.meta create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsModelStateTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsModelStateTests.cs.meta create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsSkillsPresenterStateTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsSkillsPresenterStateTests.cs.meta create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsToolSettingsPresenterTests.cs create mode 100644 Assets/Tests/Editor/UnityCliLoopSettingsToolSettingsPresenterTests.cs.meta diff --git a/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs b/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs new file mode 100644 index 0000000000..3587e6e27d --- /dev/null +++ b/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Presentation; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the setup wizard's version-change evaluation for already-seen package versions: + /// when it records the last-seen state, when it refreshes the CLI, and when it does nothing. + /// + public sealed class SetupWizardStartupFlowVersionChangeTests + { + private const string MinimumDispatcherVersion = "3.0.0"; + + private RecordingEditorSettingsPort _editorSettingsPort; + private StubProjectSettingsPort _projectSettingsPort; + private StubCliInstallationDetector _cliDetector; + private int _showWindowCount; + private SetupWizardStartupFlow _flow; + + [SetUp] + public void SetUp() + { + _editorSettingsPort = new RecordingEditorSettingsPort(); + _projectSettingsPort = new StubProjectSettingsPort(); + _cliDetector = new StubCliInstallationDetector(); + _showWindowCount = 0; + CliSetupApplicationService cliSetupApplicationService = new CliSetupApplicationService( + _cliDetector, + new UnusedNativeCliInstaller(), + new StubCliPinReader()); + _flow = new SetupWizardStartupFlow( + _editorSettingsPort, + _projectSettingsPort, + new UnusedSessionFlagsRepository(), + new UnusedAutoScanSeedRepository(), + cliSetupApplicationService, + new SkillSetupUseCase(new UnusedSkillSetupPort()), + new ThirdPartyToolMigrationUseCase(new UnusedMigrationPort()), + () => _showWindowCount++, + () => throw new InvalidOperationException("the migration auto-scan must not open")); + } + + /// + /// Verifies a project-level suppression records the current versions as seen without checking the CLI, so + /// the wizard stays quiet for this release. + /// + [Test] + public void TryShowOnVersionChange_WhenTheProjectSuppressesAutoShow_RecordsTheCurrentState() + { + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = "3.0.0", + lastSeenSetupWizardMinimumDispatcherVersion = "2.0.0" + }; + _projectSettingsPort.SuppressAutoShow = true; + + _flow.TryShowOnVersionChange(); + + Assert.That(_editorSettingsPort.UpdateCount, Is.EqualTo(1)); + Assert.That( + _editorSettingsPort.Settings.lastSeenSetupWizardVersion, + Is.EqualTo(UnityCliLoopConstants.PackageInfo.version)); + Assert.That( + _editorSettingsPort.Settings.lastSeenSetupWizardMinimumDispatcherVersion, + Is.EqualTo(MinimumDispatcherVersion)); + Assert.That(_cliDetector.ForceRefreshCount, Is.EqualTo(0)); + } + + /// + /// Verifies the personal suppression flag alone is enough to record the current state. + /// + [Test] + public void TryShowOnVersionChange_WhenTheUserSuppressesAutoShow_RecordsTheCurrentState() + { + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = "3.0.0", + lastSeenSetupWizardMinimumDispatcherVersion = "2.0.0", + suppressSetupWizardAutoShow = true + }; + + _flow.TryShowOnVersionChange(); + + Assert.That(_editorSettingsPort.UpdateCount, Is.EqualTo(1)); + Assert.That( + _editorSettingsPort.Settings.lastSeenSetupWizardMinimumDispatcherVersion, + Is.EqualTo(MinimumDispatcherVersion)); + Assert.That(_cliDetector.ForceRefreshCount, Is.EqualTo(0)); + } + + /// + /// Verifies an unchanged package and dispatcher minimum neither record state nor refresh the CLI. + /// + [Test] + public void TryShowOnVersionChange_WhenNothingChanged_LeavesSettingsAndCliAlone() + { + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = UnityCliLoopConstants.PackageInfo.version, + lastSeenSetupWizardMinimumDispatcherVersion = MinimumDispatcherVersion + }; + + _flow.TryShowOnVersionChange(); + + Assert.That(_editorSettingsPort.UpdateCount, Is.EqualTo(0)); + Assert.That(_cliDetector.ForceRefreshCount, Is.EqualTo(0)); + Assert.That(_showWindowCount, Is.EqualTo(0)); + } + + /// + /// Verifies a raised dispatcher minimum re-checks the CLI and, when the installed dispatcher already + /// satisfies it, records the new minimum instead of showing the wizard. + /// + [Test] + public void TryShowOnVersionChange_WhenOnlyTheMinimumChangedAndTheCliIsCurrent_RecordsWithoutShowing() + { + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = UnityCliLoopConstants.PackageInfo.version, + lastSeenSetupWizardMinimumDispatcherVersion = "2.0.0" + }; + _cliDetector.CliVersion = "3.1.0"; + _cliDetector.IsDispatcher = true; + + _flow.TryShowOnVersionChange(); + + Assert.That(_cliDetector.ForceRefreshCount, Is.EqualTo(1)); + Assert.That(_editorSettingsPort.UpdateCount, Is.EqualTo(1)); + Assert.That( + _editorSettingsPort.Settings.lastSeenSetupWizardMinimumDispatcherVersion, + Is.EqualTo(MinimumDispatcherVersion)); + } + + /// + /// Verifies a missing CLI does not count as needing an update, so the new minimum is recorded. + /// + [Test] + public void TryShowOnVersionChange_WhenOnlyTheMinimumChangedAndNoCliIsInstalled_RecordsWithoutShowing() + { + _editorSettingsPort.Settings = new UnityCliLoopEditorSettingsData + { + lastSeenSetupWizardVersion = UnityCliLoopConstants.PackageInfo.version, + lastSeenSetupWizardMinimumDispatcherVersion = "2.0.0" + }; + _cliDetector.CliVersion = string.Empty; + + _flow.TryShowOnVersionChange(); + + Assert.That(_cliDetector.ForceRefreshCount, Is.EqualTo(1)); + Assert.That(_editorSettingsPort.UpdateCount, Is.EqualTo(1)); + } + + private sealed class RecordingEditorSettingsPort : IUnityCliLoopEditorSettingsPort + { + internal UnityCliLoopEditorSettingsData Settings { get; set; } = new UnityCliLoopEditorSettingsData(); + internal int UpdateCount { get; private set; } + + public UnityCliLoopEditorSettingsData GetSettings() + { + return Settings; + } + + public void UpdateSettings(Func transform) + { + UpdateCount++; + Settings = transform(Settings); + } + + public void RecoverSettingsFileIfNeeded() + { + throw new NotSupportedException(); + } + + public void SaveSettings(UnityCliLoopEditorSettingsData settings) + { + throw new NotSupportedException(); + } + + public string GetLastSeenSetupWizardVersion() + { + throw new NotSupportedException(); + } + + public bool GetSuppressSetupWizardAutoShow() + { + throw new NotSupportedException(); + } + + public void SetSuppressSetupWizardAutoShow(bool suppressAutoShow) + { + throw new NotSupportedException(); + } + + public void SetShowToolSettings(bool showToolSettings) + { + throw new NotSupportedException(); + } + + public void SetInstallSkillsFlat(bool installSkillsFlat) + { + throw new NotSupportedException(); + } + } + + private sealed class StubProjectSettingsPort : IUnityCliLoopProjectSettingsPort + { + internal bool SuppressAutoShow { get; set; } + + public bool GetSuppressSetupWizardAutoShow() + { + return SuppressAutoShow; + } + + public void SetSuppressSetupWizardAutoShow(bool suppressAutoShow) + { + throw new NotSupportedException(); + } + } + + private sealed class StubCliInstallationDetector : ICliInstallationDetector + { + internal string CliVersion { get; set; } = string.Empty; + internal bool IsDispatcher { get; set; } + internal int ForceRefreshCount { get; private set; } + + public string GetCachedCliVersion() + { + return CliVersion; + } + + public bool GetCachedCliIsDispatcher() + { + return IsDispatcher; + } + + public Task ForceRefreshCliVersionAsync(CancellationToken ct) + { + ForceRefreshCount++; + return Task.CompletedTask; + } + + public bool IsCliInstalled() + { + throw new NotSupportedException(); + } + + public string GetCachedCliExecutablePath() + { + throw new NotSupportedException(); + } + + public bool IsCheckCompleted() + { + throw new NotSupportedException(); + } + + public Task RefreshCliVersionAsync(CancellationToken ct) + { + throw new NotSupportedException(); + } + + public Task IsCliVisibleFromShellAsync(RuntimePlatform platform, CancellationToken ct) + { + throw new NotSupportedException(); + } + + public void InvalidateCache() + { + throw new NotSupportedException(); + } + } + + private sealed class StubCliPinReader : ICliPinReader + { + public string LoadMinimumDispatcherVersionOrThrow() + { + return MinimumDispatcherVersion; + } + + public CliPinLoadResult LoadPackagePin() + { + throw new NotSupportedException(); + } + + public DispatcherBootstrapPinLoadResult LoadDispatcherBootstrapPin() + { + throw new NotSupportedException(); + } + } + + private sealed class UnusedNativeCliInstaller : INativeCliInstaller + { + public bool IsPackageOwnedCurrentUserInstallPath(string cliExecutablePath, RuntimePlatform platform) + { + throw new NotSupportedException(); + } + + public ManagedCliKind ResolveManagedCliKind(string cliExecutablePath) + { + throw new NotSupportedException(); + } + + public bool HasPackageOwnedCurrentUserInstall(RuntimePlatform platform) + { + throw new NotSupportedException(); + } + + public Task InstallGlobalCliAsync( + RuntimePlatform platform, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, + IProgress installProgress, + CancellationToken ct) + { + throw new NotSupportedException(); + } + + public Task UninstallGlobalCliAsync(RuntimePlatform platform, CancellationToken ct) + { + throw new NotSupportedException(); + } + + public Task GetGlobalCliPathSetupPlanAsync(RuntimePlatform platform, CancellationToken ct) + { + throw new NotSupportedException(); + } + + public CliPathSetupApplyResult ApplyGlobalCliPathSetup(CliPathSetupPlan plan) + { + throw new NotSupportedException(); + } + + public NativeCliInstallCommandLoadResult GetGlobalCliInstallCommand( + RuntimePlatform platform, + string dispatcherReleaseTag, + string dispatcherArchiveManifest, + bool removeLegacyLaunchers) + { + throw new NotSupportedException(); + } + } + + private sealed class UnusedSessionFlagsRepository : ISessionFlagsRepository + { + public bool GetIsServerRunning() => throw new NotSupportedException(); + public bool GetIsServerManuallyStopped() => throw new NotSupportedException(); + public bool GetIsAfterCompile() => throw new NotSupportedException(); + public bool GetIsDomainReloadInProgress() => throw new NotSupportedException(); + public bool GetShowReconnectingUI() => throw new NotSupportedException(); + public void SetIsAfterCompile(bool isAfterCompile) => throw new NotSupportedException(); + public void SetIsDomainReloadInProgress(bool isDomainReloadInProgress) => throw new NotSupportedException(); + public void SetIsReconnecting(bool isReconnecting) => throw new NotSupportedException(); + public void SetShowReconnectingUI(bool showReconnectingUI) => throw new NotSupportedException(); + public void SetShowPostCompileReconnectingUI(bool showPostCompileReconnectingUI) => throw new NotSupportedException(); + public void SetShouldAutoScanThirdPartyToolMigration(bool shouldAutoScanThirdPartyToolMigration) => throw new NotSupportedException(); + public bool ConsumeShouldAutoScanThirdPartyToolMigration() => throw new NotSupportedException(); + public void MarkServerStarted() => throw new NotSupportedException(); + public void MarkServerManuallyStopped() => throw new NotSupportedException(); + public void ClearServerSession() => throw new NotSupportedException(); + public void ClearAfterCompileFlag() => throw new NotSupportedException(); + public void ClearReconnectingFlags() => throw new NotSupportedException(); + public void ClearPostCompileReconnectingUI() => throw new NotSupportedException(); + public void ClearDomainReloadFlag() => throw new NotSupportedException(); + public void ClearDomainReloadRecoveryFlags() => throw new NotSupportedException(); + } + + private sealed class UnusedAutoScanSeedRepository : IThirdPartyToolMigrationAutoScanSeedRepository + { + public void StoreSeedFilePaths(string[] filePaths) => throw new NotSupportedException(); + public string[] GetSeedFilePaths() => throw new NotSupportedException(); + public void ClearSeedFilePaths() => throw new NotSupportedException(); + } + + private sealed class UnusedSkillSetupPort : ISkillSetupPort + { + public void RemoveSkillFiles(string toolName) => throw new NotSupportedException(); + public bool IsSkillInstalled(string toolName) => throw new NotSupportedException(); + + public List DetectSkillTargetsForLayoutAtProjectRoot( + string projectRoot, + bool groupSkillsUnderUnityCliLoop) => throw new NotSupportedException(); + + public List DetectSkillTargetsForLayoutFastAtProjectRoot( + string projectRoot, + bool groupSkillsUnderUnityCliLoop) => throw new NotSupportedException(); + + public Task InstallSkillFilesAsync( + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) => throw new NotSupportedException(); + + public Task InstallSkillFilesForToolAsync( + string toolName, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) => throw new NotSupportedException(); + + public SkillInstallState GetV3MigrationSkillInstallStateAtProjectRoot( + string projectRoot, + SkillSetupTargetInfo target, + bool groupSkillsUnderUnityCliLoop) => throw new NotSupportedException(); + + public Task InstallV3MigrationSkillFilesAsync( + string projectRoot, + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) => throw new NotSupportedException(); + + public Task RemoveV3MigrationSkillFilesAsync( + string projectRoot, + List targets, + bool groupSkillsUnderUnityCliLoop, + CancellationToken ct) => throw new NotSupportedException(); + } + + private sealed class UnusedMigrationPort : IThirdPartyToolMigrationPort + { + public ThirdPartyToolMigrationPreview PreviewMigration(string projectRoot) => throw new NotSupportedException(); + + public Task PreviewMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) => throw new NotSupportedException(); + + public (bool Found, List TargetFilePaths) TryDetectAutoScanTargetsFromCompileErrors( + string projectRoot) => throw new NotSupportedException(); + + public Task HasMigrationTargetsAsync(string projectRoot, CancellationToken ct) => + throw new NotSupportedException(); + + public ThirdPartyToolMigrationResult ApplyMigration(string projectRoot) => throw new NotSupportedException(); + + public Task ApplyMigrationAsync( + string projectRoot, + IProgress progress, + CancellationToken ct) => throw new NotSupportedException(); + } + } +} diff --git a/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs.meta b/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs.meta new file mode 100644 index 0000000000..81d69ab061 --- /dev/null +++ b/Assets/Tests/Editor/SetupWizardStartupFlowVersionChangeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3502897756adc4d76b01ad62610cd075 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SetupWizardWorkflowControllersTests.cs b/Assets/Tests/Editor/SetupWizardWorkflowControllersTests.cs new file mode 100644 index 0000000000..c80d204ec1 --- /dev/null +++ b/Assets/Tests/Editor/SetupWizardWorkflowControllersTests.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using NUnit.Framework; +using UnityEngine; +using UnityEngine.UIElements; + +using io.github.hatayama.UnityCliLoop.Application; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.Presentation; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the setup wizard's CLI-step refresh, skills-step synchronous paths, and the initial + /// checking state, using named stand-in elements and recording ports instead of the real window. + /// + public sealed class SetupWizardWorkflowControllersTests + { + private const string MinimumDispatcherVersion = "3.0.0"; + + private VisualElement _root; + private RecordingEditorSettingsPort _editorSettingsPort; + private StubCliInstallationDetector _cliDetector; + private StubNativeCliInstaller _nativeCliInstaller; + private RecordingSkillSetupPort _skillPort; + private CliSetupApplicationService _cliSetupApplicationService; + private int _resizeCount; + private List _refreshUiCalls; + + [SetUp] + public void SetUp() + { + _root = CreateRootElement(); + _editorSettingsPort = new RecordingEditorSettingsPort(); + _cliDetector = new StubCliInstallationDetector(); + _nativeCliInstaller = new StubNativeCliInstaller(); + _skillPort = new RecordingSkillSetupPort(); + _cliSetupApplicationService = new CliSetupApplicationService( + _cliDetector, + _nativeCliInstaller, + new StubCliPinReader()); + _resizeCount = 0; + _refreshUiCalls = new List(); + } + + /// + /// Verifies a refresh re-reads the CLI, skips the PATH check when the package does not own the install, + /// and renders the installed state. + /// + [Test] + public async Task CliRefreshAndUpdate_WithACurrentDispatcher_ReportsInstalledWithoutAShellCheck() + { + _cliDetector.CliVersion = "3.1.0"; + _cliDetector.IsDispatcher = true; + SetupWizardCliWorkflowController controller = CreateCliWorkflow(); + + bool installed = await controller.RefreshAndUpdateAsync(CancellationToken.None); + + Assert.That(installed, Is.True); + Assert.That(_cliDetector.ForceRefreshCount, Is.EqualTo(1)); + Assert.That(_cliDetector.ShellVisibilityChecks, Is.EqualTo(0)); + Assert.That(_root.Q