Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions internal/testutil/gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package testutil

import (
"context"
"fmt"
"net/url"
"os"
"testing"
"time"
)

// LiveNetworkDecision describes how a test should handle live network access.
type LiveNetworkDecision uint8

const (
// LiveNetworkSkip means the test has not explicitly opted in to live access.
LiveNetworkSkip LiveNetworkDecision = iota
// LiveNetworkFatal means the live lane is enabled but misconfigured.
LiveNetworkFatal
// LiveNetworkRun means the test may perform live network operations.
LiveNetworkRun
)

func (d LiveNetworkDecision) String() string {
switch d {
case LiveNetworkSkip:
return "skip"
case LiveNetworkFatal:
return "fatal"
case LiveNetworkRun:
return "run"
default:
return fmt.Sprintf("LiveNetworkDecision(%d)", d)
}
}

var proxyEnvironmentVariables = []string{
"HTTP_PROXY",
"HTTPS_PROXY",
"http_proxy",
"https_proxy",
}

// LiveNetworkStatus returns the live-network decision without acting on a
// testing.T, allowing all three branches to be tested directly.
func LiveNetworkStatus() (LiveNetworkDecision, string) {
if os.Getenv("AILANG_LIVE_NET") != "1" {
return LiveNetworkSkip, "AILANG_LIVE_NET is not 1; live network tests require explicit opt-in"
}

for _, name := range proxyEnvironmentVariables {
if proxyPointsAtPoison(os.Getenv(name)) {
return LiveNetworkFatal, fmt.Sprintf("%s points at the poison proxy 127.0.0.1:9 in the live network lane", name)
}
}
return LiveNetworkRun, ""
}

func proxyPointsAtPoison(value string) bool {
if value == "" {
return false
}
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
parsed, err = url.Parse("http://" + value)
}
return err == nil && parsed.Hostname() == "127.0.0.1" && parsed.Port() == "9"
}

// RequiresLiveNetwork skips tests outside the live lane and fails tests when
// that lane still carries the poison proxy configuration.
func RequiresLiveNetwork(t *testing.T) {
t.Helper()
decision, reason := LiveNetworkStatus()
if decision == LiveNetworkSkip {
t.Skip(reason)
}
if decision == LiveNetworkFatal {
// Do not unset proxy variables here: Go caches proxy configuration
// process-wide on first use, so changing the environment after an
// earlier request may silently retain the poisoned proxy.
t.Fatalf("live network lane is misconfigured: %s", reason)
}
}

// HangGuard returns an operation timeout capped by both cap and the test's
// remaining deadline, with time reserved for reporting and cleanup.
func HangGuard(t *testing.T, cap time.Duration) time.Duration {
t.Helper()
deadline, ok := t.Deadline()
if !ok {
return cap
}

bound := min(cap, time.Until(deadline)-20*time.Second)
if bound < time.Second {
return time.Second
}
return bound
}

// HangGuardContext returns a background context bounded by HangGuard.
func HangGuardContext(t *testing.T, cap time.Duration) (context.Context, context.CancelFunc) {
t.Helper()
return context.WithTimeout(context.Background(), HangGuard(t, cap))
}
130 changes: 130 additions & 0 deletions internal/testutil/gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package testutil

import (
"os"
"os/exec"
"strings"
"testing"
"time"
)

func cleanLiveNetworkEnvironment(t *testing.T) {
t.Helper()
t.Setenv("AILANG_LIVE_NET", "1")
for _, name := range proxyEnvironmentVariables {
t.Setenv(name, "")
}
}

func TestLiveNetworkStatus_OptInSetRuns(t *testing.T) {
cleanLiveNetworkEnvironment(t)

status, reason := LiveNetworkStatus()
if status != LiveNetworkRun {
t.Fatalf("LiveNetworkStatus() status = %v, want %v (reason: %q)", status, LiveNetworkRun, reason)
}
if reason != "" {
t.Fatalf("LiveNetworkStatus() reason = %q, want empty reason", reason)
}
}

func TestLiveNetworkStatus_UnsetSkips(t *testing.T) {
cleanLiveNetworkEnvironment(t)
t.Setenv("AILANG_LIVE_NET", "")

status, reason := LiveNetworkStatus()
if status != LiveNetworkSkip {
t.Fatalf("LiveNetworkStatus() status = %v, want %v (reason: %q)", status, LiveNetworkSkip, reason)
}
if !strings.Contains(reason, "AILANG_LIVE_NET") {
t.Fatalf("LiveNetworkStatus() reason = %q, want it to name AILANG_LIVE_NET", reason)
}
}

func TestLiveNetworkStatus_PoisonedProxyFatal(t *testing.T) {
for _, poisoned := range proxyEnvironmentVariables {
t.Run(poisoned, func(t *testing.T) {
cleanLiveNetworkEnvironment(t)
t.Setenv(poisoned, "http://127.0.0.1:9")

status, reason := LiveNetworkStatus()
if status != LiveNetworkFatal {
t.Fatalf("LiveNetworkStatus() status = %v, want %v (reason: %q)", status, LiveNetworkFatal, reason)
}
// Case-insensitive on purpose. Windows environment variables are
// case-INSENSITIVE, so HTTP_PROXY and http_proxy are one variable there:
// setting the lower-cased name and then reading the list in order reports
// the upper-cased name, which is correct but not byte-equal to `poisoned`.
if !strings.Contains(strings.ToLower(reason), strings.ToLower(poisoned)) {
t.Fatalf("LiveNetworkStatus() reason = %q, want it to name %s", reason, poisoned)
}
})
}
}

func TestLiveNetworkStatus_DoesNotConfuseAnotherPortForPoison(t *testing.T) {
cleanLiveNetworkEnvironment(t)
t.Setenv("HTTP_PROXY", "http://127.0.0.1:90")

status, reason := LiveNetworkStatus()
if status != LiveNetworkRun {
t.Fatalf("LiveNetworkStatus() status = %v, want %v (reason: %q)", status, LiveNetworkRun, reason)
}
}

func TestRequiresLiveNetwork_PoisonedLiveLaneFatal(t *testing.T) {
if os.Getenv("TESTUTIL_POISONED_LIVE_LANE_HELPER") == "1" {
RequiresLiveNetwork(t)
return
}

cleanLiveNetworkEnvironment(t)
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:9")
t.Setenv("TESTUTIL_POISONED_LIVE_LANE_HELPER", "1")
cmd := exec.Command(os.Args[0], "-test.run=^TestRequiresLiveNetwork_PoisonedLiveLaneFatal$")
cmd.Env = os.Environ()
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("poisoned live-lane helper succeeded, want fatal failure; output:\n%s", output)
}
if !strings.Contains(string(output), "HTTPS_PROXY") {
t.Fatalf("fatal output does not name HTTPS_PROXY:\n%s", output)
}
}

func TestHangGuard_FloorsAtOneSecond(t *testing.T) {
if got := HangGuard(t, 0); got != time.Second {
t.Fatalf("HangGuard(t, 0) = %v, want %v", got, time.Second)
}
}

func TestHangGuard_UsesCap(t *testing.T) {
const cap = 2 * time.Second
if got := HangGuard(t, cap); got != cap {
t.Fatalf("HangGuard(t, %v) = %v, want cap unchanged", cap, got)
}
}

func TestHangGuard_NoDeadlineReturnsCap(t *testing.T) {
if _, ok := t.Deadline(); ok {
t.Skip("requires go test -timeout 0 to exercise testing.T with no deadline")
}

const cap = 7 * time.Second
if got := HangGuard(t, cap); got != cap {
t.Fatalf("HangGuard(t, %v) = %v, want cap unchanged with no test deadline", cap, got)
}
}

func TestHangGuardContext_UsesGuardedDeadline(t *testing.T) {
ctx, cancel := HangGuardContext(t, 2*time.Second)
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("HangGuardContext() returned a context without a deadline")
}
remaining := time.Until(deadline)
if remaining <= 0 || remaining > 2*time.Second {
t.Fatalf("context deadline remaining = %v, want in (0, 2s]", remaining)
}
}
41 changes: 41 additions & 0 deletions internal/testutil/subproc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package testutil

import (
"bytes"
"errors"
"os/exec"
"testing"
"time"
)

// RunBounded runs a subprocess within both cap and the enclosing test's
// deadline. It captures the output streams separately and returns the child's
// exit code, including -1 when the process was killed before it could exit.
func RunBounded(t *testing.T, dir string, cap time.Duration, bin string, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
ctx, cancel := HangGuardContext(t, cap)
defer cancel()

cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = dir
cmd.WaitDelay = 5 * time.Second

var stdoutBuffer bytes.Buffer
var stderrBuffer bytes.Buffer
cmd.Stdout = &stdoutBuffer
cmd.Stderr = &stderrBuffer

err := cmd.Run()
stdout = stdoutBuffer.String()
stderr = stderrBuffer.String()
if err == nil {
return stdout, stderr, 0
}

var exitError *exec.ExitError
if errors.As(err, &exitError) {
return stdout, stderr, exitError.ExitCode()
}
t.Fatalf("testutil: run %q: %v", bin, err)
return "", "", -1
}
84 changes: 84 additions & 0 deletions internal/testutil/subproc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package testutil

import (
"fmt"
"os"
"runtime"
"strings"
"testing"
"time"
)

func TestRunBounded_CapturesOutputAndExitCode(t *testing.T) {
t.Setenv("TESTUTIL_BOUNDED_CHILD", "output")

stdout, stderr, exitCode := RunBounded(t, "", 5*time.Second, os.Args[0], "-test.run=^TestRunBoundedChild$")
if stdout != "child stdout\n" {
t.Errorf("stdout = %q, want %q", stdout, "child stdout\n")
}
if stderr != "child stderr\n" {
t.Errorf("stderr = %q, want %q", stderr, "child stderr\n")
}
if exitCode != 7 {
t.Errorf("exitCode = %d, want 7", exitCode)
}
}

// Deliberately ungated: the -short flag is never passed anywhere in CI, so a
// short-mode skip would be inert — the exact defect this package exists to replace.
// The test is self-bounding (2s cap, asserts elapsed < 10s), so it is always safe to run.
func TestRunBounded_KillsHungChild(t *testing.T) {
t.Setenv("TESTUTIL_BOUNDED_CHILD", "sleep")
started := time.Now()

_, _, exitCode := RunBounded(t, "", 2*time.Second, os.Args[0], "-test.run=^TestRunBoundedChild$")
elapsed := time.Since(started)
if elapsed >= 10*time.Second {
t.Fatalf("RunBounded took %v, want less than 10s", elapsed)
}
if exitCode == 0 {
t.Fatal("RunBounded exitCode = 0, want non-zero after killing hung child")
}
}

func TestRunBoundedChild(t *testing.T) {
switch os.Getenv("TESTUTIL_BOUNDED_CHILD") {
case "output":
fmt.Fprintln(os.Stdout, "child stdout")
fmt.Fprintln(os.Stderr, "child stderr")
os.Exit(7)
case "sleep":
time.Sleep(60 * time.Second)
default:
t.Skip("subprocess helper")
}
}

func TestRunBounded_UsesDirectory(t *testing.T) {
dir := t.TempDir()
t.Setenv("TESTUTIL_BOUNDED_CHILD", "cwd")

stdout, stderr, exitCode := RunBounded(t, dir, 5*time.Second, os.Args[0], "-test.run=^TestRunBoundedDirectoryChild$")
if exitCode != 0 {
t.Fatalf("exitCode = %d, want 0; stderr: %s", exitCode, stderr)
}
got := strings.SplitN(strings.TrimSpace(stdout), "\n", 2)[0]
if runtime.GOOS == "windows" {
got = strings.ToLower(got)
dir = strings.ToLower(dir)
}
if got != dir {
t.Fatalf("child cwd = %q, want %q", got, dir)
}
}

func TestRunBoundedDirectoryChild(t *testing.T) {
if os.Getenv("TESTUTIL_BOUNDED_CHILD") != "cwd" {
t.Skip("subprocess helper")
}
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
fmt.Fprintln(os.Stdout, dir)
}
Loading