Add remote daemon distribution regressions
This commit is contained in:
parent
b6f0e3a3f6
commit
76cfe01fa2
6 changed files with 276 additions and 4 deletions
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -28,6 +28,27 @@ jobs:
|
|||
- name: Validate GhosttyKit checksum verification
|
||||
run: ./tests/test_ci_ghosttykit_checksum_verification.sh
|
||||
|
||||
- name: Validate release asset guard
|
||||
run: node scripts/release_asset_guard.test.js
|
||||
|
||||
remote-daemon-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version-file: daemon/remote/go.mod
|
||||
|
||||
- name: Run remote daemon tests
|
||||
working-directory: daemon/remote
|
||||
run: go test ./...
|
||||
|
||||
- name: Validate remote daemon release assets
|
||||
run: ./tests/test_remote_daemon_release_assets.sh
|
||||
|
||||
web-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
|
|
|||
|
|
@ -659,6 +659,48 @@ final class WindowTransparencyDecisionTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class WorkspaceRemoteDaemonManifestTests: XCTestCase {
|
||||
func testParsesEmbeddedRemoteDaemonManifestJSON() throws {
|
||||
let manifestJSON = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"appVersion": "0.62.0",
|
||||
"releaseTag": "v0.62.0",
|
||||
"releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0",
|
||||
"checksumsAssetName": "cmuxd-remote-checksums.txt",
|
||||
"checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt",
|
||||
"entries": [
|
||||
{
|
||||
"goOS": "linux",
|
||||
"goArch": "amd64",
|
||||
"assetName": "cmuxd-remote-linux-amd64",
|
||||
"downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64",
|
||||
"sha256": "abc123"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let manifest = Workspace.remoteDaemonManifest(from: [
|
||||
Workspace.remoteDaemonManifestInfoKey: manifestJSON,
|
||||
])
|
||||
|
||||
XCTAssertEqual(manifest?.releaseTag, "v0.62.0")
|
||||
XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64")
|
||||
}
|
||||
|
||||
func testRemoteDaemonCachePathIsVersionedByPlatform() throws {
|
||||
let url = try Workspace.remoteDaemonCachedBinaryURL(
|
||||
version: "0.62.0",
|
||||
goOS: "linux",
|
||||
goArch: "arm64"
|
||||
)
|
||||
|
||||
XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/"))
|
||||
XCTAssertEqual(url.lastPathComponent, "cmuxd-remote")
|
||||
}
|
||||
}
|
||||
|
||||
final class WindowBackgroundSelectionGateTests: XCTestCase {
|
||||
func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() {
|
||||
let tabId = UUID()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -108,6 +114,80 @@ func startMockTCPSocket(t *testing.T, response string) string {
|
|||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func startMockAuthenticatedTCPSocket(t *testing.T, relayID, relayToken, response string) string {
|
||||
t.Helper()
|
||||
relayTokenBytes := mustHex(t, relayToken)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen on TCP: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
nonce := "testnonce"
|
||||
challenge, _ := json.Marshal(map[string]any{
|
||||
"protocol": "cmux-relay-auth",
|
||||
"version": 1,
|
||||
"relay_id": relayID,
|
||||
"nonce": nonce,
|
||||
})
|
||||
_, _ = conn.Write(append(challenge, '\n'))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var authResp map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &authResp); err != nil {
|
||||
_, _ = conn.Write([]byte(`{"ok":false}` + "\n"))
|
||||
return
|
||||
}
|
||||
macHex, _ := authResp["mac"].(string)
|
||||
receivedMAC, err := hex.DecodeString(macHex)
|
||||
if err != nil {
|
||||
_, _ = conn.Write([]byte(`{"ok":false}` + "\n"))
|
||||
return
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, relayTokenBytes)
|
||||
_, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1))
|
||||
expectedMAC := h.Sum(nil)
|
||||
if !hmac.Equal(receivedMAC, expectedMAC) {
|
||||
_, _ = conn.Write([]byte(`{"ok":false}` + "\n"))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = conn.Write([]byte(`{"ok":true}` + "\n"))
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := conn.Read(buf)
|
||||
_, _ = conn.Write([]byte(response))
|
||||
if n > 0 && !strings.HasSuffix(response, "\n") {
|
||||
_, _ = conn.Write([]byte("\n"))
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func mustHex(t *testing.T, value string) []byte {
|
||||
t.Helper()
|
||||
data, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
t.Fatalf("decode hex: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDialTCPRetrySuccess(t *testing.T) {
|
||||
// Get a free port, then close the listener so connection is refused initially.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
|
|
@ -175,6 +255,47 @@ func TestCLIPingV1OverTCP(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIPingV1OverAuthenticatedTCPWithEnv(t *testing.T) {
|
||||
relayID := "relay-1"
|
||||
relayToken := strings.Repeat("a1", 32)
|
||||
addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong")
|
||||
t.Setenv("CMUX_RELAY_ID", relayID)
|
||||
t.Setenv("CMUX_RELAY_TOKEN", relayToken)
|
||||
|
||||
code := runCLI([]string{"--socket", addr, "ping"})
|
||||
if code != 0 {
|
||||
t.Fatalf("ping over authenticated TCP should return 0, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIPingV1OverAuthenticatedTCPWithRelayFile(t *testing.T) {
|
||||
relayID := "relay-2"
|
||||
relayToken := strings.Repeat("b2", 32)
|
||||
addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong")
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split host port: %v", err)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("CMUX_RELAY_ID", "")
|
||||
t.Setenv("CMUX_RELAY_TOKEN", "")
|
||||
relayDir := filepath.Join(home, ".cmux", "relay")
|
||||
if err := os.MkdirAll(relayDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir relay dir: %v", err)
|
||||
}
|
||||
authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken})
|
||||
if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil {
|
||||
t.Fatalf("write auth file: %v", err)
|
||||
}
|
||||
|
||||
code := runCLI([]string{"--socket", addr, "ping"})
|
||||
if code != 0 {
|
||||
t.Fatalf("ping over authenticated TCP file relay should return 0, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialSocketDetection(t *testing.T) {
|
||||
// Unix socket paths should attempt Unix dial
|
||||
for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} {
|
||||
|
|
|
|||
|
|
@ -247,6 +247,25 @@ func TestProxyStreamRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetIntParamRejectsFractionalFloat64(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"port": 80.9,
|
||||
"timeout_ms": 100.0,
|
||||
}
|
||||
|
||||
if _, ok := getIntParam(params, "port"); ok {
|
||||
t.Fatalf("fractional float64 should be rejected")
|
||||
}
|
||||
|
||||
timeout, ok := getIntParam(params, "timeout_ms")
|
||||
if !ok {
|
||||
t.Fatalf("integral float64 should be accepted")
|
||||
}
|
||||
if timeout != 100 {
|
||||
t.Fatalf("timeout_ms = %d, want 100", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStdioOversizedFrameContinuesServing(t *testing.T) {
|
||||
oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}`
|
||||
input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n")
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const {
|
|||
|
||||
test("marks guard as complete and skips build/upload when all immutable assets already exist", () => {
|
||||
const result = evaluateReleaseAssetGuard({
|
||||
existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"],
|
||||
existingAssetNames: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS);
|
||||
|
|
@ -36,12 +36,16 @@ test("marks guard as clear when immutable assets are not present", () => {
|
|||
});
|
||||
|
||||
test("marks guard as partial when only some immutable assets exist", () => {
|
||||
const partialAssets = ["appcast.xml", "cmuxd-remote-manifest.json"];
|
||||
const result = evaluateReleaseAssetGuard({
|
||||
existingAssetNames: ["appcast.xml"],
|
||||
existingAssetNames: partialAssets,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.conflicts, ["appcast.xml"]);
|
||||
assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]);
|
||||
assert.deepEqual(result.conflicts, partialAssets);
|
||||
assert.deepEqual(
|
||||
result.missingImmutableAssets,
|
||||
IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)),
|
||||
);
|
||||
assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL);
|
||||
assert.equal(result.hasPartialConflict, true);
|
||||
assert.equal(result.shouldSkipBuildAndUpload, false);
|
||||
|
|
|
|||
65
tests/test_remote_daemon_release_assets.sh
Executable file
65
tests/test_remote_daemon_release_assets.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUTPUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-test.XXXXXX")"
|
||||
trap 'rm -rf "$OUTPUT_DIR"' EXIT
|
||||
|
||||
"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \
|
||||
--version "0.62.0-test" \
|
||||
--release-tag "v0.62.0-test" \
|
||||
--repo "manaflow-ai/cmux" \
|
||||
--output-dir "$OUTPUT_DIR" >/dev/null
|
||||
|
||||
for asset in \
|
||||
cmuxd-remote-darwin-arm64 \
|
||||
cmuxd-remote-darwin-amd64 \
|
||||
cmuxd-remote-linux-arm64 \
|
||||
cmuxd-remote-linux-amd64 \
|
||||
cmuxd-remote-checksums.txt \
|
||||
cmuxd-remote-manifest.json
|
||||
do
|
||||
if [[ ! -f "$OUTPUT_DIR/$asset" ]]; then
|
||||
echo "FAIL: missing asset $asset" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
python3 - <<'PY' "$OUTPUT_DIR/cmuxd-remote-manifest.json" "$OUTPUT_DIR/cmuxd-remote-checksums.txt"
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
manifest_path = Path(sys.argv[1])
|
||||
checksums_path = Path(sys.argv[2])
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
expected_targets = {
|
||||
("darwin", "arm64"),
|
||||
("darwin", "amd64"),
|
||||
("linux", "arm64"),
|
||||
("linux", "amd64"),
|
||||
}
|
||||
actual_targets = {(entry["goOS"], entry["goArch"]) for entry in manifest["entries"]}
|
||||
if actual_targets != expected_targets:
|
||||
raise SystemExit(f"FAIL: manifest targets {sorted(actual_targets)} != {sorted(expected_targets)}")
|
||||
|
||||
if manifest["appVersion"] != "0.62.0-test":
|
||||
raise SystemExit(f"FAIL: unexpected appVersion {manifest['appVersion']}")
|
||||
if manifest["releaseTag"] != "v0.62.0-test":
|
||||
raise SystemExit(f"FAIL: unexpected releaseTag {manifest['releaseTag']}")
|
||||
if not manifest["checksumsURL"].endswith("/cmuxd-remote-checksums.txt"):
|
||||
raise SystemExit(f"FAIL: unexpected checksumsURL {manifest['checksumsURL']}")
|
||||
|
||||
checksum_lines = [line for line in checksums_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
if len(checksum_lines) != 4:
|
||||
raise SystemExit(f"FAIL: expected 4 checksum lines, got {len(checksum_lines)}")
|
||||
|
||||
for entry in manifest["entries"]:
|
||||
if not entry["downloadURL"].endswith("/" + entry["assetName"]):
|
||||
raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}")
|
||||
if len(entry["sha256"]) != 64:
|
||||
raise SystemExit(f"FAIL: invalid sha256 for {entry['assetName']}")
|
||||
|
||||
print("PASS: remote daemon release assets include all targets and manifest entries")
|
||||
PY
|
||||
Loading…
Add table
Add a link
Reference in a new issue