Artifact 43f99bdef7d462a4cfaea430c7aae84eb0badeff817a418bd3756481d1d8829c:
- File
cmd/edgesync/notify_test.go
— part of check-in
[37780572c3]
at
2026-05-19 17:09:19
on branch main
— fix(hub): resolve TestNotifyCLIPairAndDevices flake (closes #170) (#186)
* fix(hub): resolve TestNotifyCLIPairAndDevices flake (closes #170)
Each of the 4 tests in cmd/edgesync/notify_test.go independently invoked `go build` for the edgesync binary inside its own body. Under cold build cache or parallel test load, this build storm pushed the package's total runtime past `go test -short -timeout=30s ./...` (the pre-push hook gate) and the suite-level timeout fired — surfacing as a hang on os/exec.Wait since the inner `go build` child was still running when the supervisor killed the test process.
Move the build into TestMain so the binary is built once and reused. With -timeout=30s on cold cache, cmd/edgesync drops from ~9s to ~3s and the target test from ~2.8s to ~0.07s — comfortably clear of the timeout.
No production-code change. No sleep band-aids. Verified 200/200 passes (50× per test in the package) and 80/80 under -race.
* fix(test): clean up temp dir before os.Exit (Ousterhout follow-up to PR 186)
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (user: [email protected], size: 6664) [annotate] [blame] [check-ins using]
package main_test import ( "fmt" "os" "os/exec" "path/filepath" "strings" "testing" ) // sharedBinary is the path to the edgesync binary built once by TestMain // and reused across every test in this package. Building only once avoids a // build-storm — without this, each test's buildBinary() invocation ran its own // `go build`, and under cold-cache / parallel-load the cumulative cost pushed // the package past `go test -timeout=30s` (see issue #170). var sharedBinary string func TestMain(m *testing.M) { // Build into a temp dir that survives until the process exits. dir, err := os.MkdirTemp("", "edgesync-test-bin-") if err != nil { fmt.Fprintf(os.Stderr, "tempdir: %s\n", err) os.Exit(2) } root, err := findRepoRoot() if err != nil { os.RemoveAll(dir) fmt.Fprintf(os.Stderr, "find repo root: %s\n", err) os.Exit(2) } sharedBinary = filepath.Join(dir, "edgesync") cmd := exec.Command("go", "build", "-buildvcs=false", "-o", sharedBinary, "./cmd/edgesync/") cmd.Dir = root if out, err := cmd.CombinedOutput(); err != nil { os.RemoveAll(dir) fmt.Fprintf(os.Stderr, "build edgesync binary: %s\n%s", err, out) os.Exit(2) } // os.Exit skips deferred functions, so clean up explicitly between // running tests and exiting. (Ousterhout follow-up to PR #186.) code := m.Run() os.RemoveAll(dir) os.Exit(code) } // findRepoRoot locates the EdgeSync repo root (parent of cmd/) starting from // the test's working directory. func findRepoRoot() (string, error) { wd, err := os.Getwd() if err != nil { return "", err } // When `go test` runs, cwd is the package dir (cmd/edgesync/). root := filepath.Dir(filepath.Dir(wd)) if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { return "", fmt.Errorf("could not find repo root from %s: %w", wd, err) } return root, nil } // edgesyncBin returns the shared edgesync binary path. func edgesyncBin(t *testing.T) string { t.Helper() if sharedBinary == "" { t.Fatal("sharedBinary is empty — TestMain did not run") } return sharedBinary } func TestNotifyCLIInit(t *testing.T) { bin := edgesyncBin(t) tmp := t.TempDir() // -R points to a fake repo path — init creates notify.fossil next to it. fakeRepo := filepath.Join(tmp, "project.fossil") cmd := exec.Command(bin, "-R", fakeRepo, "notify", "init") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("notify init failed: %s\n%s", err, out) } expected := filepath.Join(tmp, "notify.fossil") got := strings.TrimSpace(string(out)) if got != expected { t.Errorf("init output = %q, want %q", got, expected) } // Verify the file was created. if _, err := os.Stat(expected); os.IsNotExist(err) { t.Errorf("notify.fossil not created at %s", expected) } } func TestNotifyCLIPairAndDevices(t *testing.T) { bin := edgesyncBin(t) tmp := t.TempDir() fakeRepo := filepath.Join(tmp, "project.fossil") // Init. cmd := exec.Command(bin, "-R", fakeRepo, "notify", "init") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("init: %s\n%s", err, out) } // Pair (no endpoint/NATS = text token only, no QR). cmd = exec.Command(bin, "-R", fakeRepo, "notify", "pair", "--name", "dan-iphone") out, err = cmd.CombinedOutput() if err != nil { t.Fatalf("pair: %s\n%s", err, out) } pairOutput := string(out) // Should contain a token in XXXX-XXXX-XXXX format. if !strings.Contains(pairOutput, "-") { t.Errorf("pair output should contain token with dashes, got: %q", pairOutput) } if !strings.Contains(pairOutput, "expires") { t.Errorf("pair output should mention expiry, got: %q", pairOutput) } // Devices — should be empty (token pending, not yet validated). cmd = exec.Command(bin, "-R", fakeRepo, "notify", "devices") out, err = cmd.CombinedOutput() if err != nil { t.Fatalf("devices: %s\n%s", err, out) } if !strings.Contains(string(out), "no paired devices") { t.Errorf("devices should be empty before validation, got: %q", string(out)) } } func TestNotifyCLIUnpair(t *testing.T) { bin := edgesyncBin(t) tmp := t.TempDir() fakeRepo := filepath.Join(tmp, "project.fossil") // Init. cmd := exec.Command(bin, "-R", fakeRepo, "notify", "init") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("init: %s\n%s", err, out) } // Unpair a device that doesn't exist = error. cmd = exec.Command(bin, "-R", fakeRepo, "notify", "unpair", "nonexistent") if out, err := cmd.CombinedOutput(); err == nil { t.Fatalf("unpair nonexistent should fail, got: %s", out) } } func TestNotifyCLISendAndThreads(t *testing.T) { bin := edgesyncBin(t) tmp := t.TempDir() fakeRepo := filepath.Join(tmp, "project.fossil") // Init first. cmd := exec.Command(bin, "-R", fakeRepo, "notify", "init") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("notify init failed: %s\n%s", err, out) } // Send a message. cmd = exec.Command(bin, "-R", fakeRepo, "notify", "send", "--project", "testproj", "--priority", "urgent", "Hello from CLI test") out, err = cmd.CombinedOutput() if err != nil { t.Fatalf("notify send failed: %s\n%s", err, out) } sendOutput := string(out) // stdout should contain the message ID (msg-...). if !strings.Contains(sendOutput, "msg-") { t.Errorf("send output should contain message ID, got: %q", sendOutput) } // stderr has thread info — combined output should contain "thread:". if !strings.Contains(sendOutput, "thread:") { t.Errorf("send output should contain thread info, got: %q", sendOutput) } // List threads. cmd = exec.Command(bin, "-R", fakeRepo, "notify", "threads", "--project", "testproj") out, err = cmd.CombinedOutput() if err != nil { t.Fatalf("notify threads failed: %s\n%s", err, out) } threadsOutput := string(out) if !strings.Contains(threadsOutput, "msgs:1") { t.Errorf("threads should show msgs:1, got: %q", threadsOutput) } if !strings.Contains(threadsOutput, "priority:urgent") { t.Errorf("threads should show priority:urgent, got: %q", threadsOutput) } if !strings.Contains(threadsOutput, "Hello from CLI test") { t.Errorf("threads should show message body, got: %q", threadsOutput) } // Status. cmd = exec.Command(bin, "-R", fakeRepo, "notify", "status") out, err = cmd.CombinedOutput() if err != nil { t.Fatalf("notify status failed: %s\n%s", err, out) } statusOutput := string(out) notifyPath := filepath.Join(tmp, "notify.fossil") if !strings.Contains(statusOutput, notifyPath) { t.Errorf("status should contain notify repo path %s, got: %q", notifyPath, statusOutput) } if !strings.Contains(statusOutput, "repo-only") { t.Errorf("status should show repo-only connection, got: %q", statusOutput) } }