mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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.
676 lines
24 KiB
Go
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
|
|
}
|