Files
seaweedfs/weed/storage/blockvol/testrunner/reporter_test.go
T
Ping QiuandClaude Opus 4.6 da1b81d1c9 feat: CP8-3-1 durability modes + testrunner platform + 21 adversarial tests
Durability mode implementation (sync_all, sync_quorum, best_effort):
- DurabilityMode type with superblock persistence, parse/validate/string
- MakeDistributedSync mode-aware barrier enforcement in dist_group_commit
- blockerr sentinel package (ErrDurabilityBarrierFailed, ErrDurabilityQuorumLost)
- gRPC create path: mode validation, idempotent create consistency, partial cleanup
- F1: strict mode rejects partial replica provisioning with cleanup
- F3: empty heartbeat does not overwrite persisted strict mode
- F4: SCSI error mapping uses errors.Is sentinels (not string matching)
- Proto/wire/blockapi/CLI/UI plumbing for durability_mode field
- Observability dashboard: cluster health cards + per-volume columns

Testrunner platform (YAML-driven integration test framework):
- Engine, parser, registry, reporter (JUnit XML + HTML), metrics scraping
- 52 registered actions: block, iSCSI, I/O, fault injection, assertions
- Baseline regression framework with 7 hard-fail conditions
- 15 YAML scenarios (smoke, crash, HA, fault, consistency, snapshot)
- 49 unit tests for testrunner internals

QA adversarial suite (21 tests, all PASS):
- Idempotent create mode/RF mismatch detection
- Heartbeat mode downgrade prevention (F3)
- sync_all/sync_quorum partial replica enforcement (F1)
- Concurrent create race safety
- Failover/expand mode preservation
- Cleanup resilience when delete fails
- Master restart auto-register mode handling
- Superblock roundtrip all 3 modes
- Validate edge cases (mode×RF matrix)
- RequiredReplicas quorum math verification
- Sentinel error categorization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:06:51 -08:00

369 lines
8.7 KiB
Go

package testrunner
import (
"bytes"
"encoding/json"
"encoding/xml"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPrintSummary(t *testing.T) {
result := &ScenarioResult{
Name: "test-scenario",
Status: StatusPass,
Duration: 1500 * time.Millisecond,
Phases: []PhaseResult{
{
Name: "setup",
Status: StatusPass,
Duration: 800 * time.Millisecond,
Actions: []ActionResult{
{Action: "build_deploy", Status: StatusPass, Duration: 500 * time.Millisecond},
{Action: "start_target", Status: StatusPass, Duration: 300 * time.Millisecond},
},
},
{
Name: "verify",
Status: StatusPass,
Duration: 700 * time.Millisecond,
Actions: []ActionResult{
{Action: "assert_equal", Status: StatusPass, Duration: 1 * time.Millisecond},
},
},
},
}
var buf bytes.Buffer
PrintSummary(&buf, result)
output := buf.String()
if !strings.Contains(output, "test-scenario") {
t.Error("summary should contain scenario name")
}
if !strings.Contains(output, "PASS") {
t.Error("summary should contain PASS")
}
if !strings.Contains(output, "3 actions") {
t.Error("summary should contain action count")
}
}
func TestPrintSummary_WithFailure(t *testing.T) {
result := &ScenarioResult{
Name: "fail-scenario",
Status: StatusFail,
Duration: 500 * time.Millisecond,
Error: "something broke",
Phases: []PhaseResult{
{
Name: "main",
Status: StatusFail,
Actions: []ActionResult{
{Action: "step1", Status: StatusFail, Error: "boom"},
},
},
},
}
var buf bytes.Buffer
PrintSummary(&buf, result)
output := buf.String()
if !strings.Contains(output, "FAIL") {
t.Error("summary should contain FAIL")
}
if !strings.Contains(output, "boom") {
t.Error("summary should contain error message")
}
}
func TestWriteJSON(t *testing.T) {
result := &ScenarioResult{
Name: "json-test",
Status: StatusPass,
Duration: 100 * time.Millisecond,
}
dir := t.TempDir()
path := filepath.Join(dir, "result.json")
if err := WriteJSON(result, path); err != nil {
t.Fatalf("WriteJSON: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
var loaded ScenarioResult
if err := json.Unmarshal(data, &loaded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if loaded.Name != "json-test" {
t.Errorf("name = %q", loaded.Name)
}
if loaded.Status != StatusPass {
t.Errorf("status = %s", loaded.Status)
}
}
func TestWriteJUnitXML(t *testing.T) {
result := &ScenarioResult{
Name: "junit-test",
Status: StatusFail,
Duration: 200 * time.Millisecond,
Phases: []PhaseResult{
{
Name: "main",
Status: StatusFail,
Actions: []ActionResult{
{Action: "ok_step", Status: StatusPass, Duration: 50 * time.Millisecond},
{Action: "fail_step", Status: StatusFail, Duration: 100 * time.Millisecond, Error: "oops"},
},
},
},
}
dir := t.TempDir()
path := filepath.Join(dir, "result.xml")
if err := WriteJUnitXML(result, path); err != nil {
t.Fatalf("WriteJUnitXML: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
var suites JUnitTestSuites
if err := xml.Unmarshal(data, &suites); err != nil {
t.Fatalf("unmarshal XML: %v", err)
}
if len(suites.Suites) != 1 {
t.Fatalf("suites = %d, want 1", len(suites.Suites))
}
suite := suites.Suites[0]
if suite.Tests != 2 {
t.Errorf("tests = %d, want 2", suite.Tests)
}
if suite.Failures != 1 {
t.Errorf("failures = %d, want 1", suite.Failures)
}
}
func TestPrintSummary_WithPerfTable(t *testing.T) {
result := &ScenarioResult{
Name: "perf-scenario",
Status: StatusPass,
Duration: 2 * time.Second,
Phases: []PhaseResult{
{
Name: "bench",
Status: StatusPass,
Actions: []ActionResult{
{Action: "dd_write", Status: StatusPass, Duration: 1 * time.Second},
},
},
},
Vars: map[string]string{
"iops": `{"count":100,"min":9000,"max":15000,"mean":12345,"stddev":500,"p50":12200,"p90":13500,"p99":14000}`,
},
}
var buf bytes.Buffer
PrintSummary(&buf, result)
output := buf.String()
if !strings.Contains(output, "Performance") {
t.Error("summary should contain Performance table header")
}
if !strings.Contains(output, "iops") {
t.Error("summary should contain metric name 'iops'")
}
if !strings.Contains(output, "12345") {
t.Error("summary should contain mean value")
}
}
func TestPrintSummary_WithMetricsTable(t *testing.T) {
result := &ScenarioResult{
Name: "metrics-scenario",
Status: StatusPass,
Duration: 1 * time.Second,
Phases: []PhaseResult{{Name: "m", Status: StatusPass}},
Vars: map[string]string{
"primary_metrics": `{"target":"primary","metrics":{"blockvol_write_bytes_total":1234567,"blockvol_read_bytes_total":987654}}`,
},
}
var buf bytes.Buffer
PrintSummary(&buf, result)
output := buf.String()
if !strings.Contains(output, "Metrics (primary)") {
t.Error("summary should contain Metrics section with target name")
}
if !strings.Contains(output, "blockvol_write_bytes_total") {
t.Error("summary should contain metric name")
}
}
func TestPrintSummary_WithPerfStatsLine(t *testing.T) {
result := &ScenarioResult{
Name: "perf-line-scenario",
Status: StatusPass,
Duration: 1 * time.Second,
Phases: []PhaseResult{{Name: "p", Status: StatusPass}},
Vars: map[string]string{
"lat_stats": "lat_us: n=50 mean=420.5 stddev=30.1 p50=415.0 p90=460.0 p99=500.0 min=350.0 max=550.0",
},
}
var buf bytes.Buffer
PrintSummary(&buf, result)
output := buf.String()
if !strings.Contains(output, "Performance") {
t.Error("summary should render perf table from FormatStats-style line")
}
if !strings.Contains(output, "lat_stats") {
t.Error("summary should contain metric name from var key")
}
}
func TestParsePerfStatsLine(t *testing.T) {
line := "iops: n=100 mean=12345.00 stddev=500.00 p50=12200.00 p90=13500.00 p99=14000.00 min=9000.00 max=15000.00"
ps, ok := parsePerfStatsLine(line)
if !ok {
t.Fatal("should parse FormatStats-style line")
}
if ps.Count != 100 {
t.Errorf("count = %d, want 100", ps.Count)
}
if ps.Mean != 12345 {
t.Errorf("mean = %f, want 12345", ps.Mean)
}
if ps.P99 != 14000 {
t.Errorf("p99 = %f, want 14000", ps.P99)
}
}
func TestParsePerfStatsLine_Invalid(t *testing.T) {
_, ok := parsePerfStatsLine("not a perf line")
if ok {
t.Error("should not parse non-perf text")
}
}
func TestFormatInt(t *testing.T) {
tests := []struct {
n int64
want string
}{
{0, "0"},
{999, "999"},
{1000, "1,000"},
{1234567, "1,234,567"},
}
for _, tt := range tests {
got := formatInt(tt.n)
if got != tt.want {
t.Errorf("formatInt(%d) = %q, want %q", tt.n, got, tt.want)
}
}
}
func TestWriteJSON_WithVars(t *testing.T) {
result := &ScenarioResult{
Name: "json-vars-test",
Status: StatusPass,
Vars: map[string]string{
"key1": "value1",
"key2": `{"nested":"json"}`,
},
Artifacts: []ArtifactEntry{
{Agent: "m01", Path: "/tmp/a/log.txt", Size: 512},
},
}
dir := t.TempDir()
path := filepath.Join(dir, "result.json")
if err := WriteJSON(result, path); err != nil {
t.Fatalf("WriteJSON: %v", err)
}
data, _ := os.ReadFile(path)
var loaded ScenarioResult
json.Unmarshal(data, &loaded)
if loaded.Vars["key1"] != "value1" {
t.Errorf("vars[key1] = %q", loaded.Vars["key1"])
}
if loaded.Vars["key2"] != `{"nested":"json"}` {
t.Errorf("vars[key2] = %q", loaded.Vars["key2"])
}
if len(loaded.Artifacts) != 1 {
t.Fatalf("artifacts = %d, want 1", len(loaded.Artifacts))
}
if loaded.Artifacts[0].Agent != "m01" {
t.Errorf("artifact agent = %q", loaded.Artifacts[0].Agent)
}
}
func TestBaselineCompare(t *testing.T) {
baseline := &ScenarioResult{
Name: "baseline",
Phases: []PhaseResult{
{
Name: "main",
Actions: []ActionResult{
{Action: "step1", Status: StatusPass},
{Action: "step2", Status: StatusPass},
{Action: "step3", Status: StatusFail},
},
},
},
}
dir := t.TempDir()
baselinePath := filepath.Join(dir, "baseline.json")
if err := WriteJSON(baseline, baselinePath); err != nil {
t.Fatalf("write baseline: %v", err)
}
current := &ScenarioResult{
Name: "current",
Phases: []PhaseResult{
{
Name: "main",
Actions: []ActionResult{
{Action: "step1", Status: StatusPass}, // still pass
{Action: "step2", Status: StatusFail, Error: "regression"}, // regression!
{Action: "step3", Status: StatusFail}, // was fail, still fail (not regression)
},
},
},
}
regressions, err := BaselineCompare(current, baselinePath)
if err != nil {
t.Fatalf("compare: %v", err)
}
if len(regressions) != 1 {
t.Fatalf("regressions = %d, want 1", len(regressions))
}
if !strings.Contains(regressions[0], "step2") {
t.Errorf("regression should mention step2: %s", regressions[0])
}
}