Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/smoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func run() error {
scenariosDir := flag.String("scenarios-dir", "./scenarios", "directory containing scenario YAML files")
reportDir := flag.String("report-dir", "./smoke-report", "directory to write report.html into")
filter := flag.String("filter", "", "only run scenarios whose name or service contains this string")
parallel := flag.Int("parallel", 4, "max parallel service groups (0 = sequential)")
verbose := flag.Bool("verbose", false, "print stdout/stderr for every scenario, not just failures")
flag.Parse()

Expand Down Expand Up @@ -67,7 +68,7 @@ func run() error {
fmt.Printf("Running %d scenario(s)...\n\n", len(scenarios))

// Execute all scenarios.
runner := smoke.NewRunner(*coraBin, expandedConfig, *verbose)
runner := smoke.NewRunner(*coraBin, expandedConfig, *verbose, *parallel)
report := runner.RunAll(scenarios, *configPath)

// Print live results.
Expand Down
102 changes: 94 additions & 8 deletions internal/smoke/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,28 @@ import (
"fmt"
"os"
"os/exec"
"sort"
"strings"
"sync"
"time"
)

// Runner executes scenarios by invoking the cora binary as a subprocess.
type Runner struct {
coraBin string // path to cora binary
configPath string // expanded config file path (may be empty)
verbose bool // pass --verbose to every cora invocation
coraBin string // path to cora binary
configPath string // expanded config file path (may be empty)
verbose bool // pass --verbose to every cora invocation
maxConcurrent int // max parallel service groups (0 = sequential)
}

// NewRunner creates a Runner. configPath may be "" to skip CORA_CONFIG injection.
func NewRunner(coraBin, configPath string, verbose bool) *Runner {
return &Runner{coraBin: coraBin, configPath: configPath, verbose: verbose}
// maxConcurrent controls parallel execution: 0 = sequential, >0 = run up to N
// service groups concurrently.
func NewRunner(coraBin, configPath string, verbose bool, maxConcurrent int) *Runner {
return &Runner{
coraBin: coraBin, configPath: configPath, verbose: verbose,
maxConcurrent: maxConcurrent,
}
}

// Run executes a single Scenario and returns its result.
Expand Down Expand Up @@ -103,16 +111,94 @@ func (r *Runner) Run(s Scenario) ScenarioResult {
return result
}

// RunAll executes all scenarios sequentially and returns a RunReport.
// RunAll executes all scenarios and returns a RunReport.
// When MaxConcurrent > 0, scenarios are grouped by service and service groups
// run in parallel (up to MaxConcurrent at a time). Scenarios within a group
// still run sequentially to avoid rate-limiting the same API.
func (r *Runner) RunAll(scenarios []Scenario, configPath string) *RunReport {
if r.maxConcurrent <= 0 {
return r.runSequential(scenarios, configPath)
}
return r.runParallel(scenarios, configPath)
}

// runSequential executes all scenarios one-by-one.
func (r *Runner) runSequential(scenarios []Scenario, configPath string) *RunReport {
report := &RunReport{
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
ConfigPath: configPath,
}
start := time.Now()
tStart := time.Now()
for _, s := range scenarios {
report.Results = append(report.Results, r.Run(s))
}
report.TotalDurationMs = time.Since(start).Milliseconds()
report.TotalDurationMs = time.Since(tStart).Milliseconds()
return report
}

// runParallel groups scenarios by service, then runs service groups in
// parallel (up to MaxConcurrent). Scenarios within each group run sequentially
// to avoid overwhelming a single API with concurrent requests.
func (r *Runner) runParallel(scenarios []Scenario, configPath string) *RunReport {
tStart := time.Now()
// Group by service, preserving order within each group.
groups := make(map[string][]Scenario)
var serviceOrder []string
for _, s := range scenarios {
if _, ok := groups[s.Service]; !ok {
serviceOrder = append(serviceOrder, s.Service)
}
groups[s.Service] = append(groups[s.Service], s)
}

// Assign each scenario an index for deterministic output ordering.
origIdx := make(map[string]int)
for i, s := range scenarios {
origIdx[s.Name] = i
}

type indexedResult struct {
idx int
result ScenarioResult
}

resultCh := make(chan indexedResult, len(scenarios))
sem := make(chan struct{}, r.maxConcurrent)
var wg sync.WaitGroup

for _, svc := range serviceOrder {
wg.Add(1)
go func(svcName string, svcScenarios []Scenario) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()

for _, s := range svcScenarios {
res := r.Run(s)
resultCh <- indexedResult{idx: origIdx[s.Name], result: res}
}
}(svc, groups[svc])
}
Comment on lines +144 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current parallel runner groups scenarios by service and then maps each scenario's name to its original index using the origIdx map. This assumes that scenario names are globally unique, which is not enforced by the loader. If there are duplicate scenario names, this lookup will map them to the same index, leading to non-deterministic or incorrect sorting of the final report.

We can avoid this assumption and eliminate the origIdx map lookup entirely by grouping scenarios using a helper struct that stores the original index alongside each scenario.

	type indexedScenario struct {
		idx int
		s   Scenario
	}
	groups := make(map[string][]indexedScenario)
	var serviceOrder []string
	for i, s := range scenarios {
		if _, ok := groups[s.Service]; !ok {
			serviceOrder = append(serviceOrder, s.Service)
		}
		groups[s.Service] = append(groups[s.Service], indexedScenario{idx: i, s: s})
	}

	type indexedResult struct {
		idx    int
		result ScenarioResult
	}

	resultCh := make(chan indexedResult, len(scenarios))
	sem := make(chan struct{}, r.maxConcurrent)
	var wg sync.WaitGroup

	for _, svc := range serviceOrder {
		wg.Add(1)
		go func(svcName string, svcScenarios []indexedScenario) {
			defer wg.Done()
			sem <- struct{}{}
			defer func() { <-sem }()

			for _, is := range svcScenarios {
				res := r.Run(is.s)
				resultCh <- indexedResult{idx: is.idx, result: res}
			}
		}(svc, groups[svc])
	}


go func() {
wg.Wait()
close(resultCh)
}()

// Collect and sort by original index.
var indexed []indexedResult
for ir := range resultCh {
indexed = append(indexed, ir)
}
sort.Slice(indexed, func(i, j int) bool { return indexed[i].idx < indexed[j].idx })

report := &RunReport{
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
ConfigPath: configPath,
}
for _, ir := range indexed {
report.Results = append(report.Results, ir.result)
}
report.TotalDurationMs = time.Since(tStart).Milliseconds()
return report
}
10 changes: 5 additions & 5 deletions internal/smoke/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func main() {

func TestRunner_SkipScenario(t *testing.T) {
s := Scenario{Name: "skip me", Skip: true, SkipReason: "not ready"}
r := NewRunner("", "", false)
r := NewRunner("", "", false, 0)
result := r.Run(s)
if result.Status != StatusSkip {
t.Errorf("expected SKIP, got %s", result.Status)
Expand All @@ -63,7 +63,7 @@ func TestRunner_SuccessCapture(t *testing.T) {
{Type: "stdout_contains", Str: "TITLE"},
},
}
r := NewRunner(bin, "", false)
r := NewRunner(bin, "", false, 0)
result := r.Run(s)
if result.Status != StatusPass {
t.Errorf("expected PASS, got %s: %s", result.Status, result.Err)
Expand All @@ -88,7 +88,7 @@ func TestRunner_NonZeroExit_AssertionFails(t *testing.T) {
{Type: "exit_code", Value: 0},
},
}
r := NewRunner(bin, "", false)
r := NewRunner(bin, "", false, 0)
result := r.Run(s)
if result.Status != StatusFail {
t.Errorf("expected FAIL, got %s", result.Status)
Expand All @@ -105,7 +105,7 @@ func TestRunner_StderrCaptured(t *testing.T) {
Format: "table", TimeoutMs: 5000,
Assertions: []Assertion{{Type: "exit_code", Value: 1}},
}
r := NewRunner(bin, "", false)
r := NewRunner(bin, "", false, 0)
result := r.Run(s)
if result.Stderr == "" {
t.Error("expected stderr to be captured")
Expand All @@ -121,7 +121,7 @@ func main() { time.Sleep(10 * time.Second) }`)
Format: "table", TimeoutMs: 200,
Assertions: []Assertion{{Type: "exit_code", Value: 0}},
}
r := NewRunner(bin, "", false)
r := NewRunner(bin, "", false, 0)
result := r.Run(s)
if result.Status != StatusTimeout {
t.Errorf("expected TIMEOUT, got %s", result.Status)
Expand Down
20 changes: 20 additions & 0 deletions scenarios/gitcode/branch-get.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: "gitcode/branch-get"
service: gitcode
args:
- branch
- get
- --owner
- openeuler
- --repo
- infrastructure
- --branch
- master
format: json
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: json_has_keys
values: ["name", "protected"]
- type: json_key_not_empty
key: "name"
17 changes: 17 additions & 0 deletions scenarios/gitcode/branch-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: "gitcode/branch-list"
service: gitcode
args:
- branch
- branches
- --owner
- openeuler
- --repo
- infrastructure
format: table
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: stdout_not_empty
- type: stderr_not_contains
value: "ERROR"
14 changes: 14 additions & 0 deletions scenarios/gitcode/comments-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: "gitcode/comments-list"
service: gitcode
args:
- comments
- list
- --owner
- openeuler
- --repo
- infrastructure
format: json
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
20 changes: 20 additions & 0 deletions scenarios/gitcode/commit-get.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: "gitcode/commit-get"
service: gitcode
args:
- commit
- get
- --owner
- openeuler
- --repo
- infrastructure
- --sha
- a63ae4a0b5c40dd12f70263e23c05bbbeb888fb6
format: json
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: json_has_keys
values: ["sha", "commit", "html_url"]
- type: json_key_not_empty
key: "sha"
17 changes: 17 additions & 0 deletions scenarios/gitcode/commit-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: "gitcode/commit-list"
service: gitcode
args:
- commit
- list
- --owner
- openeuler
- --repo
- infrastructure
format: table
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: stdout_not_empty
- type: stderr_not_contains
value: "ERROR"
17 changes: 17 additions & 0 deletions scenarios/gitcode/labels-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: "gitcode/labels-list"
service: gitcode
args:
- labels
- list
- --owner
- openeuler
- --repo
- infrastructure
format: table
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: stdout_not_empty
- type: stderr_not_contains
value: "ERROR"
19 changes: 19 additions & 0 deletions scenarios/gitcode/pulls-comments.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: "gitcode/pulls-comments"
service: gitcode
args:
- pulls
- comments
- --owner
- openeuler
- --repo
- infrastructure
- --number
- 747
format: table
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: stdout_not_empty
- type: stderr_not_contains
value: "ERROR"
14 changes: 14 additions & 0 deletions scenarios/gitcode/release-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: "gitcode/release-list"
service: gitcode
args:
- release
- list
- --owner
- openeuler
- --repo
- infrastructure
format: json
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
17 changes: 17 additions & 0 deletions scenarios/gitcode/tag-get.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: "gitcode/tag-get"
service: gitcode
args:
- tag
- list
Comment on lines +3 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The scenario file is named gitcode/tag-get but the arguments specify the list subcommand instead of get. This appears to be a copy-paste error from tag-list.yaml. Even though this scenario is currently skipped, it should be defined with the correct subcommand.

args:
  - tag
  - get

- --owner
- openeuler
- --repo
- infrastructure
format: table
timeout_ms: 8000
skip: true
skip_reason: "Protected tag API requires admin permissions"
assertions:
- type: exit_code
value: 0
- type: stdout_not_empty
17 changes: 17 additions & 0 deletions scenarios/gitcode/tag-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: "gitcode/tag-list"
service: gitcode
args:
- tag
- list
- --owner
- openeuler
- --repo
- infrastructure
format: table
timeout_ms: 8000
assertions:
- type: exit_code
value: 0
- type: stdout_not_empty
- type: stderr_not_contains
value: "ERROR"
Loading