mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(corpus-sweep): run one specification against a served corpus of implementations
same fixed campaign as implementation-sweep, over a corpus that needs no build. each implementation gets its own port: the corpus holds pairs that write the same localStorage key, and one shared origin is one stored record shared between them.
This commit is contained in:
1 parent
146152a3af
commit
341d6a0614
9 files changed
+2103
No files matched your search
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The corpus is tastejs/todomvc. Its examples/ directory holds 48
|
||||
// implementations of one requirement; five are excluded and the 43 that remain
|
||||
// are this experiment's population.
|
||||
//
|
||||
// The population is named here rather than read from whatever directories
|
||||
// happen to exist, so a corpus at the wrong commit fails verifyCorpus instead
|
||||
// of quietly sweeping a different sample and reporting it as this one.
|
||||
var includedImplementations = []string{
|
||||
"angular-dart", "angular2", "angular2_es2015", "angularjs", "angularjs_require",
|
||||
"aurelia", "backbone", "backbone_marionette", "backbone_require", "binding-scala",
|
||||
"canjs", "canjs_require", "closure", "dijon", "dojo",
|
||||
"duel", "elm", "emberjs", "enyo_backbone", "exoskeleton",
|
||||
"jquery", "js_of_ocaml", "jsblocks", "knockback", "knockoutjs",
|
||||
"knockoutjs_require", "kotlin-react", "lavaca_require", "mithril", "polymer",
|
||||
"ractive", "react", "react-alt", "react-backbone", "reagent",
|
||||
"riotjs", "scalajs-react", "typescript-angular", "typescript-backbone", "typescript-react",
|
||||
"vanilla-es6", "vanillajs", "vue",
|
||||
}
|
||||
|
||||
// excludedImplementations are the five directories under examples/ that the
|
||||
// corpus survey dropped. They are listed rather than merely omitted so
|
||||
// verifyCorpus can insist the corpus holds exactly these 48 names: an example
|
||||
// added or renamed upstream then stops the sweep rather than silently shrinking
|
||||
// or growing the sample.
|
||||
var excludedImplementations = []string{
|
||||
"cujo", "emberjs_require", "firebase-angular", "gwt", "react-hooks",
|
||||
}
|
||||
|
||||
const examplesDirectory = "examples"
|
||||
|
||||
// documentPath names the served document for the implementations that do not
|
||||
// keep an index.html at the root of their example directory.
|
||||
var documentPath = map[string]string{
|
||||
"angular-dart": "examples/angular-dart/web/index.html",
|
||||
"duel": "examples/duel/www/index.html",
|
||||
}
|
||||
|
||||
func documentFor(name string) string {
|
||||
if override, ok := documentPath[name]; ok {
|
||||
return override
|
||||
}
|
||||
return examplesDirectory + "/" + name + "/index.html"
|
||||
}
|
||||
|
||||
// implementation is one member of the population, with the port it owns for the
|
||||
// whole sweep. The port is what keeps implementations apart: every one is
|
||||
// served on its own, so every one is its own web origin and localStorage keeps
|
||||
// their records in separate partitions. Four pairs in this corpus write the
|
||||
// same key, and one origin between them is one record between them.
|
||||
type implementation struct {
|
||||
Name string
|
||||
Document string
|
||||
Port int
|
||||
}
|
||||
|
||||
func (i implementation) Origin() string {
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", i.Port)
|
||||
}
|
||||
|
||||
func (i implementation) URL() string {
|
||||
return i.Origin() + "/" + i.Document
|
||||
}
|
||||
|
||||
// verifyCorpus insists the corpus holds exactly the 48 example directories this
|
||||
// population was drawn from. A sweep against a different checkout would still
|
||||
// run, and its 43 arms would still be labelled, which is why the check is here
|
||||
// and not left to whoever reads the results.
|
||||
func verifyCorpus(corpusRoot string) error {
|
||||
entries, err := os.ReadDir(filepath.Join(corpusRoot, examplesDirectory))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--corpus: %w", err)
|
||||
}
|
||||
var found []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
found = append(found, entry.Name())
|
||||
}
|
||||
}
|
||||
expected := slices.Concat(includedImplementations, excludedImplementations)
|
||||
slices.Sort(expected)
|
||||
slices.Sort(found)
|
||||
if slices.Equal(expected, found) {
|
||||
return nil
|
||||
}
|
||||
var missing, unexpected []string
|
||||
for _, name := range expected {
|
||||
if !slices.Contains(found, name) {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
for _, name := range found {
|
||||
if !slices.Contains(expected, name) {
|
||||
unexpected = append(unexpected, name)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%s/%s is not the corpus this population was drawn from: missing %v, unexpected %v",
|
||||
corpusRoot,
|
||||
examplesDirectory,
|
||||
missing,
|
||||
unexpected,
|
||||
)
|
||||
}
|
||||
|
||||
// selectImplementations resolves --implementations against the population. An
|
||||
// empty selection is the whole population, which is what a real sweep runs; a
|
||||
// named subset is for smoke runs and is recorded in the manifest like any other
|
||||
// intent.
|
||||
func selectImplementations(selection string) ([]string, error) {
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return slices.Clone(includedImplementations), nil
|
||||
}
|
||||
var names []string
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(selection, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("empty implementation in %q", selection)
|
||||
}
|
||||
if !slices.Contains(includedImplementations, name) {
|
||||
return nil, fmt.Errorf(
|
||||
"%q is not one of the %d implementations in this population",
|
||||
name,
|
||||
len(includedImplementations),
|
||||
)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("duplicate implementation %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// planImplementations gives every selected implementation its document and its
|
||||
// own port, in population order so the manifest can name the URL each arm was
|
||||
// served from before anything has been served.
|
||||
func planImplementations(
|
||||
corpusRoot string,
|
||||
names []string,
|
||||
basePort int,
|
||||
) ([]implementation, error) {
|
||||
if basePort+len(names)-1 > 65535 {
|
||||
return nil, fmt.Errorf(
|
||||
"--base-port %d leaves no room for %d implementations",
|
||||
basePort,
|
||||
len(names),
|
||||
)
|
||||
}
|
||||
planned := make([]implementation, 0, len(names))
|
||||
for index, name := range names {
|
||||
document := documentFor(name)
|
||||
if _, err := os.Stat(filepath.Join(corpusRoot, filepath.FromSlash(document))); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
planned = append(planned, implementation{
|
||||
Name: name,
|
||||
Document: document,
|
||||
Port: basePort + index,
|
||||
})
|
||||
}
|
||||
return planned, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeCorpus builds a tree with the shape the sweep expects: every example
|
||||
// directory the population was drawn from, each holding the document that
|
||||
// implementation is served at.
|
||||
func writeCorpus(t *testing.T, names []string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, name := range names {
|
||||
if err := os.MkdirAll(filepath.Join(root, examplesDirectory, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := filepath.Join(root, filepath.FromSlash(documentFor(name)))
|
||||
if err := os.MkdirAll(filepath.Dir(document), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "<!doctype html><title>" + name + "</title><ul class=\"todo-list\"></ul>"
|
||||
if err := os.WriteFile(document, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func wholeCorpus(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeCorpus(
|
||||
t,
|
||||
slices.Concat(includedImplementations, excludedImplementations),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPopulation_IsFortyThreeNamesDisjointFromTheExclusions(t *testing.T) {
|
||||
if len(includedImplementations) != 43 {
|
||||
t.Errorf(
|
||||
"population size: got %d, want 43",
|
||||
len(includedImplementations),
|
||||
)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range includedImplementations {
|
||||
if seen[name] {
|
||||
t.Errorf("%q appears twice in the population", name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
for _, name := range excludedImplementations {
|
||||
if seen[name] {
|
||||
t.Errorf("%q is both included and excluded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCorpus_RejectsATreeThatIsNotThisCorpus(t *testing.T) {
|
||||
if err := verifyCorpus(wholeCorpus(t)); err != nil {
|
||||
t.Fatalf(
|
||||
"the corpus this population was drawn from should verify: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
short := writeCorpus(
|
||||
t,
|
||||
slices.Concat(includedImplementations[1:], excludedImplementations),
|
||||
)
|
||||
err := verifyCorpus(short)
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"a corpus missing an implementation swept 42 arms and reported them as 43",
|
||||
)
|
||||
}
|
||||
if !strings.Contains(err.Error(), includedImplementations[0]) {
|
||||
t.Errorf("the error should name what is missing: %v", err)
|
||||
}
|
||||
|
||||
extra := writeCorpus(
|
||||
t,
|
||||
slices.Concat(
|
||||
includedImplementations,
|
||||
excludedImplementations,
|
||||
[]string{"svelte"},
|
||||
),
|
||||
)
|
||||
err = verifyCorpus(extra)
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"a corpus with an implementation this population never drew from verified",
|
||||
)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "svelte") {
|
||||
t.Errorf("the error should name what is unexpected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentFor_ServesTheOverriddenPathForImplementationsWithoutARootIndex(
|
||||
t *testing.T,
|
||||
) {
|
||||
for name, want := range map[string]string{
|
||||
"angular-dart": "examples/angular-dart/web/index.html",
|
||||
"duel": "examples/duel/www/index.html",
|
||||
"vanillajs": "examples/vanillajs/index.html",
|
||||
"react": "examples/react/index.html",
|
||||
} {
|
||||
if got := documentFor(name); got != want {
|
||||
t.Errorf("%s document: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanImplementations_RefusesAnImplementationWhoseDocumentIsMissing(
|
||||
t *testing.T,
|
||||
) {
|
||||
root := wholeCorpus(t)
|
||||
if err := os.Remove(filepath.Join(root, filepath.FromSlash(documentFor("duel")))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := planImplementations(root, []string{"duel"}, 5400)
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"an implementation whose document is missing would be swept as a 404 page",
|
||||
)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duel") {
|
||||
t.Errorf("the error should name the implementation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectImplementations_DefaultsToThePopulationAndRejectsNamesOutsideIt(
|
||||
t *testing.T,
|
||||
) {
|
||||
all, err := selectImplementations("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(all, includedImplementations) {
|
||||
t.Errorf(
|
||||
"an empty selection should be the whole population, got %d names",
|
||||
len(all),
|
||||
)
|
||||
}
|
||||
subset, err := selectImplementations("react, angular2_es2015")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(subset, []string{"react", "angular2_es2015"}) {
|
||||
t.Errorf("subset: got %v", subset)
|
||||
}
|
||||
for _, selection := range []string{"cujo", "svelte", "react,react"} {
|
||||
if _, err := selectImplementations(selection); err == nil {
|
||||
t.Errorf("selection %q should have been rejected", selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
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 fetcher the stub campaign uses, so the URL a
|
||||
// campaign is handed is really requested while the sweep is serving, and what
|
||||
// came back is on disk for the test to read.
|
||||
func TestMain(m *testing.M) {
|
||||
if url := os.Getenv("CORPUS_SWEEP_TEST_FETCH_URL"); url != "" {
|
||||
recordFetch(url, os.Getenv("CORPUS_SWEEP_TEST_FETCH_LOG"))
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 one seed.
|
||||
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"
|
||||
CORPUS_SWEEP_TEST_FETCH_URL="$url" CORPUS_SWEEP_TEST_FETCH_LOG="%[3]s" "%[4]s"
|
||||
mkdir -p "$output"
|
||||
printf '{"seeds":[%%s]}\n' "$seed" > "$output/campaign.json"
|
||||
echo "stub campaign seed=$seed url=$url"
|
||||
case "$output" in
|
||||
*angular2_es2015/seed-5) exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
|
||||
func TestRun_EndToEndAgainstAServedCorpusAndAStubCampaign(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
corpus := wholeCorpus(t)
|
||||
// dojo's document is served but cannot be read, so it stalls at serve and
|
||||
// the two implementations either side of it still have to reach the
|
||||
// campaign tool with both seeds.
|
||||
unreadable := filepath.Join(corpus, filepath.FromSlash(documentFor("dojo")))
|
||||
if err := os.Chmod(unreadable, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chmod(unreadable, 0o644) })
|
||||
|
||||
specPath := filepath.Join(root, "todo.ts")
|
||||
if err := os.WriteFile(specPath, []byte("export const properties = [];\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := filepath.Join(root, "campaigns")
|
||||
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)
|
||||
}
|
||||
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",
|
||||
)
|
||||
selected := []string{"angular2", "angular2_es2015", "dojo"}
|
||||
basePort := freePortRange(t, len(selected))
|
||||
|
||||
var stdout bytes.Buffer
|
||||
err = run([]string{
|
||||
"--corpus", corpus,
|
||||
"--spec", specPath,
|
||||
"--implementations", strings.Join(selected, ","),
|
||||
"--seeds", "4-5",
|
||||
"--max-steps", "40",
|
||||
"--duration", "30s",
|
||||
"--concurrency", "2",
|
||||
"--base-port", fmt.Sprint(basePort),
|
||||
"--output", output,
|
||||
"--campaign", campaignPath,
|
||||
"--sanderling", sanderlingPath,
|
||||
}, &stdout, io.Discard)
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "1 of 3 implementations never ran") {
|
||||
t.Fatalf(
|
||||
"expected the stalled implementation and the failed campaign to be reported, got %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
recorded := readManifest(t, filepath.Join(output, manifestFileName))
|
||||
if !slices.Equal(recorded.Seeds, []int64{4, 5}) {
|
||||
t.Errorf("intended seeds: got %v", recorded.Seeds)
|
||||
}
|
||||
if len(recorded.Implementations) != len(selected) {
|
||||
t.Fatalf("intended implementations: got %v", recorded.Implementations)
|
||||
}
|
||||
for index, planned := range recorded.Implementations {
|
||||
if planned.Name != selected[index] {
|
||||
t.Errorf(
|
||||
"implementation %d: got %q, want %q",
|
||||
index,
|
||||
planned.Name,
|
||||
selected[index],
|
||||
)
|
||||
}
|
||||
wantPort := basePort + index
|
||||
wantURL := fmt.Sprintf(
|
||||
"http://127.0.0.1:%d/examples/%s/index.html",
|
||||
wantPort,
|
||||
planned.Name,
|
||||
)
|
||||
if planned.Port != wantPort || planned.URL != wantURL {
|
||||
t.Errorf(
|
||||
"%s: port %d url %q, want %d %q",
|
||||
planned.Name,
|
||||
planned.Port,
|
||||
planned.URL,
|
||||
wantPort,
|
||||
wantURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
if recorded.Generator != "seeded" || recorded.Platform != "web" ||
|
||||
recorded.MaxSteps != 40 {
|
||||
t.Errorf(
|
||||
"manifest generator/platform/budget: %q/%q/%d",
|
||||
recorded.Generator,
|
||||
recorded.Platform,
|
||||
recorded.MaxSteps,
|
||||
)
|
||||
}
|
||||
if recorded.CorpusRoot != corpus {
|
||||
t.Errorf(
|
||||
"manifest corpus root: got %q, want %q",
|
||||
recorded.CorpusRoot,
|
||||
corpus,
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
port := basePort + slices.Index(selected, arm)
|
||||
wantBundle := fmt.Sprintf(
|
||||
"http://127.0.0.1:%d/examples/%s/index.html",
|
||||
port,
|
||||
arm,
|
||||
)
|
||||
if got := argumentValue(arguments, "--bundle-id"); got != wantBundle {
|
||||
t.Errorf(
|
||||
"%s seed %s: bundle id %q, want %q",
|
||||
arm,
|
||||
seed,
|
||||
got,
|
||||
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{"angular2/4", "angular2/5", "angular2_es2015/4", "angular2_es2015/5"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("%s never reached the campaign tool", want)
|
||||
}
|
||||
}
|
||||
|
||||
// What the served page actually returned: each port answered with its own
|
||||
// implementation's document, so no arm was driven against another's.
|
||||
fetched := readLines(t, fetchLog)
|
||||
for index, name := range []string{"angular2", "angular2_es2015"} {
|
||||
want := fmt.Sprintf(
|
||||
"http://127.0.0.1:%d/examples/%s/index.html -> 200 <!doctype html><title>%s</title>",
|
||||
basePort+index,
|
||||
name,
|
||||
name,
|
||||
)
|
||||
matched := 0
|
||||
for _, line := range fetched {
|
||||
if strings.HasPrefix(line, want) {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
if matched != 2 {
|
||||
t.Errorf(
|
||||
"%s was served its own document %d times, want 2:\n%s",
|
||||
name,
|
||||
matched,
|
||||
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
|
||||
}
|
||||
stalled := byName["dojo"]
|
||||
if stalled.FailedStage != stageServe || len(stalled.Runs) != 0 {
|
||||
t.Errorf(
|
||||
"dojo: stage %q with %d runs, want a serve failure and no runs",
|
||||
stalled.FailedStage,
|
||||
len(stalled.Runs),
|
||||
)
|
||||
}
|
||||
for _, name := range []string{"angular2", "angular2_es2015"} {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
if exit := byName["angular2_es2015"].Runs[1].ExitCode; exit != 1 {
|
||||
t.Errorf("angular2_es2015 seed 5 exit code: got %d, want 1", exit)
|
||||
}
|
||||
if exit := byName["angular2_es2015"].Runs[0].ExitCode; exit != 0 {
|
||||
t.Errorf("angular2_es2015 seed 4 exit code: got %d, want 0", exit)
|
||||
}
|
||||
|
||||
for _, name := range []string{"angular2", "angular2_es2015"} {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
for offset := range selected {
|
||||
if response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", basePort+offset)); err == nil {
|
||||
response.Body.Close()
|
||||
t.Errorf(
|
||||
"port %d is still served after the sweep finished",
|
||||
basePort+offset,
|
||||
)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "failed at serve") {
|
||||
t.Errorf(
|
||||
"progress output does not name the stalled implementation: %q",
|
||||
stdout.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RefusesAnOutputDirectoryThatAlreadyHoldsASweep(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
output := filepath.Join(root, "campaigns")
|
||||
if err := os.MkdirAll(output, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(output, manifestFileName), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specPath := filepath.Join(root, "todo.ts")
|
||||
if err := os.WriteFile(specPath, []byte("export const properties = [];\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := run([]string{
|
||||
"--corpus", wholeCorpus(t), "--spec", specPath, "--seeds", "1",
|
||||
"--max-steps", "10", "--output", output,
|
||||
}, io.Discard, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), manifestFileName) {
|
||||
t.Fatalf(
|
||||
"two sweeps sharing a directory would interleave their records, got %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 readManifest(t *testing.T, path string) manifest {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var recorded manifest
|
||||
if err := json.Unmarshal(body, &recorded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return recorded
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func argumentValue(arguments []string, name string) string {
|
||||
index := slices.Index(arguments, name)
|
||||
if index < 0 || index+1 >= len(arguments) {
|
||||
return ""
|
||||
}
|
||||
return arguments[index+1]
|
||||
}
|
||||
|
||||
// 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("127.0.0.1:%d", base+offset),
|
||||
)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Command corpus-sweep runs one specification against every implementation in a
|
||||
// served corpus of independent implementations of the same requirement. It
|
||||
// serves each one on its own port, which is what keeps them apart: the corpus
|
||||
// holds pairs that write the same localStorage key, and one origin shared
|
||||
// between two of them is one stored record shared between them.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/seedspec"
|
||||
)
|
||||
|
||||
// The generator and the platform are fixed rather than exposed: the
|
||||
// pre-registration runs the seeded policy against the served corpus, and a
|
||||
// sweep that could quietly run something else records a comparison nobody made.
|
||||
const (
|
||||
generator = "seeded"
|
||||
platform = "web"
|
||||
)
|
||||
|
||||
// defaultConcurrency is how many implementations are swept at once. fleet.md
|
||||
// measured eight concurrent web campaigns clean at about 1.1 GB resident each,
|
||||
// parallel efficiency 0.83 at eight against 0.87 at six, and said to re-measure
|
||||
// before trusting anything above eight on a contended host. Six sits at the
|
||||
// better efficiency and leaves the emulator farm that shares the host its
|
||||
// slots. Serving costs nothing here: the corpus needs no build and no separate
|
||||
// server process, so a worker is one browser.
|
||||
const defaultConcurrency = 6
|
||||
|
||||
// defaultBasePort starts above the range the model-implementation sweep hands
|
||||
// out, so the two can run on one host without either being served the other's
|
||||
// pages.
|
||||
const defaultBasePort = 5400
|
||||
|
||||
type config struct {
|
||||
corpusRoot string
|
||||
specPath string
|
||||
outputDirectory string
|
||||
implementations []string
|
||||
seeds []int64
|
||||
maxSteps int
|
||||
duration time.Duration
|
||||
concurrency int
|
||||
basePort int
|
||||
campaignPath string
|
||||
sanderlingPath string
|
||||
extraArguments []string
|
||||
}
|
||||
|
||||
const usage = `corpus-sweep runs one seeded campaign per implementation of a served corpus.
|
||||
|
||||
Usage:
|
||||
corpus-sweep --corpus <dir> --spec <path> --seeds <spec>
|
||||
--max-steps <n> --output <dir> [flags]
|
||||
[-- <sanderling test flags>]
|
||||
|
||||
Every implementation is served on its own port, so every one is its own origin
|
||||
and none can read another's stored state, and every one is swept with the same
|
||||
seeds and the same step budget. Each run's arm is the implementation's name.
|
||||
|
||||
Everything after a bare -- reaches every sanderling test call through the
|
||||
campaign tool.
|
||||
`
|
||||
|
||||
func parseArguments(arguments []string, stderr io.Writer) (config, error) {
|
||||
flagSet := flag.NewFlagSet("corpus-sweep", flag.ContinueOnError)
|
||||
flagSet.SetOutput(stderr)
|
||||
flagSet.Usage = func() {
|
||||
fmt.Fprint(stderr, usage)
|
||||
flagSet.PrintDefaults()
|
||||
}
|
||||
var configuration config
|
||||
var seedSpecification string
|
||||
var selection string
|
||||
flagSet.StringVar(
|
||||
&configuration.corpusRoot,
|
||||
"corpus",
|
||||
"",
|
||||
"root of the checked-out corpus, holding examples/ (required)",
|
||||
)
|
||||
flagSet.StringVar(
|
||||
&configuration.specPath,
|
||||
"spec",
|
||||
"",
|
||||
"path to the property set every implementation is run against (required)",
|
||||
)
|
||||
flagSet.StringVar(
|
||||
&seedSpecification,
|
||||
"seeds",
|
||||
"",
|
||||
"seeds every implementation runs: ranges and lists, e.g. 1-10,20 (required)",
|
||||
)
|
||||
flagSet.StringVar(
|
||||
&selection,
|
||||
"implementations",
|
||||
"",
|
||||
"comma-separated subset of the population to sweep (default: all of it)",
|
||||
)
|
||||
flagSet.IntVar(
|
||||
&configuration.maxSteps,
|
||||
"max-steps",
|
||||
0,
|
||||
"per-run step budget, identical across implementations (required, must be positive)",
|
||||
)
|
||||
flagSet.DurationVar(
|
||||
&configuration.duration,
|
||||
"duration",
|
||||
5*time.Minute,
|
||||
"per-run wall-clock ceiling passed to each campaign",
|
||||
)
|
||||
flagSet.IntVar(
|
||||
&configuration.concurrency,
|
||||
"concurrency",
|
||||
defaultConcurrency,
|
||||
"implementations swept at once",
|
||||
)
|
||||
flagSet.IntVar(
|
||||
&configuration.basePort,
|
||||
"base-port",
|
||||
defaultBasePort,
|
||||
"first port served; each implementation takes the next one in population order",
|
||||
)
|
||||
flagSet.StringVar(
|
||||
&configuration.outputDirectory,
|
||||
"output",
|
||||
"",
|
||||
"campaign tree to create (required)",
|
||||
)
|
||||
flagSet.StringVar(
|
||||
&configuration.campaignPath,
|
||||
"campaign",
|
||||
"campaign",
|
||||
"campaign binary to invoke per implementation and seed",
|
||||
)
|
||||
flagSet.StringVar(
|
||||
&configuration.sanderlingPath,
|
||||
"sanderling",
|
||||
"sanderling",
|
||||
"sanderling binary each campaign invokes",
|
||||
)
|
||||
if err := flagSet.Parse(arguments); err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
configuration.extraArguments = flagSet.Args()
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"--corpus": configuration.corpusRoot,
|
||||
"--spec": configuration.specPath,
|
||||
"--seeds": seedSpecification,
|
||||
"--output": configuration.outputDirectory,
|
||||
} {
|
||||
if value == "" {
|
||||
return config{}, fmt.Errorf("%s is required", name)
|
||||
}
|
||||
}
|
||||
if configuration.maxSteps <= 0 {
|
||||
return config{}, fmt.Errorf(
|
||||
"--max-steps must be positive: every implementation needs the same step budget",
|
||||
)
|
||||
}
|
||||
if configuration.duration <= 0 {
|
||||
return config{}, fmt.Errorf(
|
||||
"--duration must be positive: %s",
|
||||
configuration.duration,
|
||||
)
|
||||
}
|
||||
if configuration.concurrency <= 0 {
|
||||
return config{}, fmt.Errorf(
|
||||
"--concurrency must be positive: %d",
|
||||
configuration.concurrency,
|
||||
)
|
||||
}
|
||||
if configuration.basePort < 1024 || configuration.basePort > 65535 {
|
||||
return config{}, fmt.Errorf(
|
||||
"--base-port %d is outside 1024-65535",
|
||||
configuration.basePort,
|
||||
)
|
||||
}
|
||||
seeds, err := seedspec.Parse(seedSpecification)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("--seeds: %w", err)
|
||||
}
|
||||
configuration.seeds = seeds
|
||||
names, err := selectImplementations(selection)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("--implementations: %w", err)
|
||||
}
|
||||
configuration.implementations = names
|
||||
for name, value := range map[string]*string{
|
||||
"--corpus": &configuration.corpusRoot,
|
||||
"--spec": &configuration.specPath,
|
||||
"--output": &configuration.outputDirectory,
|
||||
} {
|
||||
absolute, err := filepath.Abs(*value)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*value = absolute
|
||||
}
|
||||
return configuration, nil
|
||||
}
|
||||
|
||||
func campaignDirectory(
|
||||
configuration config,
|
||||
target implementation,
|
||||
seed string,
|
||||
) string {
|
||||
return filepath.Join(
|
||||
configuration.outputDirectory,
|
||||
target.Name,
|
||||
"seed-"+seed,
|
||||
)
|
||||
}
|
||||
|
||||
// campaignArguments builds one campaign invocation. The arm is the
|
||||
// implementation's name, so every run in the tree can be attributed to the
|
||||
// implementation it came from without reading back which port served it.
|
||||
func campaignArguments(
|
||||
configuration config,
|
||||
target implementation,
|
||||
seed string,
|
||||
) []string {
|
||||
arguments := []string{
|
||||
"--spec", configuration.specPath,
|
||||
"--bundle-id", target.URL(),
|
||||
"--platform", platform,
|
||||
"--arm", target.Name,
|
||||
"--generator", generator,
|
||||
"--max-steps", strconv.Itoa(configuration.maxSteps),
|
||||
"--duration", configuration.duration.String(),
|
||||
"--seeds", seed,
|
||||
"--sanderling", configuration.sanderlingPath,
|
||||
"--output", campaignDirectory(configuration, target, seed),
|
||||
}
|
||||
if len(configuration.extraArguments) > 0 {
|
||||
arguments = append(arguments, "--")
|
||||
arguments = append(arguments, configuration.extraArguments...)
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
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 runSweep(ctx, configuration, 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,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestFileName = "sweep.json"
|
||||
recordsFileName = "implementations.jsonl"
|
||||
)
|
||||
|
||||
// plannedImplementation is one implementation the sweep intends to run, with
|
||||
// the origin that keeps its stored state its own and the URL every seed is
|
||||
// driven at.
|
||||
type plannedImplementation struct {
|
||||
Name string `json:"name"`
|
||||
Document string `json:"document"`
|
||||
Port int `json:"port"`
|
||||
Origin string `json:"origin"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// manifest is sweep.json: what the sweep INTENDED to run, written before the
|
||||
// first run so a host that dropped an implementation or a seed shows up as a
|
||||
// missing run rather than as a smaller sample.
|
||||
type manifest struct {
|
||||
Generator string `json:"generator"`
|
||||
Platform string `json:"platform"`
|
||||
SpecPath string `json:"spec_path"`
|
||||
CorpusRoot string `json:"corpus_root"`
|
||||
CorpusCommit string `json:"corpus_commit"`
|
||||
MaxSteps int `json:"max_steps"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
Seeds []int64 `json:"seeds"`
|
||||
Implementations []plannedImplementation `json:"implementations"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Host string `json:"host"`
|
||||
CampaignPath string `json:"campaign_path"`
|
||||
SanderlingPath string `json:"sanderling_path"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func buildManifest(
|
||||
configuration config,
|
||||
implementations []implementation,
|
||||
host string,
|
||||
startedAt time.Time,
|
||||
) manifest {
|
||||
planned := make([]plannedImplementation, 0, len(implementations))
|
||||
for _, target := range implementations {
|
||||
planned = append(planned, plannedImplementation{
|
||||
Name: target.Name,
|
||||
Document: target.Document,
|
||||
Port: target.Port,
|
||||
Origin: target.Origin(),
|
||||
URL: target.URL(),
|
||||
})
|
||||
}
|
||||
return manifest{
|
||||
Generator: generator,
|
||||
Platform: platform,
|
||||
SpecPath: configuration.specPath,
|
||||
CorpusRoot: configuration.corpusRoot,
|
||||
CorpusCommit: corpusCommit(configuration.corpusRoot),
|
||||
MaxSteps: configuration.maxSteps,
|
||||
DurationMillis: configuration.duration.Milliseconds(),
|
||||
Seeds: configuration.seeds,
|
||||
Implementations: planned,
|
||||
Concurrency: configuration.concurrency,
|
||||
Host: host,
|
||||
CampaignPath: configuration.campaignPath,
|
||||
SanderlingPath: configuration.sanderlingPath,
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// corpusCommit records which checkout was swept. It is empty rather than fatal
|
||||
// for a corpus that is not a git working tree, because the population check has
|
||||
// already established the corpus holds the right examples.
|
||||
func corpusCommit(corpusRoot string) string {
|
||||
command := exec.Command("git", "-C", corpusRoot, "rev-parse", "HEAD")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// runRecord is one campaign, which is one implementation at one seed.
|
||||
type runRecord struct {
|
||||
Seed int64 `json:"seed"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
LaunchError string `json:"launch_error,omitempty"`
|
||||
CampaignDirectory string `json:"campaign_directory"`
|
||||
MonotonicMillis int64 `json:"monotonic_millis"`
|
||||
}
|
||||
|
||||
// implementationRecord is one line of implementations.jsonl. FailedStage names
|
||||
// the step that stopped this implementation, and one that never got served
|
||||
// carries no runs at all.
|
||||
type implementationRecord struct {
|
||||
Name string `json:"implementation"`
|
||||
Document string `json:"document"`
|
||||
Port int `json:"port"`
|
||||
Origin string `json:"origin"`
|
||||
URL string `json:"url"`
|
||||
FailedStage string `json:"failed_stage,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
MonotonicMillis int64 `json:"monotonic_millis"`
|
||||
Runs []runRecord `json:"runs"`
|
||||
}
|
||||
|
||||
const stageServe = "serve"
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// collisionPairs are implementations in this corpus that write the same
|
||||
// localStorage key as each other. Served from one origin they share one stored
|
||||
// record: in the corpus survey angular2_es2015 crashed at bootstrap on a record
|
||||
// angular2 had written, which is a violation belonging to no implementation.
|
||||
var collisionPairs = [][2]string{
|
||||
{"angular2", "angular2_es2015"},
|
||||
{"backbone", "backbone_require"},
|
||||
{"canjs", "canjs_require"},
|
||||
{"react", "typescript-react"},
|
||||
}
|
||||
|
||||
func originOf(t *testing.T, rawURL string) string {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", rawURL, err)
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
|
||||
// The whole population is swept so the assertion covers every implementation
|
||||
// rather than the four pairs already known to collide: the survey found those
|
||||
// four, and an unexamined fifth would be just as damaging.
|
||||
func TestSweep_GivesEveryImplementationAnOriginNoOtherImplementationShares(
|
||||
t *testing.T,
|
||||
) {
|
||||
root := t.TempDir()
|
||||
corpus := wholeCorpus(t)
|
||||
specPath := filepath.Join(root, "todo.ts")
|
||||
if err := os.WriteFile(specPath, []byte("export const properties = [];\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := filepath.Join(root, "campaigns")
|
||||
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)
|
||||
}
|
||||
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",
|
||||
)
|
||||
|
||||
err = run([]string{
|
||||
"--corpus", corpus,
|
||||
"--spec", specPath,
|
||||
"--seeds", "1",
|
||||
"--max-steps", "10",
|
||||
"--duration", "30s",
|
||||
"--concurrency", "8",
|
||||
"--base-port", fmt.Sprint(freePortRange(t, len(includedImplementations))),
|
||||
"--output", output,
|
||||
"--campaign", campaignPath,
|
||||
"--sanderling", sanderlingPath,
|
||||
}, io.Discard, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
|
||||
// What the sweep declared it would serve each implementation from.
|
||||
recorded := readManifest(t, filepath.Join(output, manifestFileName))
|
||||
if len(recorded.Implementations) != len(includedImplementations) {
|
||||
t.Fatalf(
|
||||
"intended implementations: got %d, want %d",
|
||||
len(recorded.Implementations),
|
||||
len(includedImplementations),
|
||||
)
|
||||
}
|
||||
plannedOrigin := map[string]string{}
|
||||
ownerOfOrigin := map[string]string{}
|
||||
for _, planned := range recorded.Implementations {
|
||||
if owner, taken := ownerOfOrigin[planned.Origin]; taken {
|
||||
t.Errorf(
|
||||
"%s and %s are both served from %s, so they share one localStorage",
|
||||
owner,
|
||||
planned.Name,
|
||||
planned.Origin,
|
||||
)
|
||||
}
|
||||
ownerOfOrigin[planned.Origin] = planned.Name
|
||||
plannedOrigin[planned.Name] = planned.Origin
|
||||
if got := originOf(t, planned.URL); got != planned.Origin {
|
||||
t.Errorf(
|
||||
"%s: url %q is not under the origin %q the manifest claims",
|
||||
planned.Name,
|
||||
planned.URL,
|
||||
planned.Origin,
|
||||
)
|
||||
}
|
||||
}
|
||||
for _, pair := range collisionPairs {
|
||||
if plannedOrigin[pair[0]] == plannedOrigin[pair[1]] {
|
||||
t.Errorf(
|
||||
"%s and %s write the same localStorage key and are both served from %s",
|
||||
pair[0],
|
||||
pair[1],
|
||||
plannedOrigin[pair[0]],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// What actually reached the driver. The web driver parses the origin to
|
||||
// clear out of --bundle-id, so two arms sharing a bundle-id origin clear
|
||||
// and repopulate one another's storage however carefully they are labelled.
|
||||
drivenOrigin := map[string]string{}
|
||||
for _, line := range readLines(t, campaignLog) {
|
||||
arguments := strings.Fields(strings.SplitN(line, "argv: ", 2)[1])
|
||||
arm := argumentValue(arguments, "--arm")
|
||||
if arm == "" {
|
||||
t.Fatalf(
|
||||
"a campaign ran with no arm, so its runs cannot be attributed: %q",
|
||||
line,
|
||||
)
|
||||
}
|
||||
drivenOrigin[arm] = originOf(t, argumentValue(arguments, "--bundle-id"))
|
||||
}
|
||||
if len(drivenOrigin) != len(includedImplementations) {
|
||||
t.Fatalf(
|
||||
"arms that reached the campaign tool: got %d, want %d",
|
||||
len(drivenOrigin),
|
||||
len(includedImplementations),
|
||||
)
|
||||
}
|
||||
armOfOrigin := map[string]string{}
|
||||
for arm, origin := range drivenOrigin {
|
||||
if other, taken := armOfOrigin[origin]; taken {
|
||||
t.Errorf(
|
||||
"arms %s and %s were both driven at %s",
|
||||
other,
|
||||
arm,
|
||||
origin,
|
||||
)
|
||||
}
|
||||
armOfOrigin[origin] = arm
|
||||
if origin != plannedOrigin[arm] {
|
||||
t.Errorf(
|
||||
"%s was driven at %s but the manifest promised %s",
|
||||
arm,
|
||||
origin,
|
||||
plannedOrigin[arm],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// And what each origin answered with, which is the check that the ports
|
||||
// are not merely distinct but each carries its own implementation.
|
||||
served := map[string]string{}
|
||||
for _, line := range readLines(t, fetchLog) {
|
||||
requested, body, found := strings.Cut(line, " -> 200 ")
|
||||
if !found {
|
||||
t.Errorf("a served page did not answer: %q", line)
|
||||
continue
|
||||
}
|
||||
served[originOf(t, requested)] = body
|
||||
}
|
||||
for arm, origin := range drivenOrigin {
|
||||
if want := "<title>" + arm + "</title>"; !strings.Contains(
|
||||
served[origin],
|
||||
want,
|
||||
) {
|
||||
t.Errorf(
|
||||
"%s at %s was served %q, which is not its own document",
|
||||
arm,
|
||||
origin,
|
||||
served[origin],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
readinessTimeout = 5 * time.Second
|
||||
readinessInterval = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// stubScriptPath is served from every implementation's own origin in place of a
|
||||
// dependency that no longer answers.
|
||||
const stubScriptPath = "/__corpus-sweep__/stub.js"
|
||||
|
||||
// deadDependency is polyfill.io, which was shut down after this corpus was
|
||||
// pinned. One implementation loads it from its index.html; the request fails
|
||||
// and the application works regardless, but it fails slowly, once per run, on
|
||||
// every run. The corpus is served from this process, so the reference is
|
||||
// rewritten to a local no-op on the way out.
|
||||
var deadDependency = regexp.MustCompile(`https?://polyfill\.io/[^"'\s>]*`)
|
||||
|
||||
// staticServer serves the whole corpus tree on one implementation's port. The
|
||||
// tree rather than the implementation's own directory, because an example that
|
||||
// asks for a path above itself has to resolve the same way it would in the
|
||||
// upstream repository. Only one implementation is ever visited on this port, so
|
||||
// the origin still belongs to it alone.
|
||||
type staticServer struct {
|
||||
implementation implementation
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func startStaticServer(
|
||||
corpusRoot string,
|
||||
target implementation,
|
||||
) (*staticServer, error) {
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", target.Port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serve %s: %w", target.Name, err)
|
||||
}
|
||||
server := &http.Server{Handler: corpusHandler(corpusRoot)}
|
||||
running := &staticServer{
|
||||
implementation: target,
|
||||
listener: listener,
|
||||
server: server,
|
||||
}
|
||||
go server.Serve(listener)
|
||||
return running, nil
|
||||
}
|
||||
|
||||
func (s *staticServer) stop() {
|
||||
s.server.Close()
|
||||
}
|
||||
|
||||
// waitReady confirms the served document answers before any run drives it. A
|
||||
// wrong document path would otherwise reach the driver as a 404 page, and a
|
||||
// sweep of 404 pages produces clean runs for every implementation.
|
||||
//
|
||||
// Only a transport error is retried. The listener is bound before the sweep
|
||||
// starts, so a status that is not 200 is the server's answer about this
|
||||
// document and waiting will not change it.
|
||||
func (s *staticServer) waitReady(ctx context.Context) error {
|
||||
url := s.implementation.URL()
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(readinessTimeout)
|
||||
var lastErr error
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
response, err := client.Get(url)
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, response.Body)
|
||||
response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%s answered %d, not the document",
|
||||
url,
|
||||
response.StatusCode,
|
||||
)
|
||||
}
|
||||
lastErr = err
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf(
|
||||
"%s did not answer within %s: %w",
|
||||
url,
|
||||
readinessTimeout,
|
||||
lastErr,
|
||||
)
|
||||
}
|
||||
time.Sleep(readinessInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func corpusHandler(corpusRoot string) http.Handler {
|
||||
files := http.FileServer(http.Dir(corpusRoot))
|
||||
return http.HandlerFunc(
|
||||
func(writer http.ResponseWriter, request *http.Request) {
|
||||
cleaned := path.Clean(
|
||||
"/" + strings.TrimPrefix(request.URL.Path, "/"),
|
||||
)
|
||||
if cleaned == stubScriptPath {
|
||||
writer.Header().Set("Content-Type", "application/javascript")
|
||||
io.WriteString(
|
||||
writer,
|
||||
"/* corpus-sweep: dependency removed upstream */\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(cleaned, ".html") {
|
||||
files.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
body, modified, err := readDocument(corpusRoot, cleaned)
|
||||
if err != nil {
|
||||
// Never the file server's fallback: it answers a document it
|
||||
// cannot read with a 200 directory listing, and a run driven at a
|
||||
// listing explores nothing and comes back clean.
|
||||
http.Error(writer, err.Error(), documentStatus(err))
|
||||
return
|
||||
}
|
||||
rewritten := deadDependency.ReplaceAll(body, []byte(stubScriptPath))
|
||||
http.ServeContent(
|
||||
writer,
|
||||
request,
|
||||
path.Base(cleaned),
|
||||
modified,
|
||||
bytes.NewReader(rewritten),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func documentStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, fs.ErrPermission):
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func readDocument(corpusRoot, cleaned string) ([]byte, time.Time, error) {
|
||||
file, err := http.Dir(corpusRoot).Open(cleaned)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, time.Time{}, fmt.Errorf(
|
||||
"%s is not a document",
|
||||
filepath.FromSlash(cleaned),
|
||||
)
|
||||
}
|
||||
body, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
return body, info.ModTime(), nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func get(t *testing.T, url string) (int, string) {
|
||||
t.Helper()
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response.StatusCode, string(body)
|
||||
}
|
||||
|
||||
func TestStaticServer_ServesEachImplementationsDocumentFromItsOwnPort(
|
||||
t *testing.T,
|
||||
) {
|
||||
root := wholeCorpus(t)
|
||||
planned, err := planImplementations(
|
||||
root,
|
||||
[]string{"angular-dart", "duel", "react"},
|
||||
freePortRange(t, 3),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range planned {
|
||||
server, err := startStaticServer(root, target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.stop()
|
||||
if err := server.waitReady(context.Background()); err != nil {
|
||||
t.Fatalf("%s: %v", target.Name, err)
|
||||
}
|
||||
status, body := get(t, target.URL())
|
||||
if status != http.StatusOK ||
|
||||
!strings.Contains(body, "<title>"+target.Name+"</title>") {
|
||||
t.Errorf(
|
||||
"%s at %s: got %d %q",
|
||||
target.Name,
|
||||
target.URL(),
|
||||
status,
|
||||
body,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticServer_ReplacesTheDependencyThatNoLongerAnswers(t *testing.T) {
|
||||
root := wholeCorpus(t)
|
||||
document := filepath.Join(root, filepath.FromSlash(documentFor("aurelia")))
|
||||
body := `<!doctype html><script src="https://polyfill.io/v3/polyfill.min.js?features=Promise"></script>`
|
||||
if err := os.WriteFile(document, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planned, err := planImplementations(
|
||||
root,
|
||||
[]string{"aurelia"},
|
||||
freePortRange(t, 1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := startStaticServer(root, planned[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.stop()
|
||||
|
||||
status, served := get(t, planned[0].URL())
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("document answered %d", status)
|
||||
}
|
||||
if strings.Contains(served, "polyfill.io") {
|
||||
t.Errorf(
|
||||
"a request to a host that no longer answers reaches the browser once per run: %q",
|
||||
served,
|
||||
)
|
||||
}
|
||||
if !strings.Contains(served, `src="`+stubScriptPath+`"`) {
|
||||
t.Errorf(
|
||||
"the reference was removed rather than pointed at a local no-op: %q",
|
||||
served,
|
||||
)
|
||||
}
|
||||
|
||||
stubStatus, stub := get(t, planned[0].Origin()+stubScriptPath)
|
||||
if stubStatus != http.StatusOK || stub == "" {
|
||||
t.Errorf("stub script: got %d %q", stubStatus, stub)
|
||||
}
|
||||
}
|
||||
|
||||
// An example that asks for a path above its own directory has to resolve the
|
||||
// same way it does upstream, which is why the whole tree is served rather than
|
||||
// the one directory.
|
||||
func TestStaticServer_ServesPathsAboveTheImplementationsOwnDirectory(
|
||||
t *testing.T,
|
||||
) {
|
||||
root := wholeCorpus(t)
|
||||
shared := filepath.Join(root, "node_modules", "todomvc-common")
|
||||
if err := os.MkdirAll(shared, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(shared, "base.css"), []byte("body{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planned, err := planImplementations(
|
||||
root,
|
||||
[]string{"react"},
|
||||
freePortRange(t, 1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := startStaticServer(root, planned[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.stop()
|
||||
|
||||
status, body := get(
|
||||
t,
|
||||
planned[0].Origin()+"/node_modules/todomvc-common/base.css",
|
||||
)
|
||||
if status != http.StatusOK || body != "body{}" {
|
||||
t.Errorf("shared asset: got %d %q", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticServerWaitReady_ReportsADocumentThatDoesNotAnswer(t *testing.T) {
|
||||
root := wholeCorpus(t)
|
||||
document := filepath.Join(root, filepath.FromSlash(documentFor("dojo")))
|
||||
if err := os.Chmod(document, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chmod(document, 0o644) })
|
||||
planned, err := planImplementations(
|
||||
root,
|
||||
[]string{"dojo"},
|
||||
freePortRange(t, 1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := startStaticServer(root, planned[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer server.stop()
|
||||
|
||||
started := time.Now()
|
||||
err = server.waitReady(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"a document that does not answer would be swept as an error page and come back clean",
|
||||
)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > readinessTimeout {
|
||||
t.Errorf(
|
||||
"waited %s for a status that was never going to change",
|
||||
elapsed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticServerStop_ReleasesThePort(t *testing.T) {
|
||||
root := wholeCorpus(t)
|
||||
planned, err := planImplementations(
|
||||
root,
|
||||
[]string{"vue"},
|
||||
freePortRange(t, 1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server, err := startStaticServer(root, planned[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := server.waitReady(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.stop()
|
||||
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
response, err := client.Get(planned[0].URL())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
response.Body.Close()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf(
|
||||
"port %d is still served after stop(), so the next sweep cannot bind it",
|
||||
planned[0].Port,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// resolveBinaries turns campaign and sanderling into absolute paths before
|
||||
// anything is served. Each campaign runs from the sweep's own directory, and a
|
||||
// binary that is missing altogether has to stop the sweep here rather than fail
|
||||
// once per implementation and seed.
|
||||
func resolveBinaries(configuration *config) error {
|
||||
for name, value := range map[string]*string{
|
||||
"--campaign": &configuration.campaignPath,
|
||||
"--sanderling": &configuration.sanderlingPath,
|
||||
} {
|
||||
resolved, err := exec.LookPath(*value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
absolute, err := filepath.Abs(resolved)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*value = absolute
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type sweep struct {
|
||||
configuration config
|
||||
stdout io.Writer
|
||||
records io.Writer
|
||||
servers map[string]*staticServer
|
||||
mutex sync.Mutex
|
||||
stalled int
|
||||
failedRuns int
|
||||
totalRuns int
|
||||
}
|
||||
|
||||
func runSweep(
|
||||
ctx context.Context,
|
||||
configuration config,
|
||||
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 sweeps do not share a directory",
|
||||
manifestFileName,
|
||||
configuration.outputDirectory,
|
||||
)
|
||||
}
|
||||
if err := verifyCorpus(configuration.corpusRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
implementations, err := planImplementations(
|
||||
configuration.corpusRoot,
|
||||
configuration.implementations,
|
||||
configuration.basePort,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveBinaries(&configuration); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(configuration.specPath); err != nil {
|
||||
return fmt.Errorf("--spec: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(configuration.outputDirectory, 0o755); err != nil {
|
||||
return fmt.Errorf("create sweep dir: %w", err)
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
if err := writeManifest(configuration.outputDirectory, buildManifest(configuration, implementations, host, 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()
|
||||
|
||||
running := &sweep{
|
||||
configuration: configuration,
|
||||
stdout: stdout,
|
||||
records: recordsFile,
|
||||
}
|
||||
// Every port is bound before any run starts. A port already taken means
|
||||
// that implementation cannot have an origin of its own, and continuing
|
||||
// without one is what the separate origins are there to prevent.
|
||||
if err := running.serveAll(implementations); err != nil {
|
||||
return err
|
||||
}
|
||||
defer running.stopAll()
|
||||
|
||||
fmt.Fprintf(
|
||||
stdout,
|
||||
"sweep: %d implementations, %d seeds each, %d at a time, %s\n",
|
||||
len(
|
||||
implementations,
|
||||
),
|
||||
len(configuration.seeds),
|
||||
configuration.concurrency,
|
||||
configuration.outputDirectory,
|
||||
)
|
||||
running.work(ctx, implementations)
|
||||
|
||||
fmt.Fprintf(
|
||||
stdout,
|
||||
"sweep complete: %d of %d implementations never ran, %d of %d campaigns failed\n",
|
||||
running.stalled,
|
||||
len(implementations),
|
||||
running.failedRuns,
|
||||
running.totalRuns,
|
||||
)
|
||||
if running.stalled > 0 || running.failedRuns > 0 {
|
||||
return fmt.Errorf(
|
||||
"%d of %d implementations never ran and %d of %d campaigns failed; see %s",
|
||||
running.stalled,
|
||||
len(implementations),
|
||||
running.failedRuns,
|
||||
running.totalRuns,
|
||||
recordsFileName,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sweep) serveAll(implementations []implementation) error {
|
||||
s.servers = make(map[string]*staticServer, len(implementations))
|
||||
for _, target := range implementations {
|
||||
server, err := startStaticServer(s.configuration.corpusRoot, target)
|
||||
if err != nil {
|
||||
s.stopAll()
|
||||
return err
|
||||
}
|
||||
s.servers[target.Name] = server
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sweep) stopAll() {
|
||||
for _, server := range s.servers {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sweep) work(ctx context.Context, implementations []implementation) {
|
||||
queue := make(chan implementation, len(implementations))
|
||||
for _, target := range implementations {
|
||||
queue <- target
|
||||
}
|
||||
close(queue)
|
||||
|
||||
workers := min(s.configuration.concurrency, len(implementations))
|
||||
var waitGroup sync.WaitGroup
|
||||
for range workers {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
for target := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.report(s.runImplementation(ctx, target))
|
||||
}
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
}
|
||||
|
||||
// runImplementation carries one implementation through all of its seeds. A
|
||||
// document that does not answer is recorded and the sweep moves on: one
|
||||
// implementation must not cost the other forty-two their runs.
|
||||
func (s *sweep) runImplementation(
|
||||
ctx context.Context,
|
||||
target implementation,
|
||||
) (record implementationRecord) {
|
||||
record = implementationRecord{
|
||||
Name: target.Name,
|
||||
Document: target.Document,
|
||||
Port: target.Port,
|
||||
Origin: target.Origin(),
|
||||
URL: target.URL(),
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
started := time.Now()
|
||||
defer func() { record.MonotonicMillis = time.Since(started).Milliseconds() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(s.configuration.outputDirectory, target.Name), 0o755); err != nil {
|
||||
record.FailedStage = stageServe
|
||||
record.Error = err.Error()
|
||||
return record
|
||||
}
|
||||
if err := s.servers[target.Name].waitReady(ctx); err != nil {
|
||||
record.FailedStage = stageServe
|
||||
record.Error = err.Error()
|
||||
return record
|
||||
}
|
||||
|
||||
for _, seed := range s.configuration.seeds {
|
||||
if ctx.Err() != nil {
|
||||
return record
|
||||
}
|
||||
record.Runs = append(record.Runs, s.runSeed(ctx, target, seed))
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func (s *sweep) runSeed(
|
||||
ctx context.Context,
|
||||
target implementation,
|
||||
seed int64,
|
||||
) (record runRecord) {
|
||||
seedText := strconv.FormatInt(seed, 10)
|
||||
directory := campaignDirectory(s.configuration, target, seedText)
|
||||
record = runRecord{Seed: seed, CampaignDirectory: directory}
|
||||
started := time.Now()
|
||||
defer func() { record.MonotonicMillis = time.Since(started).Milliseconds() }()
|
||||
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
record.ExitCode = -1
|
||||
record.LaunchError = err.Error()
|
||||
return record
|
||||
}
|
||||
exitCode, err := runCommand(
|
||||
ctx,
|
||||
s.configuration.campaignPath,
|
||||
campaignArguments(
|
||||
s.configuration,
|
||||
target,
|
||||
seedText,
|
||||
),
|
||||
filepath.Join(directory, "campaign.log"),
|
||||
)
|
||||
record.ExitCode = exitCode
|
||||
if err != nil {
|
||||
record.LaunchError = err.Error()
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func runCommand(
|
||||
ctx context.Context,
|
||||
binary string,
|
||||
arguments []string,
|
||||
logPath string,
|
||||
) (int, error) {
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
defer logFile.Close()
|
||||
command := exec.CommandContext(ctx, binary, arguments...)
|
||||
command.Stdout = logFile
|
||||
command.Stderr = logFile
|
||||
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
|
||||
}
|
||||
|
||||
func (s *sweep) report(record implementationRecord) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
if record.FailedStage != "" {
|
||||
s.stalled++
|
||||
}
|
||||
s.totalRuns += len(record.Runs)
|
||||
failed := 0
|
||||
for _, run := range record.Runs {
|
||||
if run.ExitCode != 0 {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
s.failedRuns += failed
|
||||
if err := json.NewEncoder(s.records).Encode(record); err != nil {
|
||||
fmt.Fprintf(s.stdout, "warning: %s record: %v\n", record.Name, err)
|
||||
}
|
||||
elapsed := time.Duration(record.MonotonicMillis) * time.Millisecond
|
||||
if record.FailedStage != "" {
|
||||
fmt.Fprintf(s.stdout, "%s port=%d failed at %s: %s (%s)\n",
|
||||
record.Name, record.Port, record.FailedStage, record.Error, elapsed)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(s.stdout, "%s origin=%s campaigns=%d failed=%d elapsed=%s\n",
|
||||
record.Name, record.Origin, len(record.Runs), failed, elapsed)
|
||||
}
|
||||
Reference in new issue
Block a user