mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(campaign): sweep seeds for one experiment cell
campaign.json lists the seeds a sweep intended to run and is written before the first run, so a host that dropped runs shows up as missing seeds rather than as a smaller sample. Seed 0 is rejected: sanderling test reads it as "derive a seed from the clock", which is why conformance/gates.sh controls nothing today. Each run contributes one runs.jsonl line carrying steps to first violation by origin step, the step that armed the failed obligation, so the survival analysis never reopens a trace. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
1 parent
9f9a01f773
commit
028534cadc
11 files changed
+1605
No files matched your search
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commandExecutor runs one sanderling invocation and returns its exit code.
|
||||
// A non-nil error means the process could not be run at all, which is a
|
||||
// different failure from a run that started and exited non-zero.
|
||||
type commandExecutor func(ctx context.Context, binary string, arguments []string, output io.Writer) (int, error)
|
||||
|
||||
func executeCommand(ctx context.Context, binary string, arguments []string, output io.Writer) (int, error) {
|
||||
command := exec.CommandContext(ctx, binary, arguments...)
|
||||
command.Stdout = output
|
||||
command.Stderr = output
|
||||
err := command.Run()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
return exitError.ExitCode(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
|
||||
// runRecord is one line of runs.jsonl.
|
||||
type runRecord struct {
|
||||
Seed int64 `json:"seed"`
|
||||
Device string `json:"device,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
LaunchError string `json:"launch_error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
RunDirectory string `json:"run_directory,omitempty"`
|
||||
TraceError string `json:"trace_error,omitempty"`
|
||||
traceSummary
|
||||
}
|
||||
|
||||
type campaign struct {
|
||||
configuration config
|
||||
executor commandExecutor
|
||||
stdout io.Writer
|
||||
records io.Writer
|
||||
mutex sync.Mutex
|
||||
failures int
|
||||
unreadable int
|
||||
}
|
||||
|
||||
func runCampaign(ctx context.Context, configuration config, executor commandExecutor, stdout io.Writer) error {
|
||||
if _, err := os.Stat(filepath.Join(configuration.outputDirectory, manifestFileName)); err == nil {
|
||||
return fmt.Errorf("%s already exists in %s: pick a fresh --output so two campaigns do not share a directory",
|
||||
manifestFileName, configuration.outputDirectory)
|
||||
}
|
||||
if err := os.MkdirAll(configuration.outputDirectory, 0o755); err != nil {
|
||||
return fmt.Errorf("create campaign dir: %w", err)
|
||||
}
|
||||
binaryPath := configuration.sanderlingPath
|
||||
if resolved, err := exec.LookPath(binaryPath); err == nil {
|
||||
if absolute, err := filepath.Abs(resolved); err == nil {
|
||||
binaryPath = absolute
|
||||
}
|
||||
}
|
||||
version, err := probeVersion(ctx, configuration.sanderlingPath, executor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
if err := writeManifest(configuration.outputDirectory, buildManifest(configuration, host, binaryPath, version, time.Now().UTC())); err != nil {
|
||||
return fmt.Errorf("write %s: %w", manifestFileName, err)
|
||||
}
|
||||
|
||||
recordsFile, err := os.OpenFile(filepath.Join(configuration.outputDirectory, recordsFileName),
|
||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", recordsFileName, err)
|
||||
}
|
||||
defer recordsFile.Close()
|
||||
|
||||
sweep := &campaign{configuration: configuration, executor: executor, stdout: stdout, records: recordsFile}
|
||||
fmt.Fprintf(stdout, "campaign %s: %d seeds, %d worker(s), %s\n",
|
||||
configuration.arm, len(configuration.seeds), len(workerDevices(configuration.devices)), configuration.outputDirectory)
|
||||
sweep.sweep(ctx)
|
||||
|
||||
fmt.Fprintf(stdout, "campaign complete: %d of %d runs failed, %d produced an unreadable trace\n",
|
||||
sweep.failures, len(configuration.seeds), sweep.unreadable)
|
||||
if sweep.failures > 0 {
|
||||
return fmt.Errorf("%d of %d runs failed", sweep.failures, len(configuration.seeds))
|
||||
}
|
||||
if sweep.unreadable > 0 {
|
||||
// A run that exits 0 and leaves a trace the analysis cannot read is a
|
||||
// lost cell, not a successful campaign, and an unattended sweep has to
|
||||
// say so rather than reporting no failures.
|
||||
return fmt.Errorf("%d of %d runs produced an unreadable trace", sweep.unreadable, len(configuration.seeds))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func probeVersion(ctx context.Context, binary string, executor commandExecutor) (string, error) {
|
||||
var output bytes.Buffer
|
||||
code, err := executor(ctx, binary, []string{"version"}, &output)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("run %s version: %w", binary, err)
|
||||
}
|
||||
if code != 0 {
|
||||
return "", fmt.Errorf("%s version exited %d: %s", binary, code, strings.TrimSpace(output.String()))
|
||||
}
|
||||
return strings.TrimSpace(output.String()), nil
|
||||
}
|
||||
|
||||
// workerDevices returns one entry per concurrent worker. With no --devices
|
||||
// there is a single worker and no device to name.
|
||||
func workerDevices(devices []string) []string {
|
||||
if len(devices) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func (c *campaign) sweep(ctx context.Context) {
|
||||
queue := make(chan int64, len(c.configuration.seeds))
|
||||
for _, seed := range c.configuration.seeds {
|
||||
queue <- seed
|
||||
}
|
||||
close(queue)
|
||||
|
||||
var waitGroup sync.WaitGroup
|
||||
for _, device := range workerDevices(c.configuration.devices) {
|
||||
waitGroup.Add(1)
|
||||
go func(device string) {
|
||||
defer waitGroup.Done()
|
||||
for seed := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.report(c.runSeed(ctx, seed, device))
|
||||
}
|
||||
}(device)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
}
|
||||
|
||||
func (c *campaign) runSeed(ctx context.Context, seed int64, device string) runRecord {
|
||||
seedText := strconv.FormatInt(seed, 10)
|
||||
directory := seedDirectory(c.configuration, seedText)
|
||||
record := runRecord{Seed: seed, Device: device, StartedAt: time.Now().UTC()}
|
||||
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
record.ExitCode = -1
|
||||
record.LaunchError = err.Error()
|
||||
return record
|
||||
}
|
||||
logFile, err := os.Create(filepath.Join(directory, "sanderling.log"))
|
||||
if err != nil {
|
||||
record.ExitCode = -1
|
||||
record.LaunchError = err.Error()
|
||||
return record
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
start := time.Now()
|
||||
exitCode, runErr := c.executor(ctx, c.configuration.sanderlingPath, runArguments(c.configuration, seedText, device), logFile)
|
||||
record.DurationMillis = time.Since(start).Milliseconds()
|
||||
record.ExitCode = exitCode
|
||||
if runErr != nil {
|
||||
record.LaunchError = runErr.Error()
|
||||
}
|
||||
|
||||
name, summary, err := summarizeRun(directory)
|
||||
if name != "" {
|
||||
record.RunDirectory = filepath.Join(filepath.Base(directory), name)
|
||||
}
|
||||
if err != nil {
|
||||
record.TraceError = err.Error()
|
||||
return record
|
||||
}
|
||||
record.traceSummary = summary
|
||||
return record
|
||||
}
|
||||
|
||||
func (c *campaign) report(record runRecord) {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
if record.ExitCode != 0 {
|
||||
c.failures++
|
||||
} else if record.TraceError != "" {
|
||||
c.unreadable++
|
||||
}
|
||||
if err := json.NewEncoder(c.records).Encode(record); err != nil {
|
||||
fmt.Fprintf(c.stdout, "warning: seed %d record: %v\n", record.Seed, err)
|
||||
}
|
||||
fmt.Fprintf(c.stdout, "seed=%d device=%q outcome=%s steps=%d exit=%d duration=%s\n",
|
||||
record.Seed, record.Device, outcome(record), record.Steps, record.ExitCode,
|
||||
time.Duration(record.DurationMillis)*time.Millisecond)
|
||||
}
|
||||
|
||||
func outcome(record runRecord) string {
|
||||
switch {
|
||||
case record.ExitCode != 0:
|
||||
return "failed"
|
||||
case record.FirstViolationOriginStep != nil:
|
||||
return fmt.Sprintf("violation@%d(%s)", *record.FirstViolationOriginStep,
|
||||
strings.Join(record.FirstViolationProperties, ","))
|
||||
default:
|
||||
return "clean"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubSanderling answers `version`, writes a run directory shaped like the one
|
||||
// `sanderling test` produces, violates on seed 5 and fails on seed 7.
|
||||
const stubSanderling = `#!/bin/sh
|
||||
if [ "$1" = "version" ]; then
|
||||
echo "stub 9.9.9"
|
||||
exit 0
|
||||
fi
|
||||
seed=""
|
||||
output=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--seed) seed="$2"; shift 2 ;;
|
||||
--output) output="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
echo "stub run seed=$seed"
|
||||
run="$output/20260101-000000"
|
||||
mkdir -p "$run"
|
||||
echo "{\"seed\":$seed,\"platform\":\"web\"}" > "$run/meta.json"
|
||||
{
|
||||
echo '{"step":1,"hierarchy":{"elements":[]}}'
|
||||
echo '{"step":2,"hierarchy":{"elements":[]}}'
|
||||
if [ "$seed" = "5" ]; then
|
||||
echo '{"step":3,"hierarchy":{"elements":[]},"violations":["cartTotalMatches"],"witnesses":{"cartTotalMatches":{"reason":"total drifted","step":2,"detected_step":3}}}'
|
||||
else
|
||||
echo '{"step":3,"hierarchy":{"elements":[]}}'
|
||||
fi
|
||||
} > "$run/trace.jsonl"
|
||||
if [ "$seed" = "7" ]; then
|
||||
echo "driver crashed" >&2
|
||||
exit 3
|
||||
fi
|
||||
`
|
||||
|
||||
func TestRun_EndToEndAgainstStubBinary(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
binaryPath := filepath.Join(directory, "stub-sanderling")
|
||||
if err := os.WriteFile(binaryPath, []byte(stubSanderling), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
campaignDirectory := filepath.Join(directory, "campaign")
|
||||
var stdout bytes.Buffer
|
||||
|
||||
err := run([]string{
|
||||
"--spec", "/specs/folio.ts",
|
||||
"--bundle-id", "app.folio",
|
||||
"--platform", "web",
|
||||
"--arm", "seeded-web",
|
||||
"--generator", "seeded",
|
||||
"--max-steps", "50",
|
||||
"--duration", "30s",
|
||||
"--seeds", "4-5,7",
|
||||
"--devices", "worker-a,worker-b",
|
||||
"--sanderling", binaryPath,
|
||||
"--output", campaignDirectory,
|
||||
"--", "--clear-data=false",
|
||||
}, &stdout, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "1 of 3 runs failed") {
|
||||
t.Fatalf("expected the failing seed to be reported, got %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if recorded.SanderlingVersion != "stub 9.9.9" {
|
||||
t.Errorf("version: got %q", recorded.SanderlingVersion)
|
||||
}
|
||||
if !slices.Equal(recorded.Seeds, []int64{4, 5, 7}) {
|
||||
t.Errorf("intended seeds: got %v", recorded.Seeds)
|
||||
}
|
||||
if slices.Contains(recorded.ArgumentTemplate, "--device") {
|
||||
t.Errorf("web template should carry no device flag: %v", recorded.ArgumentTemplate)
|
||||
}
|
||||
if recorded.ArgumentTemplate[len(recorded.ArgumentTemplate)-1] != "--clear-data=false" {
|
||||
t.Errorf("template lost the passthrough flag: %v", recorded.ArgumentTemplate)
|
||||
}
|
||||
|
||||
records := readRecords(t, campaignDirectory)
|
||||
if len(records) != 3 {
|
||||
t.Fatalf("records: got %d, want 3", len(records))
|
||||
}
|
||||
bySeed := map[int64]runRecord{}
|
||||
for _, record := range records {
|
||||
bySeed[record.Seed] = record
|
||||
}
|
||||
for _, seed := range []int64{4, 5, 7} {
|
||||
record, ok := bySeed[seed]
|
||||
if !ok {
|
||||
t.Fatalf("seed %d is missing from %s", seed, recordsFileName)
|
||||
}
|
||||
if record.Steps != 3 {
|
||||
t.Errorf("seed %d steps: got %d, want 3", seed, record.Steps)
|
||||
}
|
||||
if record.RunDirectory == "" {
|
||||
t.Errorf("seed %d has no run directory", seed)
|
||||
}
|
||||
if !slices.Contains([]string{"worker-a", "worker-b"}, record.Device) {
|
||||
t.Errorf("seed %d device: got %q", seed, record.Device)
|
||||
}
|
||||
}
|
||||
violating := bySeed[5]
|
||||
if violating.FirstViolationOriginStep == nil || *violating.FirstViolationOriginStep != 2 {
|
||||
t.Fatalf("seed 5 origin step: got %v, want 2", violating.FirstViolationOriginStep)
|
||||
}
|
||||
if *violating.FirstViolationDetectedStep != 3 || violating.FirstViolationReason != "total drifted" {
|
||||
t.Errorf("seed 5 violation: %+v", violating)
|
||||
}
|
||||
if bySeed[4].FirstViolationOriginStep != nil {
|
||||
t.Errorf("seed 4 should be censored at the budget: %+v", bySeed[4])
|
||||
}
|
||||
if bySeed[7].ExitCode != 3 {
|
||||
t.Errorf("seed 7 exit code: got %d, want 3", bySeed[7].ExitCode)
|
||||
}
|
||||
|
||||
log, err := os.ReadFile(filepath.Join(campaignDirectory, "seed-7", "sanderling.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(log), "driver crashed") {
|
||||
t.Errorf("seed 7 log lost the stderr line: %q", log)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "outcome=violation@2") {
|
||||
t.Errorf("progress output: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Command campaign sweeps a list of seeds for one experiment cell, writing a
|
||||
// directory an analysis pipeline can read without re-parsing raw traces.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
specPath string
|
||||
bundleID string
|
||||
platform string
|
||||
arm string
|
||||
generator string
|
||||
maxSteps int
|
||||
duration time.Duration
|
||||
seeds []int64
|
||||
devices []string
|
||||
sanderlingPath string
|
||||
outputDirectory string
|
||||
extraArguments []string
|
||||
}
|
||||
|
||||
const usage = `campaign sweeps seeds for one experiment cell of a sanderling evaluation.
|
||||
|
||||
Usage:
|
||||
campaign --spec <path> --bundle-id <id> --platform <android|ios|web>
|
||||
--arm <label> --generator <seeded|llm> --max-steps <n>
|
||||
--seeds <spec> --output <dir> [flags] [-- <sanderling test flags>]
|
||||
|
||||
Everything after a bare -- is appended verbatim to every sanderling test call.
|
||||
`
|
||||
|
||||
func parseArguments(arguments []string, stderr io.Writer) (config, error) {
|
||||
flagSet := flag.NewFlagSet("campaign", flag.ContinueOnError)
|
||||
flagSet.SetOutput(stderr)
|
||||
flagSet.Usage = func() {
|
||||
fmt.Fprint(stderr, usage)
|
||||
flagSet.PrintDefaults()
|
||||
}
|
||||
var configuration config
|
||||
var seedSpecification string
|
||||
var deviceList string
|
||||
flagSet.StringVar(&configuration.specPath, "spec", "", "path to the TypeScript spec (required)")
|
||||
flagSet.StringVar(&configuration.bundleID, "bundle-id", "", "target app bundle ID (required)")
|
||||
flagSet.StringVar(&configuration.platform, "platform", "android", "target platform: android, ios, web")
|
||||
flagSet.StringVar(&configuration.arm, "arm", "", "experiment cell label recorded on every run (required)")
|
||||
flagSet.StringVar(&configuration.generator, "generator", "seeded", "action generator: seeded or llm")
|
||||
flagSet.IntVar(&configuration.maxSteps, "max-steps", 0, "per-run step budget (required, must be positive)")
|
||||
flagSet.DurationVar(&configuration.duration, "duration", 5*time.Minute, "per-run wall-clock ceiling")
|
||||
flagSet.StringVar(&seedSpecification, "seeds", "", "seeds to run: ranges and lists, e.g. 1-10,20,30-32 (required)")
|
||||
flagSet.StringVar(&deviceList, "devices", "", "comma-separated device identifiers; one concurrent worker per device (on web these are worker labels, no device flag is passed)")
|
||||
flagSet.StringVar(&configuration.sanderlingPath, "sanderling", "sanderling", "sanderling binary to invoke")
|
||||
flagSet.StringVar(&configuration.outputDirectory, "output", "", "campaign directory to create (required)")
|
||||
if err := flagSet.Parse(arguments); err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
configuration.extraArguments = flagSet.Args()
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"--spec": configuration.specPath,
|
||||
"--bundle-id": configuration.bundleID,
|
||||
"--arm": configuration.arm,
|
||||
"--seeds": seedSpecification,
|
||||
"--output": configuration.outputDirectory,
|
||||
} {
|
||||
if value == "" {
|
||||
return config{}, fmt.Errorf("%s is required", name)
|
||||
}
|
||||
}
|
||||
switch configuration.platform {
|
||||
case "android", "ios", "web":
|
||||
default:
|
||||
return config{}, fmt.Errorf("unsupported platform: %q (android, ios, web)", configuration.platform)
|
||||
}
|
||||
switch configuration.generator {
|
||||
case "seeded", "llm":
|
||||
default:
|
||||
return config{}, fmt.Errorf("unsupported generator: %q (seeded, llm)", configuration.generator)
|
||||
}
|
||||
if configuration.maxSteps <= 0 {
|
||||
// Steps to first violation is right-censored at the budget, so a
|
||||
// campaign without one has nothing to censor its clean runs at.
|
||||
return config{}, fmt.Errorf("--max-steps must be positive: every run needs the same step budget")
|
||||
}
|
||||
if configuration.duration <= 0 {
|
||||
return config{}, fmt.Errorf("--duration must be positive: %s", configuration.duration)
|
||||
}
|
||||
seeds, err := parseSeeds(seedSpecification)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("--seeds: %w", err)
|
||||
}
|
||||
configuration.seeds = seeds
|
||||
devices, err := parseDevices(deviceList)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("--devices: %w", err)
|
||||
}
|
||||
configuration.devices = devices
|
||||
return configuration, nil
|
||||
}
|
||||
|
||||
func parseDevices(list string) ([]string, error) {
|
||||
if strings.TrimSpace(list) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var devices []string
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
device := strings.TrimSpace(part)
|
||||
if device == "" {
|
||||
return nil, fmt.Errorf("empty device in %q", list)
|
||||
}
|
||||
if seen[device] {
|
||||
return nil, fmt.Errorf("duplicate device %q", device)
|
||||
}
|
||||
seen[device] = true
|
||||
devices = append(devices, device)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// deviceFlag names the `sanderling test` flag that selects a target on this
|
||||
// platform. Web has no device, so its workers only bound concurrency.
|
||||
func deviceFlag(platform string) string {
|
||||
switch platform {
|
||||
case "android":
|
||||
return "--device"
|
||||
case "ios":
|
||||
return "--ios-device"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func seedDirectory(configuration config, seed string) string {
|
||||
return filepath.Join(configuration.outputDirectory, "seed-"+seed)
|
||||
}
|
||||
|
||||
// runArguments builds one `sanderling test` invocation. seed and device are
|
||||
// passed as strings so the same code produces both a real command and the
|
||||
// placeholder template recorded in campaign.json.
|
||||
func runArguments(configuration config, seed, device string) []string {
|
||||
arguments := []string{
|
||||
"test",
|
||||
"--spec", configuration.specPath,
|
||||
"--bundle-id", configuration.bundleID,
|
||||
"--platform", configuration.platform,
|
||||
"--arm", configuration.arm,
|
||||
"--generator", configuration.generator,
|
||||
"--max-steps", strconv.Itoa(configuration.maxSteps),
|
||||
"--duration", configuration.duration.String(),
|
||||
"--seed", seed,
|
||||
"--output", seedDirectory(configuration, seed),
|
||||
}
|
||||
if flagName := deviceFlag(configuration.platform); flagName != "" && device != "" {
|
||||
arguments = append(arguments, flagName, device)
|
||||
}
|
||||
return append(arguments, configuration.extraArguments...)
|
||||
}
|
||||
|
||||
func run(arguments []string, stdout, stderr io.Writer) error {
|
||||
configuration, err := parseArguments(arguments, stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
return runCampaign(ctx, configuration, executeCommand, stdout)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func baseArguments() []string {
|
||||
return []string{
|
||||
"--spec", "/specs/folio.ts",
|
||||
"--bundle-id", "app.folio",
|
||||
"--platform", "android",
|
||||
"--arm", "seeded-baseline",
|
||||
"--generator", "seeded",
|
||||
"--max-steps", "300",
|
||||
"--seeds", "1-3",
|
||||
"--output", "/campaigns/a",
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArguments_AcceptsFullInvocation(t *testing.T) {
|
||||
configuration, err := parseArguments(append(baseArguments(), "--devices", "emulator-5554,emulator-5556"), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if configuration.arm != "seeded-baseline" || configuration.maxSteps != 300 {
|
||||
t.Errorf("arm/max-steps: got %q/%d", configuration.arm, configuration.maxSteps)
|
||||
}
|
||||
if !slices.Equal(configuration.seeds, []int64{1, 2, 3}) {
|
||||
t.Errorf("seeds: got %v", configuration.seeds)
|
||||
}
|
||||
if !slices.Equal(configuration.devices, []string{"emulator-5554", "emulator-5556"}) {
|
||||
t.Errorf("devices: got %v", configuration.devices)
|
||||
}
|
||||
if configuration.duration != 5*time.Minute {
|
||||
t.Errorf("duration default: got %s", configuration.duration)
|
||||
}
|
||||
if configuration.sanderlingPath != "sanderling" {
|
||||
t.Errorf("sanderling default: got %q", configuration.sanderlingPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArguments_Rejections(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arguments []string
|
||||
want string
|
||||
}{
|
||||
{"missing spec", []string{"--bundle-id", "a", "--arm", "b", "--seeds", "1", "--output", "o"}, "--spec is required"},
|
||||
{"missing arm", []string{"--spec", "s", "--bundle-id", "a", "--seeds", "1", "--output", "o", "--max-steps", "10"}, "--arm is required"},
|
||||
{"missing output", []string{"--spec", "s", "--bundle-id", "a", "--arm", "b", "--seeds", "1", "--max-steps", "10"}, "--output is required"},
|
||||
{"bad platform", append(baseArguments(), "--platform", "windows"), "unsupported platform"},
|
||||
{"bad generator", append(baseArguments(), "--generator", "vibes"), "unsupported generator"},
|
||||
{"zero max steps", append(baseArguments(), "--max-steps", "0"), "--max-steps must be positive"},
|
||||
{"seed zero", append(baseArguments(), "--seeds", "0-2"), "not reproducible"},
|
||||
{"duplicate device", append(baseArguments(), "--devices", "a,a"), "duplicate device"},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
_, err := parseArguments(testCase.arguments, io.Discard)
|
||||
if err == nil {
|
||||
t.Errorf("%s: expected error", testCase.name)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), testCase.want) {
|
||||
t.Errorf("%s: got %q, want it to contain %q", testCase.name, err, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunArguments_PlatformDeviceFlagAndPassthrough(t *testing.T) {
|
||||
cases := []struct {
|
||||
platform string
|
||||
want []string
|
||||
}{
|
||||
{"android", []string{"--device", "target-1"}},
|
||||
{"ios", []string{"--ios-device", "target-1"}},
|
||||
{"web", nil},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
arguments := append(baseArguments(), "--platform", testCase.platform, "--", "--clear-data=false")
|
||||
configuration, err := parseArguments(arguments, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", testCase.platform, err)
|
||||
}
|
||||
got := runArguments(configuration, "7", "target-1")
|
||||
if got[0] != "test" {
|
||||
t.Errorf("%s: first argument is %q, want test", testCase.platform, got[0])
|
||||
}
|
||||
for _, pair := range [][2]string{
|
||||
{"--seed", "7"},
|
||||
{"--arm", "seeded-baseline"},
|
||||
{"--max-steps", "300"},
|
||||
{"--generator", "seeded"},
|
||||
{"--output", "/campaigns/a/seed-7"},
|
||||
} {
|
||||
if value := argumentValue(got, pair[0]); value != pair[1] {
|
||||
t.Errorf("%s: %s = %q, want %q", testCase.platform, pair[0], value, pair[1])
|
||||
}
|
||||
}
|
||||
if testCase.want != nil {
|
||||
if value := argumentValue(got, testCase.want[0]); value != testCase.want[1] {
|
||||
t.Errorf("%s: %s = %q, want %q", testCase.platform, testCase.want[0], value, testCase.want[1])
|
||||
}
|
||||
} else if slices.Contains(got, "--device") || slices.Contains(got, "--ios-device") {
|
||||
t.Errorf("web: unexpected device flag in %v", got)
|
||||
}
|
||||
if got[len(got)-1] != "--clear-data=false" {
|
||||
t.Errorf("%s: passthrough argument lost: %v", testCase.platform, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func argumentValue(arguments []string, name string) string {
|
||||
index := slices.Index(arguments, name)
|
||||
if index < 0 || index+1 >= len(arguments) {
|
||||
return ""
|
||||
}
|
||||
return arguments[index+1]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestFileName = "campaign.json"
|
||||
recordsFileName = "runs.jsonl"
|
||||
seedPlaceholder = "{seed}"
|
||||
devicePlaceholder = "{device}"
|
||||
)
|
||||
|
||||
// manifest is campaign.json: what the campaign INTENDED to run, written before
|
||||
// the first run so a host that dropped runs shows up as missing seeds rather
|
||||
// than as a smaller sample.
|
||||
type manifest struct {
|
||||
Arm string `json:"arm"`
|
||||
Generator string `json:"generator"`
|
||||
Platform string `json:"platform"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
BundleID string `json:"bundle_id"`
|
||||
MaxSteps int `json:"max_steps"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
Seeds []int64 `json:"seeds"`
|
||||
Devices []string `json:"devices"`
|
||||
Host string `json:"host"`
|
||||
SanderlingPath string `json:"sanderling_path"`
|
||||
SanderlingVersion string `json:"sanderling_version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ArgumentTemplate []string `json:"argument_template"`
|
||||
}
|
||||
|
||||
func buildManifest(configuration config, host, binaryPath, version string, startedAt time.Time) manifest {
|
||||
devices := configuration.devices
|
||||
if devices == nil {
|
||||
devices = []string{}
|
||||
}
|
||||
return manifest{
|
||||
Arm: configuration.arm,
|
||||
Generator: configuration.generator,
|
||||
Platform: configuration.platform,
|
||||
SpecPath: configuration.specPath,
|
||||
BundleID: configuration.bundleID,
|
||||
MaxSteps: configuration.maxSteps,
|
||||
DurationMillis: configuration.duration.Milliseconds(),
|
||||
Seeds: configuration.seeds,
|
||||
Devices: devices,
|
||||
Host: host,
|
||||
SanderlingPath: binaryPath,
|
||||
SanderlingVersion: version,
|
||||
StartedAt: startedAt,
|
||||
ArgumentTemplate: runArguments(configuration, seedPlaceholder, devicePlaceholder),
|
||||
}
|
||||
}
|
||||
|
||||
func writeManifest(directory string, value manifest) error {
|
||||
body, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
return os.WriteFile(filepath.Join(directory, manifestFileName), append(body, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildManifest_RecordsIntendedRunsAndTemplate(t *testing.T) {
|
||||
configuration, err := parseArguments(append(baseArguments(), "--devices", "a,b", "--duration", "90s"), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startedAt := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)
|
||||
value := buildManifest(configuration, "worker-7", "/usr/local/bin/sanderling", "1.4.2", startedAt)
|
||||
|
||||
if !slices.Equal(value.Seeds, []int64{1, 2, 3}) {
|
||||
t.Errorf("seeds: got %v", value.Seeds)
|
||||
}
|
||||
if value.Host != "worker-7" || value.SanderlingVersion != "1.4.2" || value.SanderlingPath != "/usr/local/bin/sanderling" {
|
||||
t.Errorf("provenance: %+v", value)
|
||||
}
|
||||
if value.DurationMillis != 90_000 || value.MaxSteps != 300 {
|
||||
t.Errorf("budget: duration=%d max_steps=%d", value.DurationMillis, value.MaxSteps)
|
||||
}
|
||||
if !value.StartedAt.Equal(startedAt) {
|
||||
t.Errorf("started_at: got %s", value.StartedAt)
|
||||
}
|
||||
if got := argumentValue(value.ArgumentTemplate, "--seed"); got != seedPlaceholder {
|
||||
t.Errorf("template seed: got %q, want %q", got, seedPlaceholder)
|
||||
}
|
||||
if got := argumentValue(value.ArgumentTemplate, "--device"); got != devicePlaceholder {
|
||||
t.Errorf("template device: got %q, want %q", got, devicePlaceholder)
|
||||
}
|
||||
if got := argumentValue(value.ArgumentTemplate, "--output"); got != "/campaigns/a/seed-"+seedPlaceholder {
|
||||
t.Errorf("template output: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildManifest_EmptyDeviceListSerializesAsArray(t *testing.T) {
|
||||
configuration, err := parseArguments(baseArguments(), io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
if err := writeManifest(directory, buildManifest(configuration, "host", "sanderling", "dev", time.Now())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(directory, manifestFileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded struct {
|
||||
Devices []string `json:"devices"`
|
||||
Arm string `json:"arm"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Devices == nil || len(decoded.Devices) != 0 {
|
||||
t.Errorf("devices: got %v, want []", decoded.Devices)
|
||||
}
|
||||
if decoded.Arm != "seeded-baseline" {
|
||||
t.Errorf("arm: got %q", decoded.Arm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseSeeds expands a seed specification such as "1-10,20,30-32" into the
|
||||
// explicit seed list a campaign intends to run.
|
||||
func parseSeeds(specification string) ([]int64, error) {
|
||||
trimmed := strings.TrimSpace(specification)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("empty seed spec")
|
||||
}
|
||||
var seeds []int64
|
||||
seen := map[int64]bool{}
|
||||
for _, part := range strings.Split(trimmed, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("empty seed in %q", specification)
|
||||
}
|
||||
expanded, err := expandSeedPart(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, seed := range expanded {
|
||||
if seen[seed] {
|
||||
return nil, fmt.Errorf("duplicate seed %d in %q", seed, specification)
|
||||
}
|
||||
seen[seed] = true
|
||||
seeds = append(seeds, seed)
|
||||
}
|
||||
}
|
||||
return seeds, nil
|
||||
}
|
||||
|
||||
func expandSeedPart(part string) ([]int64, error) {
|
||||
start, end, isRange := strings.Cut(part, "-")
|
||||
if !isRange {
|
||||
seed, err := parseSeed(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []int64{seed}, nil
|
||||
}
|
||||
first, err := parseSeed(strings.TrimSpace(start))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed range %q: %w", part, err)
|
||||
}
|
||||
last, err := parseSeed(strings.TrimSpace(end))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed range %q: %w", part, err)
|
||||
}
|
||||
if first > last {
|
||||
return nil, fmt.Errorf("seed range %q: start %d is above end %d", part, first, last)
|
||||
}
|
||||
seeds := make([]int64, 0, last-first+1)
|
||||
for seed := first; seed <= last; seed++ {
|
||||
seeds = append(seeds, seed)
|
||||
}
|
||||
return seeds, nil
|
||||
}
|
||||
|
||||
func parseSeed(text string) (int64, error) {
|
||||
seed, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid seed %q: want a positive integer", text)
|
||||
}
|
||||
if seed == 0 {
|
||||
// `sanderling test` reads --seed 0 as "derive a seed from the clock",
|
||||
// so a campaign listing seed 0 records a run nobody can reproduce.
|
||||
return 0, fmt.Errorf("seed 0 is not reproducible: sanderling test derives a random seed when --seed is 0, so list explicit non-zero seeds")
|
||||
}
|
||||
if seed < 0 {
|
||||
return 0, fmt.Errorf("invalid seed %d: want a positive integer", seed)
|
||||
}
|
||||
return seed, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSeeds_RangesAndLists(t *testing.T) {
|
||||
cases := []struct {
|
||||
specification string
|
||||
want []int64
|
||||
}{
|
||||
{"1-5", []int64{1, 2, 3, 4, 5}},
|
||||
{"1,5,9", []int64{1, 5, 9}},
|
||||
{"1-3,20,30-32", []int64{1, 2, 3, 20, 30, 31, 32}},
|
||||
{" 7 , 8 ", []int64{7, 8}},
|
||||
{"4-4", []int64{4}},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got, err := parseSeeds(testCase.specification)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", testCase.specification, err)
|
||||
}
|
||||
if !slices.Equal(got, testCase.want) {
|
||||
t.Errorf("%q: got %v, want %v", testCase.specification, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSeeds_RejectsSeedZero(t *testing.T) {
|
||||
for _, specification := range []string{"0", "1,0,2", "0-3"} {
|
||||
_, err := parseSeeds(specification)
|
||||
if err == nil {
|
||||
t.Fatalf("%q: expected rejection of seed 0", specification)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not reproducible") {
|
||||
t.Errorf("%q: error should explain why seed 0 is rejected: %v", specification, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSeeds_RejectsMalformed(t *testing.T) {
|
||||
for _, specification := range []string{"", " ", "abc", "1,,2", "5-1", "1-", "-5", "1-2-3", "1.5", "2,2"} {
|
||||
if seeds, err := parseSeeds(specification); err == nil {
|
||||
t.Errorf("%q: expected error, got %v", specification, seeds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
// Hierarchy dumps make trace lines large; match the replay server's ceiling.
|
||||
const maxTraceLineBytes = 16 * 1024 * 1024
|
||||
|
||||
// traceSummary is everything the analysis needs from one run, so it never has
|
||||
// to open trace.jsonl again.
|
||||
type traceSummary struct {
|
||||
Steps int `json:"steps"`
|
||||
FirstViolationOriginStep *int `json:"first_violation_origin_step"`
|
||||
FirstViolationDetectedStep *int `json:"first_violation_detected_step"`
|
||||
FirstViolationProperties []string `json:"first_violation_properties,omitempty"`
|
||||
FirstViolationReason string `json:"first_violation_reason,omitempty"`
|
||||
FirstViolationIsError bool `json:"first_violation_is_error,omitempty"`
|
||||
ViolatedProperties []string `json:"violated_properties,omitempty"`
|
||||
}
|
||||
|
||||
type traceLine struct {
|
||||
Index int `json:"step"`
|
||||
// Hierarchy is read only for its presence: the run-end finalize line is the
|
||||
// one line carrying violations without an observed hierarchy.
|
||||
Hierarchy json.RawMessage `json:"hierarchy"`
|
||||
Violations []string `json:"violations"`
|
||||
Witnesses map[string]trace.Witness `json:"witnesses"`
|
||||
}
|
||||
|
||||
// findRunDirectory returns the run directory `sanderling test` created inside
|
||||
// seedDirectory. Names are UTC timestamps, so the last one sorted is the newest.
|
||||
func findRunDirectory(seedDirectory string) (string, error) {
|
||||
entries, err := os.ReadDir(seedDirectory)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read seed dir: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(seedDirectory, entry.Name(), "meta.json")); err != nil {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", fmt.Errorf("no run directory with meta.json under %s", seedDirectory)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names[len(names)-1], nil
|
||||
}
|
||||
|
||||
// summarizeRun locates the run under seedDirectory and reduces its trace to the
|
||||
// fields the analysis reads. The returned path is relative to seedDirectory.
|
||||
func summarizeRun(seedDirectory string) (string, traceSummary, error) {
|
||||
name, err := findRunDirectory(seedDirectory)
|
||||
if err != nil {
|
||||
return "", traceSummary{}, err
|
||||
}
|
||||
summary, err := summarizeTrace(filepath.Join(seedDirectory, name, "trace.jsonl"))
|
||||
if err != nil {
|
||||
return name, traceSummary{}, err
|
||||
}
|
||||
return name, summary, nil
|
||||
}
|
||||
|
||||
func summarizeTrace(tracePath string) (traceSummary, error) {
|
||||
file, err := os.Open(tracePath)
|
||||
if err != nil {
|
||||
return traceSummary{}, fmt.Errorf("open trace: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var summary traceSummary
|
||||
violated := map[string]bool{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxTraceLineBytes)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
raw := strings.TrimSpace(scanner.Text())
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
var line traceLine
|
||||
if err := json.Unmarshal([]byte(raw), &line); err != nil {
|
||||
return traceSummary{}, fmt.Errorf("trace line %d: %w", lineNumber, err)
|
||||
}
|
||||
// The finalize line is synthetic: it reports obligations that never
|
||||
// discharged, at an index one past the last step actually executed.
|
||||
synthetic := len(line.Violations) > 0 && len(line.Hierarchy) == 0
|
||||
if !synthetic && line.Index > summary.Steps {
|
||||
summary.Steps = line.Index
|
||||
}
|
||||
for _, property := range line.Violations {
|
||||
violated[property] = true
|
||||
recordViolation(&summary, line, property)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return traceSummary{}, fmt.Errorf("read trace: %w", err)
|
||||
}
|
||||
if len(violated) > 0 {
|
||||
summary.ViolatedProperties = slices.Sorted(maps.Keys(violated))
|
||||
}
|
||||
slices.Sort(summary.FirstViolationProperties)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// recordViolation folds one violated property into the first-violation fields.
|
||||
// The origin step (the step that armed the failed obligation) orders the event,
|
||||
// because that is the step count the survival analysis measures.
|
||||
func recordViolation(summary *traceSummary, line traceLine, property string) {
|
||||
origin, detected := line.Index, line.Index
|
||||
witness := line.Witnesses[property]
|
||||
if witness.Step > 0 {
|
||||
origin = witness.Step
|
||||
}
|
||||
if witness.DetectedStep > 0 {
|
||||
detected = witness.DetectedStep
|
||||
}
|
||||
switch {
|
||||
case summary.FirstViolationOriginStep == nil || origin < *summary.FirstViolationOriginStep:
|
||||
summary.FirstViolationOriginStep = &origin
|
||||
summary.FirstViolationDetectedStep = &detected
|
||||
summary.FirstViolationProperties = []string{property}
|
||||
summary.FirstViolationReason = witness.Reason
|
||||
summary.FirstViolationIsError = witness.IsError
|
||||
case origin == *summary.FirstViolationOriginStep:
|
||||
summary.FirstViolationProperties = append(summary.FirstViolationProperties, property)
|
||||
if detected < *summary.FirstViolationDetectedStep {
|
||||
summary.FirstViolationDetectedStep = &detected
|
||||
summary.FirstViolationReason = witness.Reason
|
||||
summary.FirstViolationIsError = witness.IsError
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
func observedStep(index int) trace.Step {
|
||||
return trace.Step{
|
||||
Index: index,
|
||||
Timestamp: time.Date(2026, 8, 12, 9, 0, index, 0, time.UTC),
|
||||
Screen: "Home",
|
||||
Hierarchy: &hierarchy.Tree{Elements: []*hierarchy.Element{{ResourceID: "root"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func writeRunDirectory(t *testing.T, seedDirectory, name string, steps []trace.Step) string {
|
||||
t.Helper()
|
||||
directory := filepath.Join(seedDirectory, name)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := json.Marshal(trace.Meta{Seed: 11, Platform: "web", Arm: "seeded-baseline"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "meta.json"), meta, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
for _, step := range steps {
|
||||
if err := encoder.Encode(step); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "trace.jsonl"), buffer.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
func TestSummarizeRun_CleanRunIsCensored(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{
|
||||
observedStep(1), observedStep(2), observedStep(3), observedStep(4), observedStep(5),
|
||||
})
|
||||
|
||||
name, summary, err := summarizeRun(seedDirectory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "20260812-090000" {
|
||||
t.Errorf("run directory: got %q", name)
|
||||
}
|
||||
if summary.Steps != 5 {
|
||||
t.Errorf("steps: got %d, want 5", summary.Steps)
|
||||
}
|
||||
if summary.FirstViolationOriginStep != nil || summary.FirstViolationDetectedStep != nil {
|
||||
t.Errorf("clean run reported a violation: %+v", summary)
|
||||
}
|
||||
if len(summary.ViolatedProperties) != 0 {
|
||||
t.Errorf("violated properties: got %v", summary.ViolatedProperties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRun_UsesWitnessOriginNotDetectionStep(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
violating := observedStep(9)
|
||||
violating.Violations = []string{"balanceNeverNegative"}
|
||||
violating.Witnesses = map[string]trace.Witness{
|
||||
"balanceNeverNegative": {Reason: "balance went negative", Step: 4, DetectedStep: 9},
|
||||
}
|
||||
writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{
|
||||
observedStep(1), observedStep(2), violating, observedStep(10),
|
||||
})
|
||||
|
||||
_, summary, err := summarizeRun(seedDirectory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.FirstViolationOriginStep == nil || *summary.FirstViolationOriginStep != 4 {
|
||||
t.Fatalf("origin step: got %v, want 4", summary.FirstViolationOriginStep)
|
||||
}
|
||||
if summary.FirstViolationDetectedStep == nil || *summary.FirstViolationDetectedStep != 9 {
|
||||
t.Fatalf("detected step: got %v, want 9", summary.FirstViolationDetectedStep)
|
||||
}
|
||||
if summary.FirstViolationReason != "balance went negative" {
|
||||
t.Errorf("reason: got %q", summary.FirstViolationReason)
|
||||
}
|
||||
if !slices.Equal(summary.FirstViolationProperties, []string{"balanceNeverNegative"}) {
|
||||
t.Errorf("first violation properties: got %v", summary.FirstViolationProperties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRun_EarliestOriginWinsOverEarliestDetection(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
early := observedStep(3)
|
||||
early.Violations = []string{"detectedFirst"}
|
||||
early.Witnesses = map[string]trace.Witness{"detectedFirst": {Step: 3, DetectedStep: 3}}
|
||||
late := observedStep(8)
|
||||
late.Violations = []string{"armedFirst"}
|
||||
late.Witnesses = map[string]trace.Witness{"armedFirst": {Step: 1, DetectedStep: 8, IsError: true}}
|
||||
writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{observedStep(1), early, late})
|
||||
|
||||
_, summary, err := summarizeRun(seedDirectory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.FirstViolationOriginStep == nil || *summary.FirstViolationOriginStep != 1 {
|
||||
t.Fatalf("origin step: got %v, want 1", summary.FirstViolationOriginStep)
|
||||
}
|
||||
if !summary.FirstViolationIsError {
|
||||
t.Error("is_error should come from the earliest-origin violation")
|
||||
}
|
||||
if !slices.Equal(summary.ViolatedProperties, []string{"armedFirst", "detectedFirst"}) {
|
||||
t.Errorf("violated properties: got %v", summary.ViolatedProperties)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRun_FinalizeLineIsNotAStep(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
finalize := trace.Step{
|
||||
Index: 4,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Violations: []string{"eventuallySettles"},
|
||||
Witnesses: map[string]trace.Witness{"eventuallySettles": {Step: 2, DetectedStep: 4}},
|
||||
}
|
||||
writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{
|
||||
observedStep(1), observedStep(2), observedStep(3), finalize,
|
||||
})
|
||||
|
||||
_, summary, err := summarizeRun(seedDirectory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Steps != 3 {
|
||||
t.Errorf("steps: got %d, want 3 (the finalize line is synthetic)", summary.Steps)
|
||||
}
|
||||
if summary.FirstViolationOriginStep == nil || *summary.FirstViolationOriginStep != 2 {
|
||||
t.Fatalf("origin step: got %v, want 2", summary.FirstViolationOriginStep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRun_FallsBackToStepIndexWithoutWitness(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
violating := observedStep(6)
|
||||
violating.Violations = []string{"noWitness"}
|
||||
writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{observedStep(5), violating})
|
||||
|
||||
_, summary, err := summarizeRun(seedDirectory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.FirstViolationOriginStep == nil || *summary.FirstViolationOriginStep != 6 {
|
||||
t.Fatalf("origin step: got %v, want 6", summary.FirstViolationOriginStep)
|
||||
}
|
||||
if summary.FirstViolationDetectedStep == nil || *summary.FirstViolationDetectedStep != 6 {
|
||||
t.Fatalf("detected step: got %v, want 6", summary.FirstViolationDetectedStep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRun_PicksNewestRunDirectory(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{observedStep(1)})
|
||||
writeRunDirectory(t, seedDirectory, "20260812-093000", []trace.Step{observedStep(1), observedStep(2)})
|
||||
|
||||
name, summary, err := summarizeRun(seedDirectory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "20260812-093000" || summary.Steps != 2 {
|
||||
t.Errorf("got %q with %d steps", name, summary.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRun_MissingRunDirectory(t *testing.T) {
|
||||
if _, _, err := summarizeRun(t.TempDir()); err == nil {
|
||||
t.Fatal("expected an error when no run directory was produced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeTrace_MalformedLine(t *testing.T) {
|
||||
seedDirectory := t.TempDir()
|
||||
directory := writeRunDirectory(t, seedDirectory, "20260812-090000", []trace.Step{observedStep(1)})
|
||||
if err := os.WriteFile(filepath.Join(directory, "trace.jsonl"), []byte("{\"step\":1}\n{not json\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := summarizeRun(seedDirectory); err == nil {
|
||||
t.Fatal("expected an error for a malformed trace line")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user