From fcf58b0b4b73298004c79164a6438b0e86b0c44d Mon Sep 17 00:00:00 2001 From: naiba Date: Mon, 20 Jul 2026 04:39:48 +0000 Subject: [PATCH] test(agentcompat): define integration contracts Co-authored-by: naiba/CloudCode --- .../agentcompat/internal/contract/budget.go | 51 +++++ .../internal/contract/contract_test.go | 105 +++++++++++ .../agentcompat/internal/contract/profile.go | 96 ++++++++++ .../internal/contract/scenario_registry.go | 175 ++++++++++++++++++ .../contract/scenario_registry_test.go | 83 +++++++++ .../agentcompat/internal/contract/values.go | 73 ++++++++ 6 files changed, 583 insertions(+) create mode 100644 integration/agentcompat/internal/contract/budget.go create mode 100644 integration/agentcompat/internal/contract/contract_test.go create mode 100644 integration/agentcompat/internal/contract/profile.go create mode 100644 integration/agentcompat/internal/contract/scenario_registry.go create mode 100644 integration/agentcompat/internal/contract/scenario_registry_test.go create mode 100644 integration/agentcompat/internal/contract/values.go diff --git a/integration/agentcompat/internal/contract/budget.go b/integration/agentcompat/internal/contract/budget.go new file mode 100644 index 00000000..f5d51bf4 --- /dev/null +++ b/integration/agentcompat/internal/contract/budget.go @@ -0,0 +1,51 @@ +package contract + +import ( + "errors" + "time" +) + +const ( + ResourceWarmupRuns = 1 + ResourceSampleCount = 5 + ResourceSampleInterval = 250 * time.Millisecond + ResourceExpectedCountDrift = 0 + DashboardRSSDeltaBytes uint64 = 64 * 1024 * 1024 + AgentRSSDeltaBytes uint64 = 32 * 1024 * 1024 + TransferHeapBytes uint64 = 16 * 1024 * 1024 +) + +type ResourceBudgetInput struct { + WarmupRuns int + SampleCount int + SampleInterval time.Duration + ChildProcessCountDrift int + ListenerCountDrift int + NonStdioFDCountDrift int + DashboardRSSDeltaBytes uint64 + AgentRSSDeltaBytes uint64 + TransferHeapBytes uint64 +} + +type ResourceBudget struct{ input ResourceBudgetInput } + +func NewResourceBudget(input ResourceBudgetInput) (ResourceBudget, error) { + if input.WarmupRuns < 1 || input.SampleCount < 1 || input.SampleInterval <= 0 || input.ChildProcessCountDrift < 0 || input.ListenerCountDrift < 0 || input.NonStdioFDCountDrift < 0 || input.DashboardRSSDeltaBytes == 0 || input.AgentRSSDeltaBytes == 0 || input.TransferHeapBytes == 0 { + return ResourceBudget{}, errors.New("invalid resource budget") + } + return ResourceBudget{input: input}, nil +} + +func (b ResourceBudget) WarmupRuns() int { return b.input.WarmupRuns } +func (b ResourceBudget) SampleCount() int { return b.input.SampleCount } +func (b ResourceBudget) SampleInterval() time.Duration { return b.input.SampleInterval } +func (b ResourceBudget) ChildProcessCountDrift() int { return b.input.ChildProcessCountDrift } +func (b ResourceBudget) ListenerCountDrift() int { return b.input.ListenerCountDrift } +func (b ResourceBudget) NonStdioFDCountDrift() int { return b.input.NonStdioFDCountDrift } +func (b ResourceBudget) DashboardRSSDeltaBytes() uint64 { return b.input.DashboardRSSDeltaBytes } +func (b ResourceBudget) AgentRSSDeltaBytes() uint64 { return b.input.AgentRSSDeltaBytes } +func (b ResourceBudget) TransferHeapBytes() uint64 { return b.input.TransferHeapBytes } + +func DefaultResourceBudget() ResourceBudget { + return ResourceBudget{input: ResourceBudgetInput{WarmupRuns: ResourceWarmupRuns, SampleCount: ResourceSampleCount, SampleInterval: ResourceSampleInterval, ChildProcessCountDrift: ResourceExpectedCountDrift, ListenerCountDrift: ResourceExpectedCountDrift, NonStdioFDCountDrift: ResourceExpectedCountDrift, DashboardRSSDeltaBytes: DashboardRSSDeltaBytes, AgentRSSDeltaBytes: AgentRSSDeltaBytes, TransferHeapBytes: TransferHeapBytes}} +} diff --git a/integration/agentcompat/internal/contract/contract_test.go b/integration/agentcompat/internal/contract/contract_test.go new file mode 100644 index 00000000..73549ca2 --- /dev/null +++ b/integration/agentcompat/internal/contract/contract_test.go @@ -0,0 +1,105 @@ +package contract + +import ( + "testing" + "time" +) + +func TestContract_Profiles(t *testing.T) { + pr, err := ProfileByName("pr-full") + if err != nil { + t.Fatalf("parse pr-full: %v", err) + } + if pr.JobTimeout() != 75*time.Minute || pr.SuiteDeadline() != 55*time.Minute { + t.Fatalf("unexpected pr-full deadlines: %#v", pr) + } + if pr.Seed() != Seed(0x4e5a4841) || pr.AgentCount() != 8 || pr.StressRounds() != 4 || pr.ConcurrentOperations() != 64 { + t.Fatalf("unexpected pr-full load: %#v", pr) + } + if pr.ConcurrentSessions() != 4 || pr.TransferPairs() != 1 || pr.DashboardRestartCycles() != 1 { + t.Fatalf("unexpected pr-full sessions: %#v", pr) + } + + soak, err := ProfileByName("soak") + if err != nil { + t.Fatalf("parse soak: %v", err) + } + if soak.SuiteDeadline() != 150*time.Minute || soak.AgentCount() != 20 || soak.Iterations() != 3 || soak.DashboardRestartCycles() != 10 || soak.TransferPairs() != 5 || !soak.StreamBoundaryCheck() { + t.Fatalf("unexpected soak profile: %#v", soak) + } + if soak.JobTimeout() != 150*time.Minute || soak.Seed() != DefaultSeed || soak.StreamBoundaryAllowed() != 40 || soak.StreamBoundaryRejected() != 41 || soak.TransferBytes() != 100*1024*1024 { + t.Fatalf("unexpected soak limits: %#v", soak) + } + if _, err := ProfileByName("unknown"); err == nil { + t.Fatal("unknown profile accepted") + } +} + +func TestContract_ResourceBudget(t *testing.T) { + budget, err := NewResourceBudget(ResourceBudgetInput{ + WarmupRuns: 1, + SampleCount: 5, + SampleInterval: 250 * time.Millisecond, + ChildProcessCountDrift: 0, + ListenerCountDrift: 0, + NonStdioFDCountDrift: 0, + DashboardRSSDeltaBytes: 64 * 1024 * 1024, + AgentRSSDeltaBytes: 32 * 1024 * 1024, + TransferHeapBytes: 16 * 1024 * 1024, + }) + if err != nil { + t.Fatalf("construct budget: %v", err) + } + if budget.WarmupRuns() != 1 || budget.SampleCount() != 5 || budget.SampleInterval() != 250*time.Millisecond || budget.ChildProcessCountDrift() != 0 || budget.ListenerCountDrift() != 0 || budget.NonStdioFDCountDrift() != 0 { + t.Fatalf("unexpected sampling budget: %#v", budget) + } + if budget.DashboardRSSDeltaBytes() != 64*1024*1024 || budget.AgentRSSDeltaBytes() != 32*1024*1024 || budget.TransferHeapBytes() != 16*1024*1024 { + t.Fatalf("unexpected memory budget: %#v", budget) + } +} + +func TestContract_ResourceBudgetRejectsInvalidInput(t *testing.T) { + base := ResourceBudgetInput{WarmupRuns: 1, SampleCount: 5, SampleInterval: 250 * time.Millisecond, DashboardRSSDeltaBytes: 1, AgentRSSDeltaBytes: 1, TransferHeapBytes: 1} + for name, mutate := range map[string]func(*ResourceBudgetInput){ + "zero warmup": func(input *ResourceBudgetInput) { input.WarmupRuns = 0 }, + "zero samples": func(input *ResourceBudgetInput) { input.SampleCount = 0 }, + "negative samples": func(input *ResourceBudgetInput) { input.SampleCount = -1 }, + "zero interval": func(input *ResourceBudgetInput) { input.SampleInterval = 0 }, + "negative interval": func(input *ResourceBudgetInput) { input.SampleInterval = -time.Millisecond }, + "negative child drift": func(input *ResourceBudgetInput) { input.ChildProcessCountDrift = -1 }, + "missing dashboard threshold": func(input *ResourceBudgetInput) { input.DashboardRSSDeltaBytes = 0 }, + "missing agent threshold": func(input *ResourceBudgetInput) { input.AgentRSSDeltaBytes = 0 }, + "missing heap threshold": func(input *ResourceBudgetInput) { input.TransferHeapBytes = 0 }, + } { + t.Run(name, func(t *testing.T) { + input := base + mutate(&input) + if _, err := NewResourceBudget(input); err == nil { + t.Fatal("invalid budget accepted") + } + }) + } +} + +func TestContract_CLIValues(t *testing.T) { + paths, err := NewPaths("/src/nezha", "/src/agent", "/tmp/results") + if err != nil { + t.Fatalf("construct paths: %v", err) + } + if paths.NezhaSource().String() != "/src/nezha" || paths.AgentSource().String() != "/src/agent" || paths.ResultsDir().String() != "/tmp/results" { + t.Fatalf("unexpected paths: %#v", paths) + } + scenario, err := NewScenario("metadata") + if err != nil || scenario.String() != "metadata" { + t.Fatalf("construct scenario: %q %v", scenario.String(), err) + } + fault, err := NewFault("transfer-hash") + if err != nil || fault.String() != "transfer-hash" { + t.Fatalf("construct fault: %q %v", fault.String(), err) + } + for _, invalid := range []string{"", "../escape", "has space"} { + if _, err := NewScenario(invalid); err == nil { + t.Fatalf("invalid scenario accepted: %q", invalid) + } + } +} diff --git a/integration/agentcompat/internal/contract/profile.go b/integration/agentcompat/internal/contract/profile.go new file mode 100644 index 00000000..92399869 --- /dev/null +++ b/integration/agentcompat/internal/contract/profile.go @@ -0,0 +1,96 @@ +package contract + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type ProfileName string +type Seed uint64 + +const ( + ProfilePRFull ProfileName = "pr-full" + ProfileSoak ProfileName = "soak" + DefaultSeed Seed = 0x4e5a4841 + + PRFullJobTimeout = 75 * time.Minute + PRFullSuiteDeadline = 55 * time.Minute + PRFullAgentCount = 8 + PRFullStressRounds = 4 + PRFullConcurrentOperations = 64 + PRFullConcurrentSessions = 4 + PRFullTransferPairs = 1 + PRFullRestartCycles = 1 + + SoakJobTimeout = 150 * time.Minute + SoakSuiteDeadline = 150 * time.Minute + SoakAgentCount = 20 + SoakStressRounds = 4 + SoakConcurrentOperations = 160 + SoakConcurrentSessions = 4 + SoakTransferPairs = 5 + SoakRestartCycles = 10 + SoakIterations = 3 + + TransferBytes uint64 = 100 * 1024 * 1024 + StreamBoundaryAllowed = 40 + StreamBoundaryRejected = 41 +) + +type Profile struct { + name ProfileName + jobTimeout time.Duration + suiteDeadline time.Duration + seed Seed + agentCount int + stressRounds int + concurrentOperations int + concurrentSessions int + transferPairs int + dashboardRestartCycles int + iterations int + streamBoundaryAllowed int + streamBoundaryRejected int +} + +func ProfileByName(name string) (Profile, error) { + switch ProfileName(name) { + case ProfilePRFull: + return Profile{name: ProfilePRFull, jobTimeout: PRFullJobTimeout, suiteDeadline: PRFullSuiteDeadline, seed: DefaultSeed, agentCount: PRFullAgentCount, stressRounds: PRFullStressRounds, concurrentOperations: PRFullConcurrentOperations, concurrentSessions: PRFullConcurrentSessions, transferPairs: PRFullTransferPairs, dashboardRestartCycles: PRFullRestartCycles, iterations: 1, streamBoundaryAllowed: StreamBoundaryAllowed, streamBoundaryRejected: StreamBoundaryRejected}, nil + case ProfileSoak: + return Profile{name: ProfileSoak, jobTimeout: SoakJobTimeout, suiteDeadline: SoakSuiteDeadline, seed: DefaultSeed, agentCount: SoakAgentCount, stressRounds: SoakStressRounds, concurrentOperations: SoakConcurrentOperations, concurrentSessions: SoakConcurrentSessions, transferPairs: SoakTransferPairs, dashboardRestartCycles: SoakRestartCycles, iterations: SoakIterations, streamBoundaryAllowed: StreamBoundaryAllowed, streamBoundaryRejected: StreamBoundaryRejected}, nil + default: + return Profile{}, errors.New("unknown profile; expected pr-full or soak") + } +} + +func (p Profile) Name() ProfileName { return p.name } +func (p Profile) JobTimeout() time.Duration { return p.jobTimeout } +func (p Profile) SuiteDeadline() time.Duration { return p.suiteDeadline } +func (p Profile) Seed() Seed { return p.seed } +func (p Profile) AgentCount() int { return p.agentCount } +func (p Profile) StressRounds() int { return p.stressRounds } +func (p Profile) ConcurrentOperations() int { return p.concurrentOperations } +func (p Profile) ConcurrentSessions() int { return p.concurrentSessions } +func (p Profile) TransferPairs() int { return p.transferPairs } +func (p Profile) DashboardRestartCycles() int { return p.dashboardRestartCycles } +func (p Profile) Iterations() int { return p.iterations } +func (p Profile) TransferBytes() uint64 { return TransferBytes } +func (p Profile) StreamBoundaryAllowed() int { return p.streamBoundaryAllowed } +func (p Profile) StreamBoundaryRejected() int { return p.streamBoundaryRejected } +func (p Profile) StreamBoundaryCheck() bool { + return p.streamBoundaryAllowed > 0 && p.streamBoundaryRejected > p.streamBoundaryAllowed +} + +var ErrInvalidSeed = errors.New("invalid seed") + +func ParseSeed(raw string) (Seed, error) { + value, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X"), 16, 64) + if err != nil || value == 0 { + return 0, fmt.Errorf("%w; expected nonzero hexadecimal", ErrInvalidSeed) + } + return Seed(value), nil +} diff --git a/integration/agentcompat/internal/contract/scenario_registry.go b/integration/agentcompat/internal/contract/scenario_registry.go new file mode 100644 index 00000000..bbe78e11 --- /dev/null +++ b/integration/agentcompat/internal/contract/scenario_registry.go @@ -0,0 +1,175 @@ +package contract + +import ( + "errors" + "slices" +) + +const ( + ScenarioMetadata = "metadata" + ScenarioRegistrationConfigExec = "registration-config-exec" + ScenarioNAT = "nat" + ScenarioLegacyFM = "legacy-fm" + ScenarioTerminal = "terminal" + ScenarioMCPFilesystem = "mcp-filesystem" + ScenarioTransfer100MiB = "transfer-100mib" + ScenarioReconnect = "reconnect" + + FaultAgentBadSecret = "agent-bad-secret" + FaultTransferHash = "transfer-hash" + FaultDashboardExit = "dashboard-exit" +) + +type DedicatedArtifactKind uint8 + +const ( + DedicatedArtifactNone DedicatedArtifactKind = iota + DedicatedArtifactTransfer + DedicatedArtifactReconnect +) + +type ScenarioExecutionKind uint8 + +const ( + ScenarioExecutionMetadata ScenarioExecutionKind = iota + ScenarioExecutionRegistrationConfigExec + ScenarioExecutionNAT + ScenarioExecutionLegacyFM + ScenarioExecutionTerminal + ScenarioExecutionMCPFilesystem + ScenarioExecutionTransfer + ScenarioExecutionReconnect +) + +type ScenarioDefinition struct { + Name string + AllowedFaults []string + Execution ScenarioExecutionKind + DedicatedArtifact DedicatedArtifactKind +} + +type AssertionDefinition struct { + Name string + Passed bool +} + +const ( + AssertionInjectedFault = "injected fault produced the expected scenario failure" + + AssertionTransferWarmup = "small real upload and download warm-up precedes event and deadline quiescence" + AssertionTransferUpload = "exact 100MiB upload has size mode SHA and create_dirs" + AssertionTransferDownload = "exact 100MiB download has equal nonempty SHA" + AssertionTransferUploadReplay = "upload token replay is typed unauthorized" + AssertionTransferDownloadReplay = "download token replay is typed unauthorized" + AssertionTransferOversize = "100MiB plus one upload is typed too large" + AssertionTransferResidue = "Dashboard spool and Agent temp residue are zero" + AssertionTransferSentinels = "outside-root sentinels remain unchanged" + AssertionTransferHeap = "retained live heap stays within 16MiB" + AssertionTransferCleanup = "process listener and workspace cleanup completed" + AssertionTransferHashRejected = "transfer-hash rejects upload with typed 502" + AssertionTransferHashTargetAbsent = "transfer-hash leaves target absent" + + AssertionReconnectDisconnect = "Dashboard disconnect barrier stopped generation one" + AssertionReconnectDashboard = "Dashboard generation two preserves fixture and recreates runtime clients" + AssertionReconnectIdentity = "Agent reconnect preserves exact server ID and UUID" + AssertionReconnectReceipts = "post-reconnect MCP task and result receipts are exactly once" + AssertionReconnectStale = "stale Dashboard generation cannot receive new task receipts" + AssertionReconnectAgent = "Agent restart advances state stream and preserves config identity" + AssertionReconnectSentinel = "outside-root sentinel remains unchanged" + AssertionReconnectCleanup = "multi-generation process listener and workspace cleanup completed" +) + +func (definition ScenarioDefinition) DedicatedArtifactName() string { + switch definition.DedicatedArtifact { + case DedicatedArtifactNone: + return "" + case DedicatedArtifactTransfer: + return "transfer.json" + case DedicatedArtifactReconnect: + return "reconnect.json" + default: + return "" + } +} + +func (definition ScenarioDefinition) Assertions(fault string) []AssertionDefinition { + switch definition.Name { + case ScenarioTransfer100MiB: + if fault == FaultTransferHash { + return assertions( + AssertionTransferWarmup, AssertionTransferHashRejected, AssertionTransferHashTargetAbsent, + AssertionTransferResidue, AssertionTransferSentinels, AssertionTransferCleanup, AssertionInjectedFault, + ) + } + return assertions( + AssertionTransferWarmup, AssertionTransferUpload, AssertionTransferDownload, + AssertionTransferUploadReplay, AssertionTransferDownloadReplay, AssertionTransferOversize, + AssertionTransferResidue, AssertionTransferSentinels, AssertionTransferHeap, AssertionTransferCleanup, + ) + case ScenarioReconnect: + if fault == FaultDashboardExit { + return assertions(AssertionReconnectDisconnect, AssertionReconnectSentinel, AssertionReconnectCleanup, AssertionInjectedFault) + } + return assertions( + AssertionReconnectDisconnect, AssertionReconnectDashboard, AssertionReconnectIdentity, + AssertionReconnectReceipts, AssertionReconnectStale, AssertionReconnectAgent, + AssertionReconnectSentinel, AssertionReconnectCleanup, + ) + default: + return nil + } +} + +func assertions(names ...string) []AssertionDefinition { + definitions := make([]AssertionDefinition, 0, len(names)) + for _, name := range names { + definitions = append(definitions, AssertionDefinition{Name: name, Passed: name != AssertionInjectedFault}) + } + return definitions +} + +var scenarioDefinitions = []ScenarioDefinition{ + {Name: ScenarioMetadata, AllowedFaults: []string{""}, Execution: ScenarioExecutionMetadata}, + {Name: ScenarioRegistrationConfigExec, AllowedFaults: []string{"", FaultAgentBadSecret}, Execution: ScenarioExecutionRegistrationConfigExec}, + {Name: ScenarioNAT, AllowedFaults: []string{""}, Execution: ScenarioExecutionNAT}, + {Name: ScenarioLegacyFM, AllowedFaults: []string{"", FaultAgentBadSecret}, Execution: ScenarioExecutionLegacyFM}, + {Name: ScenarioTerminal, AllowedFaults: []string{""}, Execution: ScenarioExecutionTerminal}, + {Name: ScenarioMCPFilesystem, AllowedFaults: []string{""}, Execution: ScenarioExecutionMCPFilesystem}, + {Name: ScenarioTransfer100MiB, AllowedFaults: []string{"", FaultTransferHash}, Execution: ScenarioExecutionTransfer, DedicatedArtifact: DedicatedArtifactTransfer}, + {Name: ScenarioReconnect, AllowedFaults: []string{"", FaultDashboardExit}, Execution: ScenarioExecutionReconnect, DedicatedArtifact: DedicatedArtifactReconnect}, +} + +func ScenarioDefinitions() []ScenarioDefinition { + definitions := make([]ScenarioDefinition, len(scenarioDefinitions)) + copy(definitions, scenarioDefinitions) + for index := range definitions { + definitions[index].AllowedFaults = slices.Clone(definitions[index].AllowedFaults) + } + return definitions +} + +func ScenarioDefinitionByName(name string) (ScenarioDefinition, error) { + for _, definition := range scenarioDefinitions { + if definition.Name == name { + definition.AllowedFaults = slices.Clone(definition.AllowedFaults) + return definition, nil + } + } + return ScenarioDefinition{}, errors.New("unsupported scenario") +} + +func ValidateScenarioFault(scenario Scenario, fault Fault) error { + definition, err := ScenarioDefinitionByName(scenario.String()) + if err != nil { + return err + } + if !slices.Contains(definition.AllowedFaults, fault.String()) { + return errors.New("unsupported fault for scenario") + } + return nil +} + +func IsSupportedScenario(name string) bool { + _, err := ScenarioDefinitionByName(name) + return err == nil +} diff --git a/integration/agentcompat/internal/contract/scenario_registry_test.go b/integration/agentcompat/internal/contract/scenario_registry_test.go new file mode 100644 index 00000000..a34eb49d --- /dev/null +++ b/integration/agentcompat/internal/contract/scenario_registry_test.go @@ -0,0 +1,83 @@ +package contract + +import ( + "slices" + "testing" +) + +func TestContract_ScenarioFaultRegistry(t *testing.T) { + tests := []struct { + scenario string + fault string + valid bool + }{ + {ScenarioTransfer100MiB, "", true}, + {ScenarioTransfer100MiB, FaultTransferHash, true}, + {ScenarioTransfer100MiB, FaultDashboardExit, false}, + {ScenarioReconnect, "", true}, + {ScenarioReconnect, FaultDashboardExit, true}, + {ScenarioReconnect, FaultTransferHash, false}, + {ScenarioRegistrationConfigExec, FaultAgentBadSecret, true}, + {ScenarioLegacyFM, FaultAgentBadSecret, true}, + {ScenarioMCPFilesystem, FaultAgentBadSecret, false}, + {"future-scenario", "", false}, + } + for _, test := range tests { + scenario, err := NewScenario(test.scenario) + if err != nil { + t.Fatalf("construct scenario %q: %v", test.scenario, err) + } + fault := Fault{} + if test.fault != "" { + fault, err = NewFault(test.fault) + if err != nil { + t.Fatalf("construct fault %q: %v", test.fault, err) + } + } + err = ValidateScenarioFault(scenario, fault) + if (err == nil) != test.valid { + t.Fatalf("scenario=%q fault=%q valid=%t err=%v", test.scenario, test.fault, test.valid, err) + } + } +} + +func TestContract_SyntacticConstructorsPreserveArbitraryValidNames(t *testing.T) { + if _, err := NewScenario("future-scenario"); err != nil { + t.Fatalf("valid future scenario syntax rejected: %v", err) + } + if _, err := NewFault("future-fault"); err != nil { + t.Fatalf("valid future fault syntax rejected: %v", err) + } +} + +func TestContract_ScenarioDefinitionsAreDeterministicAndTyped(t *testing.T) { + definitions := ScenarioDefinitions() + wantNames := []string{ + ScenarioMetadata, + ScenarioRegistrationConfigExec, + ScenarioNAT, + ScenarioLegacyFM, + ScenarioTerminal, + ScenarioMCPFilesystem, + ScenarioTransfer100MiB, + ScenarioReconnect, + } + gotNames := make([]string, 0, len(definitions)) + seenExecution := make(map[ScenarioExecutionKind]struct{}, len(definitions)) + for _, definition := range definitions { + gotNames = append(gotNames, definition.Name) + if len(definition.AllowedFaults) == 0 || definition.AllowedFaults[0] != "" { + t.Fatalf("scenario %q does not explicitly allow the no-fault path", definition.Name) + } + if _, exists := seenExecution[definition.Execution]; exists { + t.Fatalf("execution kind is duplicated: %d", definition.Execution) + } + seenExecution[definition.Execution] = struct{}{} + if definition.DedicatedArtifactName() == "" && definition.DedicatedArtifact != DedicatedArtifactNone { + t.Fatalf("scenario %q has unnamed dedicated artifact", definition.Name) + } + } + if !slices.Equal(gotNames, wantNames) { + t.Fatalf("scenario enumeration order=%v want=%v", gotNames, wantNames) + } +} diff --git a/integration/agentcompat/internal/contract/values.go b/integration/agentcompat/internal/contract/values.go new file mode 100644 index 00000000..a1d9daf0 --- /dev/null +++ b/integration/agentcompat/internal/contract/values.go @@ -0,0 +1,73 @@ +package contract + +import ( + "errors" + "path/filepath" + "regexp" + "strings" +) + +var namePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +type NezhaSourcePath struct{ value string } +type AgentSourcePath struct{ value string } +type ResultsPath struct{ value string } + +type Paths struct { + nezhaSource NezhaSourcePath + agentSource AgentSourcePath + resultsDir ResultsPath +} + +func NewPaths(nezhaSource, agentSource, resultsDir string) (Paths, error) { + nezha, err := cleanAbsolutePath(nezhaSource) + if err != nil { + return Paths{}, errors.New("invalid --nezha-source path") + } + agent, err := cleanAbsolutePath(agentSource) + if err != nil { + return Paths{}, errors.New("invalid --agent-source path") + } + results, err := cleanAbsolutePath(resultsDir) + if err != nil { + return Paths{}, errors.New("invalid --results-dir path") + } + return Paths{nezhaSource: NezhaSourcePath{value: nezha}, agentSource: AgentSourcePath{value: agent}, resultsDir: ResultsPath{value: results}}, nil +} + +func cleanAbsolutePath(raw string) (string, error) { + if strings.TrimSpace(raw) == "" || !filepath.IsAbs(raw) { + return "", errors.New("path must be absolute") + } + return filepath.Clean(raw), nil +} + +func (p Paths) NezhaSource() NezhaSourcePath { return p.nezhaSource } +func (p Paths) AgentSource() AgentSourcePath { return p.agentSource } +func (p Paths) ResultsDir() ResultsPath { return p.resultsDir } +func (p NezhaSourcePath) String() string { return p.value } +func (p AgentSourcePath) String() string { return p.value } +func (p ResultsPath) String() string { return p.value } + +type Scenario struct{ value string } + +func NewScenario(raw string) (Scenario, error) { + if !namePattern.MatchString(raw) { + return Scenario{}, errors.New("invalid scenario name") + } + return Scenario{value: raw}, nil +} + +func (s Scenario) String() string { return s.value } + +type Fault struct{ value string } + +func NewFault(raw string) (Fault, error) { + if !namePattern.MatchString(raw) { + return Fault{}, errors.New("invalid fault name") + } + return Fault{value: raw}, nil +} + +func (f Fault) String() string { return f.value } +func (f Fault) IsZero() bool { return f.value == "" }