Files
sanderling/cmd/internal-tools/campaign/campaign_test.go
T
pj e0180f891a fix(campaign): a record always says how many actions named no producer
An omitted count reads the same as a run recorded before actions carried a
source, so the two cannot be told apart by anything downstream.
2026-08-18 19:45:49 +05:30

676 lines
24 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/trace"
)
func testConfiguration(t *testing.T, outputDirectory string, extra ...string) config {
t.Helper()
arguments := append(baseArguments(), "--output", outputDirectory)
configuration, err := parseArguments(append(arguments, extra...), io.Discard)
if err != nil {
t.Fatal(err)
}
return configuration
}
// versionAnswering wraps a test executor so every fake answers `sanderling
// version`, which the campaign probes before it writes the manifest.
func versionAnswering(executor commandExecutor) commandExecutor {
return func(ctx context.Context, binary string, arguments []string, output io.Writer) (int, error) {
if len(arguments) > 0 && arguments[0] == "version" {
fmt.Fprintln(output, "stub-version")
return 0, nil
}
return executor(ctx, binary, arguments, output)
}
}
func readRecords(t *testing.T, campaignDirectory string) []runRecord {
t.Helper()
file, err := os.Open(filepath.Join(campaignDirectory, recordsFileName))
if err != nil {
t.Fatal(err)
}
defer file.Close()
var records []runRecord
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var record runRecord
if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
t.Fatalf("decode %q: %v", scanner.Text(), err)
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
return records
}
func writeFakeRun(t *testing.T, arguments []string, steps []trace.Step) {
t.Helper()
writeRunDirectory(t, argumentValue(arguments, "--output"), "20260101-000000", steps)
}
// readings hands out the given instants in turn, so a test can script a clock
// that jumps across a host sleep independently of one that stops through it.
func readings(instants ...time.Time) func() time.Time {
var index int
return func() time.Time {
instant := instants[min(index, len(instants)-1)]
index++
return instant
}
}
// A sleeping host stops the monotonic clock and not the wall clock, so a run
// timed on the monotonic clock alone reports the sleep as time that never
// passed. The record carries both, named for the clock each came from.
func TestRunSeed_RecordsTheTimeWorkedAndTheTimeThatPassed(t *testing.T) {
directory := t.TempDir()
startedAt := time.Date(2026, 8, 14, 2, 0, 0, 0, time.UTC)
var records bytes.Buffer
sweep := &campaign{
configuration: testConfiguration(t, directory, "--seeds", "1"),
executor: func(context.Context, string, []string, io.Writer) (int, error) {
return 0, nil
},
stdout: io.Discard,
records: &records,
clocks: clocks{
monotonicNow: readings(startedAt, startedAt.Add(2*time.Minute)),
wallClockNow: readings(startedAt, startedAt.Add(17*time.Minute)),
},
}
sweep.report(sweep.runSeed(context.Background(), 1, ""))
var written map[string]any
if err := json.Unmarshal(records.Bytes(), &written); err != nil {
t.Fatalf("decode %q: %v", records.String(), err)
}
if written["monotonic_millis"] != float64((2 * time.Minute).Milliseconds()) {
t.Errorf("monotonic_millis %v, want the two minutes of work", written["monotonic_millis"])
}
if written["wall_clock_millis"] != float64((17 * time.Minute).Milliseconds()) {
t.Errorf("wall_clock_millis %v, want the seventeen minutes that passed", written["wall_clock_millis"])
}
}
func TestRunCampaign_RecordsDispatchedActionsNotSteps(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1")
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
writeFakeRun(t, arguments, []trace.Step{
actingStep(1), observedStep(2), skippedActionStep(3, "unresolved_selector"), actingStep(4),
})
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err != nil {
t.Fatal(err)
}
records := readRecords(t, directory)
if len(records) != 1 {
t.Fatalf("records: got %d, want 1", len(records))
}
if records[0].Steps != 4 || records[0].Actions != 2 {
t.Errorf("steps %d actions %d, want 4 and 2", records[0].Steps, records[0].Actions)
}
}
// A run that never produced a readable trace still has to carry the field, so
// analysis can tell a zero-action run from a file written before the count. The
// same holds for the unattributed count: a run whose every action named its
// producer says so with a zero, and a file that says nothing is one recorded
// before actions named one at all.
func TestRunRecord_AlwaysCarriesTheActionCounts(t *testing.T) {
body, err := json.Marshal(runRecord{Seed: 7, TraceError: "no run directory with meta.json"})
if err != nil {
t.Fatal(err)
}
for _, field := range []string{`"actions":0`, `"unattributed_actions":0`} {
if !strings.Contains(string(body), field) {
t.Errorf("record %s omits %s", body, field)
}
}
}
func TestRunCampaign_WritesManifestBeforeAnyRun(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory)
var stdout bytes.Buffer
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
if _, err := os.Stat(filepath.Join(directory, manifestFileName)); err != nil {
t.Errorf("manifest missing when the first run started: %v", err)
}
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, &stdout); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(directory, manifestFileName))
if err != nil {
t.Fatal(err)
}
var recorded manifest
if err := json.Unmarshal(body, &recorded); err != nil {
t.Fatal(err)
}
if !slices.Equal(recorded.Seeds, []int64{1, 2, 3}) {
t.Errorf("intended seeds: got %v", recorded.Seeds)
}
if recorded.SanderlingVersion != "stub-version" {
t.Errorf("version: got %q", recorded.SanderlingVersion)
}
if recorded.Host == "" {
t.Error("host was not recorded")
}
}
func TestRunCampaign_RecordsPerRunSummary(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-3")
var stdout bytes.Buffer
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) {
fmt.Fprintln(output, "stub run log")
steps := []trace.Step{observedStep(1), observedStep(2), observedStep(3)}
if argumentValue(arguments, "--seed") == "2" {
violating := observedStep(3)
violating.Violations = []string{"listNeverEmpty"}
violating.Witnesses = map[string]trace.Witness{
"listNeverEmpty": {Reason: "list emptied", Step: 2, DetectedStep: 3},
}
steps[2] = violating
}
writeFakeRun(t, arguments, steps)
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, &stdout); err != nil {
t.Fatal(err)
}
records := readRecords(t, directory)
if len(records) != 3 {
t.Fatalf("records: got %d, want 3", len(records))
}
for _, record := range records {
if record.Steps != 3 {
t.Errorf("seed %d steps: got %d, want 3", record.Seed, record.Steps)
}
if record.RunDirectory != fmt.Sprintf("seed-%d/20260101-000000", record.Seed) {
t.Errorf("seed %d run directory: got %q", record.Seed, record.RunDirectory)
}
if record.TraceError != "" {
t.Errorf("seed %d trace error: %s", record.Seed, record.TraceError)
}
violated := record.FirstViolationOriginStep != nil
if violated != (record.Seed == 2) {
t.Errorf("seed %d violation: got %v", record.Seed, record.FirstViolationOriginStep)
}
if record.Seed != 2 {
continue
}
if *record.FirstViolationOriginStep != 2 || *record.FirstViolationDetectedStep != 3 {
t.Errorf("seed 2 violation steps: origin %d detected %d",
*record.FirstViolationOriginStep, *record.FirstViolationDetectedStep)
}
if !slices.Equal(record.ViolatedProperties, []string{"listNeverEmpty"}) {
t.Errorf("seed 2 properties: got %v", record.ViolatedProperties)
}
}
log, err := os.ReadFile(filepath.Join(directory, "seed-1", "sanderling.log"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(log), "stub run log") {
t.Errorf("per-run log: got %q", log)
}
if !strings.Contains(stdout.String(), "seed=2") {
t.Errorf("progress output missing seed 2: %q", stdout.String())
}
}
func TestRunCampaign_DistributesSeedsAcrossDeviceWorkers(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-9", "--devices", "device-a,device-b,device-c")
devicesPresent(t, "device-a", "device-b", "device-c")
var mutex sync.Mutex
assignments := map[int64]string{}
var inFlight atomic.Int32
var releaseOnce sync.Once
var timedOut atomic.Bool
release := make(chan struct{})
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
if inFlight.Add(1) >= 3 {
releaseOnce.Do(func() { close(release) })
}
select {
case <-release:
case <-time.After(5 * time.Second):
timedOut.Store(true)
}
seed, err := strconv.ParseInt(argumentValue(arguments, "--seed"), 10, 64)
if err != nil {
t.Errorf("seed argument: %v", err)
}
mutex.Lock()
if previous, seen := assignments[seed]; seen {
t.Errorf("seed %d ran twice (%s then %s)", seed, previous, argumentValue(arguments, "--device"))
}
assignments[seed] = argumentValue(arguments, "--device")
mutex.Unlock()
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err != nil {
t.Fatal(err)
}
if timedOut.Load() {
t.Fatal("three device workers never ran concurrently")
}
if len(assignments) != 9 {
t.Fatalf("seeds run: got %d, want 9", len(assignments))
}
used := map[string]int{}
for seed, device := range assignments {
if !slices.Contains(configuration.devices, device) {
t.Errorf("seed %d ran on unknown device %q", seed, device)
}
used[device]++
}
if len(used) != 3 {
t.Errorf("devices used: got %v, want all three", used)
}
if got := len(readRecords(t, directory)); got != 9 {
t.Errorf("records: got %d, want 9", got)
}
}
func TestRunCampaign_ContinuesAfterFailingRun(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-3")
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) {
if argumentValue(arguments, "--seed") == "2" {
fmt.Fprintln(output, "error: device offline")
return 1, nil
}
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
err := runCampaign(context.Background(), configuration, executor, io.Discard)
if err == nil || !strings.Contains(err.Error(), "1 of 3 runs failed") {
t.Fatalf("expected a failure summary, got %v", err)
}
records := readRecords(t, directory)
if len(records) != 3 {
t.Fatalf("a failing run must not abort the campaign: got %d records", len(records))
}
for _, record := range records {
if record.Seed == 2 {
if record.ExitCode != 1 {
t.Errorf("seed 2 exit code: got %d, want 1", record.ExitCode)
}
if record.TraceError == "" {
t.Error("seed 2 produced no trace; that should be recorded")
}
continue
}
if record.ExitCode != 0 {
t.Errorf("seed %d exit code: got %d", record.Seed, record.ExitCode)
}
}
}
func TestRunCampaign_GivesEveryRunTheCellsLabelSource(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-3", "--label-source", "resource-id")
var mutex sync.Mutex
var dispatched []string
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
mutex.Lock()
dispatched = append(dispatched, argumentValue(arguments, "--label-source"))
mutex.Unlock()
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err != nil {
t.Fatal(err)
}
if !slices.Equal(dispatched, []string{"resource-id", "resource-id", "resource-id"}) {
t.Errorf("--label-source reaching sanderling: got %v, want resource-id on every run", dispatched)
}
}
func TestRunCampaign_RefusesToReuseACampaignDirectory(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory)
if err := os.WriteFile(filepath.Join(directory, manifestFileName), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
executor := versionAnswering(func(context.Context, string, []string, io.Writer) (int, error) {
t.Error("no run should start in a directory that already holds a campaign")
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err == nil {
t.Fatal("expected a refusal to reuse the campaign directory")
}
}
func TestRunCampaign_AbortsWhenVersionProbeFails(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory)
executor := func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) {
if arguments[0] != "version" {
t.Error("a run started despite an unusable binary")
}
fmt.Fprintln(output, "no such command")
return 2, nil
}
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err == nil {
t.Fatal("expected the campaign to abort before writing a manifest it cannot attribute")
}
if _, err := os.Stat(filepath.Join(directory, manifestFileName)); err == nil {
t.Error("manifest was written despite an unusable binary")
}
}
func TestRunCampaign_UnreadableTraceIsNotASuccessfulCampaign(t *testing.T) {
campaignDirectory := filepath.Join(t.TempDir(), "cell")
configuration := testConfiguration(t, campaignDirectory, "--seeds", "1-2")
executor := versionAnswering(func(context.Context, string, []string, io.Writer) (int, error) {
return 0, nil
})
var stdout bytes.Buffer
err := runCampaign(context.Background(), configuration, executor, &stdout)
if err == nil || !strings.Contains(err.Error(), "2 of 2 runs produced an unreadable trace") {
t.Fatalf("a campaign whose runs left no readable trace must not report success: %v", err)
}
for _, record := range readRecords(t, campaignDirectory) {
if record.TraceError == "" {
t.Errorf("seed %d: expected a trace error, got none", record.Seed)
}
}
}
func TestRunCampaign_KillsAndRecordsAWedgedRun(t *testing.T) {
campaignDirectory := filepath.Join(t.TempDir(), "cell")
configuration := testConfiguration(t, campaignDirectory,
"--seeds", "1", "--duration", "50ms", "--run-timeout", "300ms")
executor := versionAnswering(func(ctx context.Context, _ string, _ []string, _ io.Writer) (int, error) {
<-ctx.Done()
return -1, ctx.Err()
})
var stdout bytes.Buffer
if err := runCampaign(context.Background(), configuration, executor, &stdout); err == nil {
t.Fatal("a campaign whose only run was killed must not report success")
}
records := readRecords(t, campaignDirectory)
if len(records) != 1 {
t.Fatalf("want one record, got %d", len(records))
}
if !records[0].TimedOut {
t.Error("a run killed by the run timeout must be recorded as timed out")
}
if !strings.Contains(stdout.String(), "timed out") {
t.Errorf("the progress line must name the outcome:\n%s", stdout.String())
}
}
func TestParseArguments_RunTimeoutMustExceedDuration(t *testing.T) {
_, err := parseArguments(append(baseArguments(),
"--output", t.TempDir(), "--duration", "5m", "--run-timeout", "1m"), io.Discard)
if err == nil {
t.Fatal("a run timeout below the duration would kill every run before it finished")
}
}
func TestParseArguments_RunTimeoutDefaultsToThreeTimesDuration(t *testing.T) {
configuration, err := parseArguments(append(baseArguments(),
"--output", t.TempDir(), "--duration", "4m"), io.Discard)
if err != nil {
t.Fatal(err)
}
if configuration.runTimeout != 12*time.Minute {
t.Errorf("run timeout default: got %s, want 12m", configuration.runTimeout)
}
}
// devicesPresent points the preflight at a fixed set of serials, so a campaign
// can be preflighted on a host with no device farm attached.
func devicesPresent(t *testing.T, present ...string) {
t.Helper()
original := connectedDevices
connectedDevices = func(context.Context) ([]string, error) { return present, nil }
t.Cleanup(func() { connectedDevices = original })
}
// A worker aimed at a serial that no longer exists fails in seconds and pulls
// the next seed, so a few dead serials drain the queue while the healthy
// workers are still inside their first run. That has to be caught before the
// first seed is dispatched: a sweep that discovers it on run 1 of 20 has
// already been destroyed, and its output does not say so.
func TestRunCampaign_RefusesToStartWhenADeviceIsMissing(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory,
"--seeds", "1-20", "--devices", "emulator-5554,emulator-5564,emulator-5556")
devicesPresent(t, "emulator-5554", "emulator-5556")
executor := func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
t.Errorf("the campaign dispatched %v despite a missing device", arguments)
return 0, nil
}
err := runCampaign(context.Background(), configuration, executor, io.Discard)
if err == nil || !strings.Contains(err.Error(), "not connected: emulator-5564") {
t.Fatalf("the error must name the missing serial, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(directory, manifestFileName)); statErr == nil {
t.Error("a campaign that cannot run wrote a manifest")
}
}
func TestRunCampaign_RunsWhenEveryDeviceIsPresent(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-2", "--devices", "emulator-5554,emulator-5556")
devicesPresent(t, "emulator-5556", "emulator-5580", "emulator-5554")
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err != nil {
t.Fatal(err)
}
if got := len(readRecords(t, directory)); got != 2 {
t.Errorf("records: got %d, want 2", got)
}
}
// Preflight only means something where the worker names a device. A campaign
// without --devices has one worker and no serial, and a web worker is a label
// with no device behind it; neither may be blocked by a device check.
func TestRunCampaign_PreflightsOnlyWhereWorkersNameADevice(t *testing.T) {
for _, testCase := range []struct {
name string
extra []string
}{
{"no devices", []string{"--seeds", "1"}},
{"web workers", []string{"--seeds", "1", "--platform", "web", "--devices", "worker-a,worker-b"}},
} {
t.Run(testCase.name, func(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, testCase.extra...)
original := connectedDevices
connectedDevices = func(context.Context) ([]string, error) {
t.Error("preflight looked for devices where the workers name none")
return nil, fmt.Errorf("no adb server")
}
t.Cleanup(func() { connectedDevices = original })
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, _ io.Writer) (int, error) {
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
if err := runCampaign(context.Background(), configuration, executor, io.Discard); err != nil {
t.Fatal(err)
}
})
}
}
// Preflight cannot catch a device that disappears mid-campaign. A device that
// keeps failing in seconds is drained of seeds by exactly the speed of its
// failure, so it has to stop being given any.
func TestRunCampaign_QuarantinesADeviceThatKeepsFailingFast(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-8", "--devices", "device-a,device-b")
devicesPresent(t, "device-a", "device-b")
failedEnough := make(chan struct{})
var closeOnce sync.Once
var timedOut atomic.Bool
var mutex sync.Mutex
dispatched := map[string][]int64{}
executor := versionAnswering(func(_ context.Context, _ string, arguments []string, output io.Writer) (int, error) {
device := argumentValue(arguments, "--device")
seed, err := strconv.ParseInt(argumentValue(arguments, "--seed"), 10, 64)
if err != nil {
t.Errorf("seed argument: %v", err)
}
mutex.Lock()
dispatched[device] = append(dispatched[device], seed)
failures := len(dispatched["device-a"])
mutex.Unlock()
if device == "device-a" {
fmt.Fprintln(output, "device 'device-a' not found")
if failures >= fastFailuresBeforeQuarantine {
closeOnce.Do(func() { close(failedEnough) })
}
return 1, nil
}
// The healthy worker holds its seed until the sick one has failed its
// way to quarantine, so the split of the queue is the scheduler's
// decision and not a race between two equally fast fakes.
select {
case <-failedEnough:
case <-time.After(5 * time.Second):
timedOut.Store(true)
}
writeFakeRun(t, arguments, []trace.Step{observedStep(1)})
return 0, nil
})
var stdout bytes.Buffer
err := runCampaign(context.Background(), configuration, executor, &stdout)
if timedOut.Load() {
t.Fatal("device-a never reached quarantine")
}
if err == nil || !strings.Contains(err.Error(), "3 of 8 runs failed") {
t.Fatalf("expected the three fast failures to be reported, got %v", err)
}
if got := len(dispatched["device-a"]); got != fastFailuresBeforeQuarantine {
t.Errorf("seeds sent to the failing device: got %d, want %d", got, fastFailuresBeforeQuarantine)
}
if got := len(dispatched["device-b"]); got != 8-fastFailuresBeforeQuarantine {
t.Errorf("seeds sent to the healthy device: got %d, want %d", got, 8-fastFailuresBeforeQuarantine)
}
ran := append(append([]int64{}, dispatched["device-a"]...), dispatched["device-b"]...)
slices.Sort(ran)
if !slices.Equal(ran, []int64{1, 2, 3, 4, 5, 6, 7, 8}) {
t.Errorf("every seed must be dispatched exactly once: got %v", ran)
}
if !strings.Contains(stdout.String(), `quarantined device "device-a"`) {
t.Errorf("the quarantine must be reported in the campaign output:\n%s", stdout.String())
}
recorded := readManifest(t, directory)
if len(recorded.Quarantined) != 1 || recorded.Quarantined[0].Device != "device-a" {
t.Fatalf("the manifest must record the quarantine: %+v", recorded.Quarantined)
}
burned := slices.Clone(dispatched["device-a"])
slices.Sort(burned)
if !slices.Equal(recorded.Quarantined[0].ConsumedSeeds, burned) {
t.Errorf("consumed seeds: got %v, want %v", recorded.Quarantined[0].ConsumedSeeds, burned)
}
if !slices.Equal(recorded.UnrunSeeds, burned) {
t.Errorf("the seeds the quarantined device burned have no result and must be reported unrun: got %v, want %v",
recorded.UnrunSeeds, burned)
}
}
// Every device gone is not a campaign that should keep pulling seeds: the rest
// of the queue would be spent producing the same failure.
func TestRunCampaign_AbortsWhenEveryDeviceIsQuarantined(t *testing.T) {
directory := t.TempDir()
configuration := testConfiguration(t, directory, "--seeds", "1-10", "--devices", "device-a,device-b")
devicesPresent(t, "device-a", "device-b")
var dispatches atomic.Int32
executor := versionAnswering(func(_ context.Context, _ string, _ []string, output io.Writer) (int, error) {
dispatches.Add(1)
fmt.Fprintln(output, "sidecar health check: context deadline exceeded")
return 1, nil
})
var stdout bytes.Buffer
err := runCampaign(context.Background(), configuration, executor, &stdout)
if err == nil || !strings.Contains(err.Error(), "every device was quarantined") {
t.Fatalf("a campaign with no device left must abort with a clear error, got %v", err)
}
if got := dispatches.Load(); got != int32(2*fastFailuresBeforeQuarantine) {
t.Errorf("runs dispatched: got %d, want %d: the sweep must stop rather than spin through the queue",
got, 2*fastFailuresBeforeQuarantine)
}
recorded := readManifest(t, directory)
if !slices.Equal(recorded.UnrunSeeds, []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) {
t.Errorf("every seed is either burned by a quarantined device or never dispatched: got %v", recorded.UnrunSeeds)
}
}
func readManifest(t *testing.T, campaignDirectory string) manifest {
t.Helper()
body, err := os.ReadFile(filepath.Join(campaignDirectory, manifestFileName))
if err != nil {
t.Fatal(err)
}
var recorded manifest
if err := json.Unmarshal(body, &recorded); err != nil {
t.Fatal(err)
}
return recorded
}