Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

730 lines
18 KiB
Go

package mobile
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
devicelink "github.com/tutti-os/tutti/packages/device-link"
"github.com/tutti-os/tutti/packages/device-link/authenticated"
"github.com/tutti-os/tutti/packages/device-link/candidateexchange"
)
func TestEncodeLocalDescriptionUsesEmptyCandidateArray(t *testing.T) {
t.Parallel()
raw, err := encodeLocalDescription(authenticated.Description{
Fingerprint: "fingerprint",
Ufrag: "username",
Pwd: "password",
})
if err != nil {
t.Fatal(err)
}
var description struct {
Candidates []string `json:"candidates"`
}
if err := json.Unmarshal([]byte(raw), &description); err != nil {
t.Fatal(err)
}
if description.Candidates == nil || len(description.Candidates) != 0 {
t.Fatalf("encoded candidates = %#v, want non-nil empty array", description.Candidates)
}
}
func TestStopCandidateExchangeCancelsRefreshWithoutClosingLink(t *testing.T) {
t.Parallel()
link, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer link.Close()
if _, err := link.StartLocalDescription(); err != nil {
t.Fatal(err)
}
pump, err := link.candidateActionPump()
if err != nil {
t.Fatal(err)
}
pump.NotifyRemoteChange()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
kinds := map[candidateexchange.ActionKind]bool{}
for range 2 {
action, err := pump.Next(ctx)
if err != nil {
t.Fatal(err)
}
kinds[action.Kind] = true
}
if !kinds[candidateexchange.ActionPublishLocal] || !kinds[candidateexchange.ActionRefreshRemote] {
t.Fatalf("candidate action kinds = %#v, want local and remote workers", kinds)
}
result := make(chan error, 1)
go func() {
_, nextErr := pump.Next(context.Background())
result <- nextErr
}()
link.StopCandidateExchange()
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("ActionPump.Next error = %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("candidate refresh did not stop")
}
select {
case <-link.participant.Done():
t.Fatal("stopping candidate workers closed the participant")
default:
}
}
func TestLoopbackLinksConnectWhileDescriptionsTrickle(t *testing.T) {
t.Parallel()
caller, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer caller.Close()
owner, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer owner.Close()
callerInitial, err := caller.StartLocalDescription()
if err != nil {
t.Fatal(err)
}
ownerInitial, err := owner.StartLocalDescription()
if err != nil {
t.Fatal(err)
}
for label, raw := range map[string]string{"caller": callerInitial, "owner": ownerInitial} {
var description map[string]any
if err := json.Unmarshal([]byte(raw), &description); err != nil {
t.Fatalf("decode %s initial description: %v", label, err)
}
if description["ufrag"] == "" || description["pwd"] == "" || description["fingerprint"] == "" {
t.Fatalf("%s initial description lacks credentials: %s", label, raw)
}
}
rendezvous := &testCandidateRendezvous{
descriptions: map[*Link]authenticated.Description{},
}
rendezvous.storeJSON(t, caller, callerInitial)
rendezvous.storeJSON(t, owner, ownerInitial)
actionResult := make(chan error, 2)
go runCandidateActions(caller, owner, rendezvous, actionResult)
go runCandidateActions(owner, caller, rendezvous, actionResult)
ownerResult := make(chan error, 1)
go func() {
_, connectErr := owner.Connect(callerInitial, false, 20_000)
ownerResult <- connectErr
}()
if _, err := caller.Connect(ownerInitial, true, 20_000); err != nil {
t.Fatal(err)
}
if err := <-ownerResult; err != nil {
t.Fatal(err)
}
caller.StopCandidateExchange()
owner.StopCandidateExchange()
for range 2 {
if err := <-actionResult; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
}
}
type testCandidateRendezvous struct {
mu sync.Mutex
descriptions map[*Link]authenticated.Description
}
func (r *testCandidateRendezvous) storeJSON(t *testing.T, link *Link, raw string) {
t.Helper()
var description authenticated.Description
if err := json.Unmarshal([]byte(raw), &description); err != nil {
t.Fatal(err)
}
r.store(link, description)
}
func (r *testCandidateRendezvous) store(link *Link, description authenticated.Description) {
r.mu.Lock()
r.descriptions[link] = description
r.mu.Unlock()
}
func (r *testCandidateRendezvous) candidates(link *Link) []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string{}, r.descriptions[link].Candidates...)
}
func runCandidateActions(
source *Link,
peer *Link,
rendezvous *testCandidateRendezvous,
result chan<- error,
) {
for {
raw, err := source.NextCandidateExchangeAction(20_000)
if err != nil {
result <- err
return
}
var action candidateExchangeAction
if err := json.Unmarshal([]byte(raw), &action); err != nil {
result <- err
return
}
switch action.Kind {
case candidateexchange.ActionPublishLocal:
if action.Description == nil {
result <- errors.New("publish action has no description")
return
}
rendezvous.store(source, *action.Description)
if err := peer.NotifyRemoteCandidateChange(); err != nil {
result <- err
return
}
if _, err := source.ResolveCandidateExchangeAction(
int64(action.ActionID), true, false, "[]",
); err != nil {
result <- err
return
}
case candidateexchange.ActionRefreshRemote:
candidates, err := json.Marshal(rendezvous.candidates(peer))
if err != nil {
result <- err
return
}
if _, err := source.ResolveCandidateExchangeAction(
int64(action.ActionID), true, false, string(candidates),
); err != nil {
result <- err
return
}
default:
result <- errors.New("unsupported candidate action")
return
}
}
}
func TestStreamReadPreservesFinalBytesReturnedWithEOF(t *testing.T) {
t.Parallel()
stream := &Stream{conn: &finalBytesConn{remaining: []byte("final response frame")}}
buffer := make([]byte, 1024)
count := stream.ReadInto(buffer)
if count != len("final response frame") {
t.Fatalf("read final bytes count = %d, want %d", count, len("final response frame"))
}
if string(buffer[:count]) != "final response frame" {
t.Fatalf("read = %q, want final response frame", buffer[:count])
}
if count := stream.ReadInto(buffer); count != -1 {
t.Fatalf("read after final bytes count = %d, want -1", count)
}
}
func TestLoopbackLinksExchangeDescriptionsAndStreams(t *testing.T) {
t.Parallel()
caller, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer caller.Close()
owner, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer owner.Close()
callerDescription, err := caller.LocalDescription(20_000)
if err != nil {
t.Fatal(err)
}
ownerDescription, err := owner.LocalDescription(20_000)
if err != nil {
t.Fatal(err)
}
ownerResult := make(chan error, 1)
go func() {
_, connectErr := owner.Connect(callerDescription, false, 20_000)
ownerResult <- connectErr
}()
if _, err := caller.Connect(ownerDescription, true, 20_000); err != nil {
t.Fatal(err)
}
if err := <-ownerResult; err != nil {
t.Fatal(err)
}
serveDone := make(chan error, 1)
go func() {
stream, acceptErr := owner.AcceptStream(20_000)
if acceptErr != nil {
serveDone <- acceptErr
return
}
defer stream.Close()
serveDone <- devicelink.ServeStreamProbe(context.Background(), &mobileStreamConn{stream: stream}, func(
_ context.Context,
conn net.Conn,
) error {
buffer := make([]byte, 1024)
for {
count, readErr := conn.Read(buffer)
if count > 0 {
if _, writeErr := conn.Write(buffer[:count]); writeErr != nil {
return writeErr
}
}
if readErr != nil {
return readErr
}
}
})
}()
stream, err := caller.OpenStream(20_000)
if err != nil {
t.Fatal(err)
}
if err := stream.SetDeadline(20_000); err != nil {
t.Fatal(err)
}
payload := []byte("gomobile-authenticated-link")
if _, err := stream.Write(payload); err != nil {
t.Fatal(err)
}
buffer := make([]byte, len(payload))
count := stream.ReadInto(buffer)
if count != len(payload) {
t.Fatalf("read count = %d, want %d", count, len(payload))
}
got := buffer[:count]
if string(got) != string(payload) {
t.Fatalf("echo = %q, want %q", got, payload)
}
if err := stream.Close(); err != nil {
t.Fatal(err)
}
if err := <-serveDone; err != nil && err != io.EOF {
t.Fatal(err)
}
}
func TestOpenStreamWithRelayStartsBeforeConnect(t *testing.T) {
t.Parallel()
server := newRelayEchoServer(t)
defer server.Close()
link, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer link.Close()
stream, err := link.OpenStreamWithRelay(
server.endpoint,
`{"authority_id":["authority-1"]}`,
`{"Authorization":["Bearer target-token"]}`,
"relay.test.v1",
5_000,
)
if err != nil {
t.Fatal(err)
}
defer stream.Close()
if got := stream.transport; got != "relay" {
t.Fatalf("transport = %q, want relay", got)
}
if err := stream.SetDeadline(5_000); err != nil {
t.Fatal(err)
}
if _, err := stream.Write([]byte("relay-payload")); err != nil {
t.Fatal(err)
}
buffer := make([]byte, len("relay-payload"))
count := stream.ReadInto(buffer)
if count != len(buffer) || string(buffer[:count]) != "relay-payload" {
t.Fatalf("relay echo = %q (%d bytes)", buffer[:max(count, 0)], count)
}
}
func TestOpenStreamWithRelayDoesNotSelectUnresponsiveDirectStream(t *testing.T) {
t.Parallel()
caller, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer caller.Close()
owner, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer owner.Close()
callerDescription, err := caller.LocalDescription(20_000)
if err != nil {
t.Fatal(err)
}
ownerDescription, err := owner.LocalDescription(20_000)
if err != nil {
t.Fatal(err)
}
ownerResult := make(chan error, 1)
go func() {
_, connectErr := owner.Connect(callerDescription, false, 20_000)
ownerResult <- connectErr
}()
if _, err := caller.Connect(ownerDescription, true, 20_000); err != nil {
t.Fatal(err)
}
if err := <-ownerResult; err != nil {
t.Fatal(err)
}
unresponsiveDirect := make(chan *Stream, 1)
go func() {
stream, acceptErr := owner.AcceptStream(5_000)
if acceptErr != nil {
return
}
unresponsiveDirect <- stream
}()
relay := newRelayEchoServer(t)
defer relay.Close()
stream, err := caller.OpenStreamWithRelay(
relay.endpoint,
`{"authority_id":["authority-1"]}`,
`{"Authorization":["Bearer target-token"]}`,
"relay.test.v1",
5_000,
)
if err != nil {
t.Fatal(err)
}
defer stream.Close()
if got := stream.transport; got != transportRelay {
t.Fatalf("transport = %q, want Relay after direct probe timeout", got)
}
select {
case direct := <-unresponsiveDirect:
_ = direct.Close()
case <-time.After(time.Second):
t.Fatal("direct stream was not accepted")
}
}
func TestOpenStreamCanStartBeforeConnect(t *testing.T) {
t.Parallel()
caller, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer caller.Close()
owner, err := NewLoopbackLink()
if err != nil {
t.Fatal(err)
}
defer owner.Close()
callerDescription, err := caller.LocalDescription(20_000)
if err != nil {
t.Fatal(err)
}
ownerDescription, err := owner.LocalDescription(20_000)
if err != nil {
t.Fatal(err)
}
streamResult := make(chan struct {
stream *Stream
err error
}, 1)
go func() {
stream, openErr := caller.OpenStream(5_000)
streamResult <- struct {
stream *Stream
err error
}{stream: stream, err: openErr}
}()
ownerResult := make(chan error, 1)
go func() {
_, connectErr := owner.Connect(callerDescription, false, 20_000)
ownerResult <- connectErr
}()
if _, err := caller.Connect(ownerDescription, true, 20_000); err != nil {
t.Fatal(err)
}
if err := <-ownerResult; err != nil {
t.Fatal(err)
}
acceptResult := make(chan struct {
stream *Stream
err error
}, 1)
go func() {
stream, acceptErr := owner.AcceptStream(5_000)
if acceptErr == nil {
probeErr := devicelink.ServeStreamProbe(context.Background(), &mobileStreamConn{stream: stream}, func(
_ context.Context,
_ net.Conn,
) error {
return nil
})
acceptErr = probeErr
}
acceptResult <- struct {
stream *Stream
err error
}{stream: stream, err: acceptErr}
}()
result := <-streamResult
if result.err != nil {
t.Fatal(result.err)
}
if result.stream == nil {
t.Fatal("OpenStream returned a nil stream")
}
defer result.stream.Close()
if result.stream.transport != "direct" {
t.Fatalf("transport = %q, want direct", result.stream.transport)
}
if _, err := result.stream.Write([]byte("pending-stream")); err != nil {
t.Fatal(err)
}
acceptedResult := <-acceptResult
if acceptedResult.err != nil {
t.Fatal(acceptedResult.err)
}
accepted := acceptedResult.stream
if accepted == nil {
t.Fatal("AcceptStream returned a nil stream")
}
defer accepted.Close()
}
func TestProtocolEpochMatchesApplicationPrelude(t *testing.T) {
if ProtocolEpoch() != ApplicationProtocolEpoch {
t.Fatalf("ProtocolEpoch() = %d, want %d", ProtocolEpoch(), ApplicationProtocolEpoch)
}
}
func TestDialRelayOpensConfiguredByteStream(t *testing.T) {
t.Parallel()
server := newRelayEchoServer(t)
defer server.Close()
stream, err := DialRelay(
server.endpoint,
`{"authority_id":["authority-1"]}`,
`{"Authorization":["Bearer target-token"]}`,
"relay.test.v1",
5_000,
)
if err != nil {
t.Fatal(err)
}
defer stream.Close()
if err := stream.SetDeadline(5_000); err != nil {
t.Fatal(err)
}
if _, err := stream.Write([]byte("relay-payload")); err != nil {
t.Fatal(err)
}
buffer := make([]byte, len("relay-payload"))
count := stream.ReadInto(buffer)
if count != len(buffer) || string(buffer[:count]) != "relay-payload" {
t.Fatalf("relay echo = %q (%d bytes)", buffer[:max(count, 0)], count)
}
}
func TestDialRelayRejectsMalformedValuesBeforeDial(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
query string
headers string
want string
}{
{name: "query", query: "[", headers: "{}", want: "decode relay query"},
{name: "headers", query: "{}", headers: "[", want: "decode relay headers"},
{name: "empty key", query: `{" ":["value"]}`, headers: "{}", want: "empty key"},
} {
t.Run(test.name, func(t *testing.T) {
_, err := DialRelay("ws://relay.invalid", test.query, test.headers, "relay.test.v1", 1)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("DialRelay() error = %v, want substring %q", err, test.want)
}
})
}
}
type relayTestServer struct {
endpoint string
server *httptest.Server
}
func newRelayEchoServer(t *testing.T) *relayTestServer {
t.Helper()
upgrader := websocket.Upgrader{
CheckOrigin: func(*http.Request) bool { return true },
Subprotocols: []string{"relay.test.v1"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("authority_id") != "authority-1" {
http.Error(w, "authority missing", http.StatusBadRequest)
return
}
if r.Header.Get("Authorization") != "Bearer target-token" {
http.Error(w, "authorization missing", http.StatusUnauthorized)
return
}
connection, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer connection.Close()
stream := &relayMessageConn{ws: connection}
_ = devicelink.ServeStreamProbe(context.Background(), stream, func(
_ context.Context,
stream net.Conn,
) error {
payload := make([]byte, len("relay-payload"))
if _, err := io.ReadFull(stream, payload); err != nil {
return err
}
_, err := stream.Write(payload)
return err
})
}))
return &relayTestServer{
endpoint: "ws" + strings.TrimPrefix(server.URL, "http"),
server: server,
}
}
type relayMessageConn struct {
ws *websocket.Conn
readMu sync.Mutex
readBuf []byte
writeMu sync.Mutex
}
func (c *relayMessageConn) Read(buffer []byte) (int, error) {
c.readMu.Lock()
defer c.readMu.Unlock()
for len(c.readBuf) == 0 {
messageType, payload, err := c.ws.ReadMessage()
if err != nil {
return 0, err
}
if messageType == websocket.BinaryMessage {
c.readBuf = payload
}
}
count := copy(buffer, c.readBuf)
c.readBuf = c.readBuf[count:]
return count, nil
}
func (c *relayMessageConn) Write(payload []byte) (int, error) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.ws.WriteMessage(websocket.BinaryMessage, payload); err != nil {
return 0, err
}
return len(payload), nil
}
func (c *relayMessageConn) Close() error { return c.ws.Close() }
func (c *relayMessageConn) LocalAddr() net.Addr { return c.ws.LocalAddr() }
func (c *relayMessageConn) RemoteAddr() net.Addr { return c.ws.RemoteAddr() }
func (c *relayMessageConn) SetDeadline(value time.Time) error {
if err := c.ws.SetReadDeadline(value); err != nil {
return err
}
return c.ws.SetWriteDeadline(value)
}
func (c *relayMessageConn) SetReadDeadline(value time.Time) error {
return c.ws.SetReadDeadline(value)
}
func (c *relayMessageConn) SetWriteDeadline(value time.Time) error {
return c.ws.SetWriteDeadline(value)
}
type mobileStreamConn struct {
stream *Stream
}
func (c *mobileStreamConn) Read(buffer []byte) (int, error) {
count := c.stream.ReadInto(buffer)
if count < 0 {
return 0, io.EOF
}
return count, nil
}
func (c *mobileStreamConn) Write(payload []byte) (int, error) {
return c.stream.Write(payload)
}
func (c *mobileStreamConn) Close() error { return c.stream.Close() }
func (c *mobileStreamConn) LocalAddr() net.Addr { return c.stream.conn.LocalAddr() }
func (c *mobileStreamConn) RemoteAddr() net.Addr { return c.stream.conn.RemoteAddr() }
func (c *mobileStreamConn) SetDeadline(value time.Time) error {
return c.stream.conn.SetDeadline(value)
}
func (c *mobileStreamConn) SetReadDeadline(value time.Time) error {
return c.stream.conn.SetReadDeadline(value)
}
func (c *mobileStreamConn) SetWriteDeadline(value time.Time) error {
return c.stream.conn.SetWriteDeadline(value)
}
func (s *relayTestServer) Close() {
s.server.Close()
}
func max(left, right int) int {
if left > right {
return left
}
return right
}
type finalBytesConn struct {
net.Conn
remaining []byte
}
func (c *finalBytesConn) Read(buffer []byte) (int, error) {
if len(c.remaining) == 0 {
return 0, io.EOF
}
count := copy(buffer, c.remaining)
c.remaining = c.remaining[count:]
return count, io.EOF
}