diff --git a/.gitattributes b/.gitattributes index 9f48f5f..c27a46e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,8 @@ # This is the mirror image of tailcat's own .gitattributes, which pins # build-tags.txt to eol=lf for the same class of reason. *.patch -text + +# Same class of bug for the shell scripts CI runs under Git Bash on Windows +# (e2e/require-agent-e2e-ran.sh in windows-e2e): an autocrlf=true checkout +# makes them CRLF, and bash then reads every line with a trailing \r. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76f3343..6a7b8a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,13 +97,14 @@ jobs: # Run the agent E2E suite only after the real binaries exist; the # earlier go test step intentionally runs before ./build.sh and those - # tests skip when dist/ is absent. + # tests skip when dist/ is absent. The wrapper fails the step if any + # of them still skipped for a missing binary, or none ran at all. - name: End-to-end test the agent against the binaries just built env: GOTOOLCHAIN: local MEOWSHELL: ${{ github.workspace }}/dist/meowshell_linux_amd64 TAILCAT: ${{ github.workspace }}/dist/tailcat_linux_amd64 - run: go test ./cmd/meowshell/ -count=1 + run: ./e2e/require-agent-e2e-ran.sh go test -v ./cmd/meowshell/ -count=1 - name: Scan the patched tailcat build input for known vulnerabilities env: @@ -244,7 +245,9 @@ jobs: windows-e2e: needs: build runs-on: windows-latest - timeout-minutes: 15 + # Was 15 before this job also ran the Go agent E2E suite, which recompiles + # and reruns cmd/meowshell's tests on top of the vet/test step. + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -287,7 +290,8 @@ jobs: # validation, N9) only builds and runs under GOOS=windows, so it never # gets exercised by the "build" job's own go vet/go test, which run on # ubuntu-latest -- this is the only place in CI a real Windows host - # actually type-checks and runs that code. + # actually type-checks and runs that code. The agent E2E tests skip + # here, since the binaries are not fetched yet; they run further down. - name: Vet and test meowshell on Windows shell: bash env: @@ -324,6 +328,24 @@ jobs: name: tailcat-binaries path: ${{ runner.temp }}/meowshell-dist + # The same agent E2E suite the "build" job runs after ./build.sh, here + # against the Windows binaries it built. Until this step existed the + # suite only ever ran in the vet/test step above, with no binaries and + # a fallback that only knew linux_amd64, so all of it skipped and the + # job went green. MEOWSHELL/TAILCAT stay explicit so a broken binary + # fails rather than skips; the wrapper fails the step if anything + # still skipped for a missing binary. Before the DERP relay step on + # purpose: that exports TAILCAT_DERPMAP_URL for later steps, and these + # tests run their servers in TS_DEBUG_TAILCAT_LOCAL_DERP mode instead, + # exactly as they do in the "build" job, which never sets it. + - name: End-to-end test the agent against the Windows binaries + shell: bash + env: + GOTOOLCHAIN: local + MEOWSHELL: ${{ runner.temp }}\meowshell-dist\meowshell_windows_amd64.exe + TAILCAT: ${{ runner.temp }}\meowshell-dist\tailcat_windows_amd64.exe + run: ./e2e/require-agent-e2e-ran.sh go test -v ./cmd/meowshell/ -count=1 + - name: Start a local DERP relay for the E2E tests shell: pwsh run: ./e2e/start-testderp.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 70a7d53..511c413 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,13 +51,19 @@ fail once before believing it. Both E2E suites are wired into CI, but only just: the `dotnet` job sets `DOTNET_E2E_*`, and the `build` job runs the Go agent E2E tests in a step *after* `./build.sh`, because the `go test ./...` step before it has no `dist/` -yet and skipped all 22 for as long as they existed. Those two steps are the -only reason any of it runs — `findE2EBinary`'s `../../dist` fallback skips on a -missing file, while an explicit `$MEOWSHELL`/`$TAILCAT` is returned unchecked, -so keep setting them and a broken binary fails loudly instead of going quiet. -Windows is still dark: `windows-e2e` also runs `go test` before fetching the -artifact, and the dist name `findE2EBinary` falls back to is hardcoded -`linux_amd64`. +yet and skipped all 22 for as long as they existed. `windows-e2e` does the +same against the Windows binaries, in a step after it downloads the build +artifact (its own earlier `go test` step skips them too). Those steps are the +only reason any of it runs — `findE2EBinary`'s `../../dist` fallback (named +for the host's GOOS/GOARCH as `./build.sh` names it, `.exe` on Windows) skips +on a missing file, while an explicit `$MEOWSHELL`/`$TAILCAT` is returned +unchecked, so keep setting them and a broken binary fails loudly instead of +going quiet. Both Go agent E2E steps run through `e2e/require-agent-e2e-ran.sh`, +which fails the step if any test skipped for a missing binary or none resolved +one; it needs `go test -v`, since it greps the markers `findE2EBinary` logs. +The .NET E2E suite has no such guard and runs on Linux only: there is no .NET +job on Windows, and `FindRealBinaries` also no-ops when a `DOTNET_E2E_*` path +does not exist, so a wrong path there still goes quiet. ## E2E tests and the DERP relay diff --git a/cmd/meowshell/agent_auth_e2e_test.go b/cmd/meowshell/agent_auth_e2e_test.go index 9e442c7..84dea1a 100644 --- a/cmd/meowshell/agent_auth_e2e_test.go +++ b/cmd/meowshell/agent_auth_e2e_test.go @@ -79,7 +79,7 @@ func acceptHostKeyPrompt(t *testing.T, stdin *os.File, out *bufio.Reader) { } func TestAgentPasswordAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _ := startAuthTestSSHServer(t, func(cfg *ssh.ServerConfig) { cfg.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { @@ -127,7 +127,7 @@ func TestAgentPasswordAuth(t *testing.T) { } func TestAgentSuppliedPrivateKeyAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") privatePEM, publicKey := newTestKeyPair(t) addr, _ := startAuthTestSSHServer(t, func(cfg *ssh.ServerConfig) { @@ -189,7 +189,7 @@ func driveKeystoreAuth(t *testing.T, stdin *os.File, out *bufio.Reader, signer s } func TestAgentKeystoreKeyAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") // An RSA key is the case the key-type-as-signature-algorithm shortcut got // wrong: ssh-rsa means SHA-1, which every current server refuses. The server @@ -312,7 +312,7 @@ func newEncryptedTestKeyPair(t *testing.T, passphrase string) (encryptedPEM []by // complete. mustReadFrame's 30s deadline (see readFrameWithDeadline) turns a // regression here into a clean failure instead of a hung test. func TestAgentEncryptedSuppliedPrivateKeyAuth(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") const passphrase = "correct-passphrase" encryptedPEM, publicKey := newEncryptedTestKeyPair(t, passphrase) diff --git a/cmd/meowshell/agent_e2e_test.go b/cmd/meowshell/agent_e2e_test.go index fda9f49..0c22810 100644 --- a/cmd/meowshell/agent_e2e_test.go +++ b/cmd/meowshell/agent_e2e_test.go @@ -9,13 +9,14 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "time" ) func TestAgentEndToEnd(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) @@ -61,7 +62,15 @@ func TestAgentEndToEnd(t *testing.T) { }) t.Run("exec channel with a nonzero exit reports it as a structured value", func(t *testing.T) { - send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: []string{"sh", "-c", "'exit 42'"}}) + // The server runs an exec command through its user's shell: sh on + // Unix, but `pwsh -Command` on Windows, which turns a failing native + // command's exit code into 1 -- so `sh -c 'exit 42'` read back 1 + // there. `exit 42` is what each shell itself exits 42 on. + command := []string{"sh", "-c", "'exit 42'"} + if runtime.GOOS == "windows" { + command = []string{"exit", "42"} + } + send(t, stdin, 0, controlMessage{Msg: "open_channel", Kind: "exec", Command: command}) id := expectChannelOpened(t, out) exitCode := readExitOnly(t, out, id) @@ -77,34 +86,92 @@ func TestAgentEndToEnd(t *testing.T) { send(t, stdin, id, controlMessage{Msg: "resize", Cols: 120, Rows: 40}) - mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("echo shell-marker-e2e\n")}) + // Enter is \r, as a real terminal (and the app's xterm.js) sends it. + // A Unix pty turns it into \n, which is why \n used to work here; the + // Windows server's ConPTY does not, and PowerShell took \n as + // Ctrl+Enter -- a new line, never a submitted command -- so "exit" + // sat unrun until the frame read timed out. + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("echo shell-marker-e2e\r")}) got := readUntil(t, out, id, "shell-marker-e2e", 20*time.Second) if !bytes.Contains(got, []byte("shell-marker-e2e")) { t.Errorf("shell output = %q, want it to contain the echoed marker", got) } - mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("exit\n")}) + mustWriteFrame(t, stdin, frame{Type: frameTypeData, ChannelID: id, Payload: []byte("exit\r")}) readUntilExit(t, out, id) }) } -func findE2EBinary(t *testing.T, envVar, distName string) string { +// CI greps the `go test -v` log of every step meant to run these tests for +// both markers (e2e/require-agent-e2e-ran.sh): any "missing" line, or no +// "resolved" line at all, fails the step. Without that, a job whose binaries +// were never wired up passes green with every E2E test skipped -- which is +// exactly how windows-e2e ran none of them for as long as they existed. +const ( + e2eBinaryMissingMarker = "E2E binary missing:" + e2eBinaryResolvedMarker = "E2E binary resolved:" +) + +// findE2EBinary returns the real binary for this test: $envVar if set, +// else the one ./build.sh wrote for this host under ../../dist. +// +// An explicit $envVar is returned unchecked on purpose: CI always sets it, so +// a missing or broken binary fails the test loudly instead of skipping. +func findE2EBinary(t *testing.T, envVar, name string) string { t.Helper() if p := os.Getenv(envVar); p != "" { + t.Logf("%s $%s=%s", e2eBinaryResolvedMarker, envVar, p) return p } + distName := e2eDistName(name, runtime.GOOS, runtime.GOARCH) p := filepath.Join("..", "..", "dist", distName) if _, err := os.Stat(p); err != nil { - t.Skipf("no %s (looked for $%s and %s); build.sh must run first", distName, envVar, p) + t.Skipf("%s no %s (looked for $%s and %s); build.sh must run first", e2eBinaryMissingMarker, distName, envVar, p) } abs, err := filepath.Abs(p) if err != nil { t.Fatal(err) } + t.Logf("%s %s", e2eBinaryResolvedMarker, abs) return abs } +// e2eDistName is the file name ./build.sh's build() gives for +// goos/goarch. This used to be the literal "..._linux_amd64" at every call +// site, so on any other host -- Windows above all, where the file also ends +// in .exe -- the fallback looked for a binary that was never there and every +// E2E test skipped. build.sh only builds arm as GOARM=7, hence "armv7". +func e2eDistName(name, goos, goarch string) string { + s := name + "_" + goos + "_" + goarch + if goarch == "arm" { + s += "v7" + } + if goos == "windows" { + s += ".exe" + } + return s +} + +// The wanted names are copied from what ./build.sh writes (its build() and +// targets_for), not derived from e2eDistName's own logic: the point is that +// the fallback finds build.sh's output on the host running the tests. +func TestE2EDistNameMatchesBuildSh(t *testing.T) { + for _, tc := range []struct{ name, goos, goarch, want string }{ + {"meowshell", "linux", "amd64", "meowshell_linux_amd64"}, + {"tailcat", "linux", "arm64", "tailcat_linux_arm64"}, + {"meowshell", "linux", "arm", "meowshell_linux_armv7"}, + {"tailcat", "linux", "386", "tailcat_linux_386"}, + {"meowshell", "windows", "amd64", "meowshell_windows_amd64.exe"}, + {"tailcat", "windows", "arm64", "tailcat_windows_arm64.exe"}, + {"meowshell", "android", "arm64", "meowshell_android_arm64"}, + } { + if got := e2eDistName(tc.name, tc.goos, tc.goarch); got != tc.want { + t.Errorf("e2eDistName(%q, %q, %q) = %q, want %q", tc.name, tc.goos, tc.goarch, got, tc.want) + } + } +} + func startE2EServer(t *testing.T, tailcatBin, meowshellBin, home string) string { t.Helper() addrFile := filepath.Join(home, "addr") diff --git a/cmd/meowshell/agent_forward_e2e_test.go b/cmd/meowshell/agent_forward_e2e_test.go index b78214f..7a90cff 100644 --- a/cmd/meowshell/agent_forward_e2e_test.go +++ b/cmd/meowshell/agent_forward_e2e_test.go @@ -10,7 +10,7 @@ import ( ) func TestAgentLocalForwardEndToEnd(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -65,7 +65,7 @@ func TestAgentLocalForwardEndToEnd(t *testing.T) { } func TestAgentSOCKSForwardEndToEnd(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/cmd/meowshell/agent_health_e2e_test.go b/cmd/meowshell/agent_health_e2e_test.go index 649da5f..47bfd96 100644 --- a/cmd/meowshell/agent_health_e2e_test.go +++ b/cmd/meowshell/agent_health_e2e_test.go @@ -44,8 +44,8 @@ import ( // legitimately report while it's still settling in a sandboxed test // environment. func TestAgentRelayHealthReporterAttachesToARealTailcatConnection(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) diff --git a/cmd/meowshell/agent_security_e2e_test.go b/cmd/meowshell/agent_security_e2e_test.go index 34a1ceb..d0e43c4 100644 --- a/cmd/meowshell/agent_security_e2e_test.go +++ b/cmd/meowshell/agent_security_e2e_test.go @@ -11,7 +11,7 @@ import ( ) func TestAgentRejectsNonLoopbackBindByDefault(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, _ := startTestSSHServer(t, echoCommandHandler) knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -63,7 +63,7 @@ func TestAgentUnixSocketForward(t *testing.T) { if os.PathSeparator == '\\' { t.Skip("unix domain sockets aren't this test's concern on Windows") } - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -122,7 +122,7 @@ func TestAgentUnixSocketForward(t *testing.T) { } func TestAgentSocksAuthToken(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") backendLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -207,7 +207,7 @@ func TestAgentSocksAuthToken(t *testing.T) { } func TestAgentConfigureCarriesProxyURL(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, _ := startTestSSHServer(t, echoCommandHandler) proxyLn, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/cmd/meowshell/agent_sftp_e2e_test.go b/cmd/meowshell/agent_sftp_e2e_test.go index 95e28ed..03949a8 100644 --- a/cmd/meowshell/agent_sftp_e2e_test.go +++ b/cmd/meowshell/agent_sftp_e2e_test.go @@ -7,13 +7,30 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "testing" "time" ) +// servedFileMode is the permission bits a server on this platform reports +// for a file chmod-ed to mode. Windows has no Unix permissions: Go's +// os.Chmod there sets or clears only the read-only attribute, from the owner +// write bit, and os.Stat reports 0666 or 0444. Expecting 0640 back failed +// the first time these tests ran on Windows; the exact bits are the Linux +// job's to check. +func servedFileMode(mode uint32) uint32 { + if runtime.GOOS != "windows" { + return mode + } + if mode&0o200 != 0 { + return 0o666 + } + return 0o444 +} + func TestAgentSFTPEndToEnd(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) @@ -46,8 +63,8 @@ func TestAgentSFTPEndToEnd(t *testing.T) { sftpOp(t, stdin, out, controlMessage{Op: "chmod", Path: "adir/file.txt", Mode: 0o640}) stat = sftpOp(t, stdin, out, controlMessage{Op: "stat", Path: "adir/file.txt"}) - if got := stat.Entries[0].Mode & 0o777; got != 0o640 { - t.Errorf("mode after chmod = %o, want 0640", got) + if got, want := stat.Entries[0].Mode&0o777, servedFileMode(0o640); got != want { + t.Errorf("mode after chmod = %o, want %o", got, want) } sftpOp(t, stdin, out, controlMessage{Op: "rename", Path: "adir/file.txt", NewPath: "adir/renamed.txt"}) @@ -71,8 +88,8 @@ func TestAgentSFTPEndToEnd(t *testing.T) { if len(stat.Entries) != 1 { t.Fatalf("stat = %+v", stat.Entries) } - if got := stat.Entries[0].Mode & 0o777; got != 0o600 { - t.Errorf("preserved mode = %o, want 0600", got) + if got, want := stat.Entries[0].Mode&0o777, servedFileMode(0o600); got != want { + t.Errorf("preserved mode = %o, want %o", got, want) } if got := stat.Entries[0].ModTime; got != mtime.Unix() { t.Errorf("preserved mtime = %d, want %d", got, mtime.Unix()) diff --git a/cmd/meowshell/agent_tailcat_forward_e2e_test.go b/cmd/meowshell/agent_tailcat_forward_e2e_test.go index 1f3ef17..ae990c0 100644 --- a/cmd/meowshell/agent_tailcat_forward_e2e_test.go +++ b/cmd/meowshell/agent_tailcat_forward_e2e_test.go @@ -13,8 +13,8 @@ import ( ) func TestAgentForwardsThroughTailcatDestination(t *testing.T) { - tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat_linux_amd64") - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + tailcatBin := findE2EBinary(t, "TAILCAT", "tailcat") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") home := t.TempDir() t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) diff --git a/cmd/meowshell/agent_tcp_e2e_test.go b/cmd/meowshell/agent_tcp_e2e_test.go index a27e396..bdcfd8d 100644 --- a/cmd/meowshell/agent_tcp_e2e_test.go +++ b/cmd/meowshell/agent_tcp_e2e_test.go @@ -26,7 +26,7 @@ import ( // keeps working normally afterward (a real exec command run against it still // completes and returns its output). func TestNonTerminalErrorDoesNotEndTheChannel(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, stop := startTestSSHServer(t, echoCommandHandler) defer stop() knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -69,7 +69,7 @@ func TestNonTerminalErrorDoesNotEndTheChannel(t *testing.T) { } func TestAgentTCPEndToEnd(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, hostKey1, stopServer1 := startTestSSHServer(t, echoCommandHandler) knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -139,7 +139,7 @@ func TestAgentTCPEndToEnd(t *testing.T) { // nothing was listening to (what MeowshellAgentConnection sends when no // HostKeyPromptRequested handler is attached). func TestUnknownHostKeyIsReportedAsHostKeyUnknown(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, stopServer := startTestSSHServer(t, echoCommandHandler) defer stopServer() @@ -191,7 +191,7 @@ func TestUnknownHostKeyIsReportedAsHostKeyUnknown(t *testing.T) { // unwind a "successfully opened" channel for. A server that rejects the // exec request lets the test trigger that failure deterministically. func TestOpenShellChannelReportsOpenFailureNotChannelOpenedThenError(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, stop := startTestSSHServerRejectingSessionStart(t) defer stop() knownHosts := filepath.Join(t.TempDir(), "known_hosts") @@ -309,7 +309,7 @@ func startTestSSHServerRejectingSessionStart(t *testing.T) (addr string, stop fu // ordinary exec channel opened right after it must still succeed promptly // rather than wait on the first. func TestOpenChannelDoesNotBlockOtherChannels(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") const stuckCommand = "stuck" addr, stop := startTestSSHServerHangingOnExecCommand(t, stuckCommand) defer stop() diff --git a/cmd/meowshell/mosh_agent_e2e_test.go b/cmd/meowshell/mosh_agent_e2e_test.go index 09b5e5f..b2aef8a 100644 --- a/cmd/meowshell/mosh_agent_e2e_test.go +++ b/cmd/meowshell/mosh_agent_e2e_test.go @@ -20,7 +20,7 @@ import ( // uses), a real system mosh-server for the bootstrap's exec command, and a // real Mosh/UDP client dialing it -- the same three pieces production wires // together, just all on loopback. They skip (via findE2EBinary) without a -// built dist/meowshell_linux_amd64, and skip outright if this host has no +// built dist/meowshell for this host, and skip outright if this host has no // mosh-server on PATH: neither is optional stand-in behavior worth faking, // since bootstrapMosh's whole job is parsing that program's real output. @@ -126,7 +126,7 @@ func acceptMoshHostKey(t *testing.T, stdin *os.File, out *bufio.Reader) { // here proven from the outside through the compiled binary instead. func TestMoshAgentEndToEnd(t *testing.T) { requireMoshServerBinary(t) - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") pids := make(chan int, 1) addr, _, stop := startTestSSHServer(t, moshServerExecHandler(pids)) @@ -204,7 +204,7 @@ func TestMoshAgentEndToEnd(t *testing.T) { // confirms that message actually reaches the client as a structured "error" // rather than the process just hanging or exiting silently. func TestMoshAgentSurfacesAMissingMoshServer(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") addr, _, stop := startTestSSHServer(t, moshServerNotInstalledExecHandler) defer stop() @@ -241,7 +241,7 @@ func TestMoshAgentSurfacesAMissingMoshServer(t *testing.T) { // init()'s os.Args dispatch and main's actual os.Exit(1), which the direct // moshAgentCmd() call can't observe on its own. func TestMoshAgentRejectsTailcatAddressAsARealProcess(t *testing.T) { - meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell_linux_amd64") + meowshellBin := findE2EBinary(t, "MEOWSHELL", "meowshell") cmd := exec.Command(meowshellBin, "mosh-agent", validTailcatAddress) var stderr bytes.Buffer diff --git a/e2e/require-agent-e2e-ran.sh b/e2e/require-agent-e2e-ran.sh new file mode 100755 index 0000000..78985fd --- /dev/null +++ b/e2e/require-agent-e2e-ran.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run a `go test -v` command and fail unless the agent E2E tests really ran. +# +# ./e2e/require-agent-e2e-ran.sh go test -v ./cmd/meowshell/ -count=1 +# +# The agent E2E tests skip rather than fail when findE2EBinary finds no real +# binary, so a green `go test` says nothing about whether any of them ran: +# windows-e2e passed for as long as they existed with all of them skipped +# (it ran go test before fetching the binaries, and the fallback only knew +# the linux_amd64 file name). This fails the step if any test skipped for a +# missing binary, or if none resolved one at all -- the latter catches the +# tests having been filtered out or moved, which would skip nothing and +# still run nothing. The markers are the constants next to findE2EBinary in +# cmd/meowshell/agent_e2e_test.go; -v is required, or t.Logf/t.Skipf output +# never reaches the log. +set -uo pipefail + +log=$(mktemp) +trap 'rm -f "$log"' EXIT + +"$@" 2>&1 | tee "$log" +status=$? +if [ "$status" -ne 0 ]; then + exit "$status" +fi + +if grep -F 'E2E binary missing:' "$log" >/dev/null; then + echo "::error::agent E2E tests skipped for a missing binary; set \$MEOWSHELL and \$TAILCAT to the binaries this job built or fetched:" >&2 + grep -F 'E2E binary missing:' "$log" >&2 + exit 1 +fi +if ! grep -F 'E2E binary resolved:' "$log" >/dev/null; then + echo "::error::no agent E2E test resolved a binary, so none of them ran (was -v dropped, or the tests filtered out?)" >&2 + exit 1 +fi +echo "agent E2E guard: $(grep -cF 'E2E binary resolved:' "$log") binary lookups resolved, none skipped"