mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
installs, builds and serves each implementation on its own port, then hands the campaign tool the same seed slice, step budget and generator for all of them, so a difference between implementations is not a difference in exploration. the generator and platform are fixed rather than exposed.
483 lines
12 KiB
Go
483 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The test binary doubles as the stub implementation's server and as the
|
|
// fetcher the stub campaign uses, so the sweep drives a real preview process
|
|
// over a real port and the served URL is answered by a real HTTP server.
|
|
func TestMain(m *testing.M) {
|
|
switch {
|
|
case os.Getenv("SWEEP_TEST_SERVE_PORT") != "":
|
|
serveUntilKilled(os.Getenv("SWEEP_TEST_SERVE_PORT"))
|
|
case os.Getenv("SWEEP_TEST_FETCH_URL") != "":
|
|
recordFetch(
|
|
os.Getenv("SWEEP_TEST_FETCH_URL"),
|
|
os.Getenv("SWEEP_TEST_FETCH_LOG"),
|
|
)
|
|
default:
|
|
os.Exit(m.Run())
|
|
}
|
|
}
|
|
|
|
func serveUntilKilled(port string) {
|
|
handler := http.HandlerFunc(
|
|
func(writer http.ResponseWriter, request *http.Request) {
|
|
fmt.Fprintf(writer, "%s %s", port, request.URL.RequestURI())
|
|
},
|
|
)
|
|
http.ListenAndServe("localhost:"+port, handler)
|
|
}
|
|
|
|
func recordFetch(url, logPath string) {
|
|
line := ""
|
|
response, err := http.Get(url)
|
|
if err != nil {
|
|
line = fmt.Sprintf("%s -> error %v\n", url, err)
|
|
} else {
|
|
body, _ := io.ReadAll(response.Body)
|
|
response.Body.Close()
|
|
line = fmt.Sprintf("%s -> %d %s\n", url, response.StatusCode, body)
|
|
}
|
|
logFile, err := os.OpenFile(
|
|
logPath,
|
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
|
|
0o644,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
logFile.WriteString(line)
|
|
logFile.Close()
|
|
}
|
|
|
|
// stubBun answers install, fails to build impl-02, and serves the preview from
|
|
// the test binary on the port it was given.
|
|
const stubBun = `#!/bin/sh
|
|
echo "$PWD $*" >> "%[1]s"
|
|
if [ "$1" = "run" ] && [ "$2" = "build" ]; then
|
|
case "$PWD" in *impl-02) echo "TS2322: type error" >&2; exit 1 ;; esac
|
|
exit 0
|
|
fi
|
|
if [ "$1" = "run" ] && [ "$2" = "preview" ]; then
|
|
port=""
|
|
previous=""
|
|
for argument in "$@"; do
|
|
if [ "$previous" = "--port" ]; then port="$argument"; fi
|
|
previous="$argument"
|
|
done
|
|
SWEEP_TEST_SERVE_PORT="$port" exec "%[2]s"
|
|
fi
|
|
exit 0
|
|
`
|
|
|
|
// stubCampaign records the argv it was handed and whether the sweep manifest
|
|
// was already on disk when it ran, fetches the URL it was told to drive, writes
|
|
// the campaign directory the real tool would write, and fails impl-03 seed 4.
|
|
const stubCampaign = `#!/bin/sh
|
|
output=""
|
|
seed=""
|
|
url=""
|
|
previous=""
|
|
for argument in "$@"; do
|
|
case "$previous" in
|
|
--output) output="$argument" ;;
|
|
--seeds) seed="$argument" ;;
|
|
--bundle-id) url="$argument" ;;
|
|
esac
|
|
previous="$argument"
|
|
done
|
|
manifest=missing
|
|
if [ -f "%[1]s" ]; then manifest=present; fi
|
|
echo "manifest=$manifest argv: $*" >> "%[2]s"
|
|
SWEEP_TEST_FETCH_URL="$url" SWEEP_TEST_FETCH_LOG="%[3]s" "%[4]s"
|
|
mkdir -p "$output"
|
|
printf '{"arm":"stub","seeds":[%%s]}\n' "$seed" > "$output/campaign.json"
|
|
echo "stub campaign seed=$seed url=$url"
|
|
case "$output" in
|
|
*impl-03/seed-4) exit 1 ;;
|
|
esac
|
|
exit 0
|
|
`
|
|
|
|
func TestRun_EndToEndAgainstStubBunAndCampaign(t *testing.T) {
|
|
root := t.TempDir()
|
|
implementations := filepath.Join(root, "implementations")
|
|
for _, name := range []string{"impl-01", "impl-02", "impl-03"} {
|
|
if err := os.MkdirAll(filepath.Join(implementations, name), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
specPath := filepath.Join(root, "relay.ts")
|
|
if err := os.WriteFile(specPath, []byte("export const properties = [];\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output := filepath.Join(root, "campaigns")
|
|
bunLog := filepath.Join(root, "bun.log")
|
|
campaignLog := filepath.Join(root, "campaign.log")
|
|
fetchLog := filepath.Join(root, "fetch.log")
|
|
testBinary, err := filepath.Abs(os.Args[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bunPath := writeScript(
|
|
t,
|
|
filepath.Join(root, "stub-bun"),
|
|
fmt.Sprintf(stubBun, bunLog, testBinary),
|
|
)
|
|
campaignPath := writeScript(
|
|
t,
|
|
filepath.Join(root, "stub-campaign"),
|
|
fmt.Sprintf(
|
|
stubCampaign,
|
|
filepath.Join(output, manifestFileName),
|
|
campaignLog,
|
|
fetchLog,
|
|
testBinary,
|
|
),
|
|
)
|
|
sanderlingPath := writeScript(
|
|
t,
|
|
filepath.Join(root, "stub-sanderling"),
|
|
"#!/bin/sh\nexit 0\n",
|
|
)
|
|
basePort := freePortRange(t, 3)
|
|
|
|
var stdout bytes.Buffer
|
|
err = run([]string{
|
|
"--implementations", implementations,
|
|
"--spec", specPath,
|
|
"--seeds", "4-5",
|
|
"--max-steps", "40",
|
|
"--duration", "30s",
|
|
"--concurrency", "2",
|
|
"--base-port", fmt.Sprint(basePort),
|
|
"--output", output,
|
|
"--bun", bunPath,
|
|
"--campaign", campaignPath,
|
|
"--sanderling", sanderlingPath,
|
|
}, &stdout, io.Discard)
|
|
if err == nil ||
|
|
!strings.Contains(err.Error(), "1 of 3 implementations never ran") {
|
|
t.Fatalf(
|
|
"expected the failed build and the failed campaign to be reported, got %v",
|
|
err,
|
|
)
|
|
}
|
|
|
|
var recorded manifest
|
|
manifestBody, err := os.ReadFile(filepath.Join(output, manifestFileName))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(manifestBody, &recorded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Equal(recorded.Seeds, []int64{4, 5}) {
|
|
t.Errorf("intended seeds: got %v", recorded.Seeds)
|
|
}
|
|
if len(recorded.Implementations) != 3 {
|
|
t.Fatalf("intended implementations: got %v", recorded.Implementations)
|
|
}
|
|
for index, planned := range recorded.Implementations {
|
|
wantPort := basePort + index
|
|
if planned.Port != wantPort {
|
|
t.Errorf(
|
|
"%s port: got %d, want %d",
|
|
planned.Name,
|
|
planned.Port,
|
|
wantPort,
|
|
)
|
|
}
|
|
wantURL := fmt.Sprintf("http://localhost:%d/?seed={seed}", wantPort)
|
|
if planned.URLTemplate != wantURL {
|
|
t.Errorf(
|
|
"%s url template: got %q, want %q",
|
|
planned.Name,
|
|
planned.URLTemplate,
|
|
wantURL,
|
|
)
|
|
}
|
|
}
|
|
if recorded.Generator != "seeded" || recorded.MaxSteps != 40 {
|
|
t.Errorf(
|
|
"manifest generator/budget: got %q/%d",
|
|
recorded.Generator,
|
|
recorded.MaxSteps,
|
|
)
|
|
}
|
|
|
|
// impl-02 fails its build, so the two implementations either side of it
|
|
// still have to reach the campaign tool with both seeds.
|
|
campaignLines := readLines(t, campaignLog)
|
|
if len(campaignLines) != 4 {
|
|
t.Fatalf(
|
|
"campaign invocations: got %d, want 4:\n%s",
|
|
len(campaignLines),
|
|
strings.Join(campaignLines, "\n"),
|
|
)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, line := range campaignLines {
|
|
if !strings.HasPrefix(line, "manifest=present") {
|
|
t.Errorf(
|
|
"a campaign ran before the sweep manifest was written: %q",
|
|
line,
|
|
)
|
|
}
|
|
arguments := strings.Fields(strings.SplitN(line, "argv: ", 2)[1])
|
|
arm := argumentValue(arguments, "--arm")
|
|
seed := argumentValue(arguments, "--seeds")
|
|
bundle := argumentValue(arguments, "--bundle-id")
|
|
port := basePort + slices.Index(
|
|
[]string{"impl-01", "impl-02", "impl-03"},
|
|
arm,
|
|
)
|
|
wantBundle := fmt.Sprintf("http://localhost:%d/?seed=%s", port, seed)
|
|
if bundle != wantBundle {
|
|
t.Errorf(
|
|
"%s seed %s: bundle id %q, want %q",
|
|
arm,
|
|
seed,
|
|
bundle,
|
|
wantBundle,
|
|
)
|
|
}
|
|
if got := argumentValue(arguments, "--output"); got != filepath.Join(
|
|
output,
|
|
arm,
|
|
"seed-"+seed,
|
|
) {
|
|
t.Errorf("%s seed %s: campaign output %q", arm, seed, got)
|
|
}
|
|
if got := argumentValue(arguments, "--sanderling"); got != sanderlingPath {
|
|
t.Errorf("%s seed %s: sanderling path %q", arm, seed, got)
|
|
}
|
|
seen[arm+"/"+seed] = true
|
|
}
|
|
for _, want := range []string{"impl-01/4", "impl-01/5", "impl-03/4", "impl-03/5"} {
|
|
if !seen[want] {
|
|
t.Errorf("%s never reached the campaign tool", want)
|
|
}
|
|
}
|
|
|
|
// What the served page actually saw: the right port for the
|
|
// implementation, carrying the same seed the campaign was given.
|
|
fetched := readLines(t, fetchLog)
|
|
for _, want := range []string{
|
|
fmt.Sprintf("http://localhost:%d/?seed=4 -> 200 %d /?seed=4", basePort, basePort),
|
|
fmt.Sprintf("http://localhost:%d/?seed=5 -> 200 %d /?seed=5", basePort, basePort),
|
|
fmt.Sprintf("http://localhost:%d/?seed=4 -> 200 %d /?seed=4", basePort+2, basePort+2),
|
|
fmt.Sprintf("http://localhost:%d/?seed=5 -> 200 %d /?seed=5", basePort+2, basePort+2),
|
|
} {
|
|
if !slices.Contains(fetched, want) {
|
|
t.Errorf(
|
|
"the served page never saw %q:\n%s",
|
|
want,
|
|
strings.Join(fetched, "\n"),
|
|
)
|
|
}
|
|
}
|
|
|
|
records := readRecords(t, filepath.Join(output, recordsFileName))
|
|
if len(records) != 3 {
|
|
t.Fatalf("implementation records: got %d, want 3", len(records))
|
|
}
|
|
byName := map[string]implementationRecord{}
|
|
for _, record := range records {
|
|
byName[record.Name] = record
|
|
}
|
|
failed := byName["impl-02"]
|
|
if failed.FailedStage != stageBuild || len(failed.Runs) != 0 {
|
|
t.Errorf(
|
|
"impl-02: got stage %q with %d runs, want a build failure and no runs",
|
|
failed.FailedStage,
|
|
len(failed.Runs),
|
|
)
|
|
}
|
|
if !strings.Contains(failed.Error, "build.log") {
|
|
t.Errorf("impl-02 error should point at its log: %q", failed.Error)
|
|
}
|
|
buildLog, err := os.ReadFile(
|
|
filepath.Join(output, "impl-02", stageBuild+".log"),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(buildLog), "TS2322") {
|
|
t.Errorf("impl-02 build log lost the compiler error: %q", buildLog)
|
|
}
|
|
for _, name := range []string{"impl-01", "impl-03"} {
|
|
record := byName[name]
|
|
if record.FailedStage != "" || len(record.Runs) != 2 {
|
|
t.Errorf(
|
|
"%s: stage %q with %d runs, want no failure and 2 runs",
|
|
name,
|
|
record.FailedStage,
|
|
len(record.Runs),
|
|
)
|
|
}
|
|
if record.MonotonicMillis <= 0 {
|
|
t.Errorf(
|
|
"%s took %d ms, so nothing timed how long it worked",
|
|
name,
|
|
record.MonotonicMillis,
|
|
)
|
|
}
|
|
for _, run := range record.Runs {
|
|
if run.MonotonicMillis <= 0 {
|
|
t.Errorf(
|
|
"%s seed %d took %d ms, so nothing timed the campaign",
|
|
name,
|
|
run.Seed,
|
|
run.MonotonicMillis,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if exit := byName["impl-03"].Runs[0].ExitCode; exit != 1 {
|
|
t.Errorf("impl-03 seed 4 exit code: got %d, want 1", exit)
|
|
}
|
|
if exit := byName["impl-03"].Runs[1].ExitCode; exit != 0 {
|
|
t.Errorf(
|
|
"impl-03 seed 5 ran after seed 4 failed and should have exited 0, got %d",
|
|
exit,
|
|
)
|
|
}
|
|
|
|
for _, name := range []string{"impl-01", "impl-03"} {
|
|
for _, seed := range []string{"4", "5"} {
|
|
directory := filepath.Join(output, name, "seed-"+seed)
|
|
if _, err := os.Stat(filepath.Join(directory, "campaign.json")); err != nil {
|
|
t.Errorf(
|
|
"%s seed %s: no campaign directory: %v",
|
|
name,
|
|
seed,
|
|
err,
|
|
)
|
|
}
|
|
log, err := os.ReadFile(filepath.Join(directory, "campaign.log"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(log), "stub campaign seed="+seed) {
|
|
t.Errorf(
|
|
"%s seed %s: campaign output was not captured: %q",
|
|
name,
|
|
seed,
|
|
log,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
installed := readLines(t, bunLog)
|
|
for _, name := range []string{"impl-01", "impl-02", "impl-03"} {
|
|
if !slices.Contains(
|
|
installed,
|
|
filepath.Join(implementations, name)+" install",
|
|
) {
|
|
t.Errorf(
|
|
"%s was never installed:\n%s",
|
|
name,
|
|
strings.Join(installed, "\n"),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Every preview server the sweep started is gone with it: a leaked one
|
|
// holds its port, and the next sweep would be served by the old build.
|
|
client := &http.Client{Timeout: 2 * time.Second}
|
|
for _, port := range []int{basePort, basePort + 2} {
|
|
if response, err := client.Get(readinessURL(port)); err == nil {
|
|
response.Body.Close()
|
|
t.Errorf("port %d is still served after the sweep finished", port)
|
|
}
|
|
}
|
|
if !strings.Contains(stdout.String(), "failed at build") {
|
|
t.Errorf(
|
|
"progress output does not name the build failure: %q",
|
|
stdout.String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func writeScript(t *testing.T, path, body string) string {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func readLines(t *testing.T, path string) []string {
|
|
t.Helper()
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var lines []string
|
|
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
|
for scanner.Scan() {
|
|
if line := strings.TrimSpace(scanner.Text()); line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func readRecords(t *testing.T, path string) []implementationRecord {
|
|
t.Helper()
|
|
var records []implementationRecord
|
|
for _, line := range readLines(t, path) {
|
|
var record implementationRecord
|
|
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
|
t.Fatalf("%s: %v", line, err)
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
return records
|
|
}
|
|
|
|
// freePortRange finds count consecutive free ports, which is what the sweep
|
|
// hands out: one port per implementation from --base-port upwards.
|
|
func freePortRange(t *testing.T, count int) int {
|
|
t.Helper()
|
|
for range 100 {
|
|
base := 20000 + rand.Intn(20000)
|
|
if portsAreFree(base, count) {
|
|
return base
|
|
}
|
|
}
|
|
t.Fatalf("no run of %d free ports", count)
|
|
return 0
|
|
}
|
|
|
|
func portsAreFree(base, count int) bool {
|
|
for offset := range count {
|
|
listener, err := net.Listen(
|
|
"tcp",
|
|
fmt.Sprintf("localhost:%d", base+offset),
|
|
)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
listener.Close()
|
|
}
|
|
return true
|
|
}
|