package desktop_tray import ( "context" "io" "testing" "time" "github.com/aperturerobotics/starpc/srpc" "github.com/s4wave/spacewave/bldr/resource" resource_client "github.com/s4wave/spacewave/bldr/resource/client" resource_server "github.com/s4wave/spacewave/bldr/resource/server" ) type testResourceClientContext struct { ctx context.Context nextID uint32 values map[uint32]any releases map[uint32]func() attached map[uint32]srpc.Client } func newTestResourceClientContext(ctx context.Context) *testResourceClientContext { return &testResourceClientContext{ ctx: ctx, values: make(map[uint32]any), releases: make(map[uint32]func()), attached: make(map[uint32]srpc.Client), } } func (c *testResourceClientContext) Context() context.Context { return c.ctx } func (c *testResourceClientContext) AddResource(mux srpc.Invoker, releaseFn func()) (uint32, error) { return c.AddResourceValue(mux, nil, releaseFn) } func (c *testResourceClientContext) AddResourceValue(mux srpc.Invoker, value any, releaseFn func()) (uint32, error) { // Allocate the next resource ID and record the value and release hook. c.nextID++ resourceID := c.nextID c.values[resourceID] = value c.releases[resourceID] = releaseFn return resourceID, nil } func (c *testResourceClientContext) ReleaseResource(resourceID uint32) bool { // Invoke and forget the release hook when the resource exists. releaseFn := c.releases[resourceID] if releaseFn == nil { return false } delete(c.releases, resourceID) delete(c.values, resourceID) releaseFn() return true } func (c *testResourceClientContext) GetResourceValue(resourceID uint32) (any, error) { value, ok := c.values[resourceID] if !ok { return nil, resource.ErrResourceNotFound } return value, nil } func (c *testResourceClientContext) GetAttachedResource(id uint32) (srpc.Client, error) { client := c.attached[id] if client == nil { return nil, resource.ErrResourceNotFound } return client, nil } type testActionClient struct { requests []*HandleDesktopTrayActionRequest } func (c *testActionClient) ExecCall(ctx context.Context, service, method string, in, out srpc.Message) error { req := in.(*HandleDesktopTrayActionRequest) c.requests = append(c.requests, req.CloneVT()) return nil } func (c *testActionClient) NewStream( ctx context.Context, service string, method string, firstMsg srpc.Message, ) (srpc.Stream, error) { return nil, resource.ErrResourceNotFound } type testActionHandler struct { requests []*HandleDesktopTrayActionRequest } func (h *testActionHandler) HandleDesktopTrayAction( ctx context.Context, req *HandleDesktopTrayActionRequest, ) (*HandleDesktopTrayActionResponse, error) { h.requests = append(h.requests, req.CloneVT()) return &HandleDesktopTrayActionResponse{}, nil } type testWatchStream struct { ctx context.Context updates chan *WatchDesktopTrayResponse } func newTestWatchStream(ctx context.Context) *testWatchStream { return &testWatchStream{ ctx: ctx, updates: make(chan *WatchDesktopTrayResponse, 8), } } func (s *testWatchStream) Context() context.Context { return s.ctx } func (s *testWatchStream) MsgSend(msg srpc.Message) error { return nil } func (s *testWatchStream) MsgRecv(msg srpc.Message) error { return io.EOF } func (s *testWatchStream) CloseSend() error { return nil } func (s *testWatchStream) Close() error { return nil } func (s *testWatchStream) Send(resp *WatchDesktopTrayResponse) error { s.updates <- resp.CloneVT() return nil } func (s *testWatchStream) SendAndClose(resp *WatchDesktopTrayResponse) error { if resp != nil { return s.Send(resp) } return nil } func TestDesktopTrayRegistryOrdersEntriesAndUpdatesState(t *testing.T) { // Build the request context and the tray under test. ctx := context.Background() client := newTestResourceClientContext(ctx) reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() // Register a status entry and an action entry. statusResp, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "status", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Path: []string{"Status"}, Group: "runtime", Order: 20, Label: "Runtime", Enabled: true, IconState: DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_NORMAL, }, }) if err != nil { t.Fatal(err) } actionResp, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "open", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Path: []string{"Actions"}, Group: "primary", Order: 10, Label: "Open Spacewave", Enabled: true, IconState: DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE, }, }) if err != nil { t.Fatal(err) } // Expect the action entry to sort before the status entry with an active icon. state := tray.snapshot() entries := state.GetEntries() if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } if entries[0].GetId() != "open" || entries[1].GetId() != "status" { t.Fatalf("unexpected order: %s, %s", entries[0].GetId(), entries[1].GetId()) } if state.GetIconState() != DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE { t.Fatalf("expected active icon state, got %s", state.GetIconState()) } // Toggle the action entry active and disabled through its resource. value, err := client.GetResourceValue(actionResp.GetResourceId()) if err != nil { t.Fatal(err) } entryResource := value.(*DesktopTrayEntryResource) if _, err := entryResource.SetDesktopTrayEntryActive(ctx, &SetDesktopTrayEntryActiveRequest{Active: true}); err != nil { t.Fatal(err) } if _, err := entryResource.SetDesktopTrayEntryEnabled(ctx, &SetDesktopTrayEntryEnabledRequest{Enabled: false}); err != nil { t.Fatal(err) } // Expect the snapshot to reflect the active and disabled flags. state = tray.snapshot() entries = state.GetEntries() if !entries[0].GetActive() { t.Fatal("expected active entry") } if entries[0].GetEnabled() { t.Fatal("expected disabled entry") } // Release the status entry and expect only the action entry left. if !client.ReleaseResource(statusResp.GetResourceId()) { t.Fatal("expected release to unregister entry") } state = tray.snapshot() entries = state.GetEntries() if len(entries) != 1 || entries[0].GetId() != "open" { t.Fatalf("unexpected entries after release: %#v", entries) } } func TestDesktopTrayRegistryDerivesStatusTextFromTitleEntry(t *testing.T) { // Build the request context and the tray under test. ctx := context.Background() client := newTestResourceClientContext(ctx) reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() // Register a title entry with an active icon state. _, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "title", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Spacewave: Syncing", IconState: DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE, }, }) if err != nil { t.Fatal(err) } // Expect the status text and icon state to derive from the title entry. state := tray.snapshot() if state.GetStatusText() != "Syncing" { t.Fatalf("status text = %q, want Syncing", state.GetStatusText()) } if state.GetIconState() != DesktopTrayIconState_DESKTOP_TRAY_ICON_STATE_ACTIVE { t.Fatalf("icon state = %v, want active", state.GetIconState()) } } func TestDesktopTrayRegistryWatchStreamsSnapshots(t *testing.T) { // Cancel the context when the test finishes. ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Start the watch stream in the background. client := newTestResourceClientContext(ctx) reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() strm := newTestWatchStream(ctx) errCh := make(chan error, 1) go func() { errCh <- tray.WatchDesktopTray(&WatchDesktopTrayRequest{}, strm) }() // Expect an empty initial snapshot. resp := <-strm.updates if len(resp.GetState().GetEntries()) != 0 { t.Fatalf("expected empty initial snapshot, got %d entries", len(resp.GetState().GetEntries())) } // Register a runtime entry after the watch started. _, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "runtime", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Runtime", Enabled: true, }, }) if err != nil { t.Fatal(err) } // Expect the new entry to appear in the streamed snapshot. resp = <-strm.updates entries := resp.GetState().GetEntries() if len(entries) != 1 || entries[0].GetId() != "runtime" { t.Fatalf("unexpected watch snapshot: %#v", entries) } // Cancel the context and expect the watch to stop with the error. cancel() if err := <-errCh; err != context.Canceled { t.Fatalf("expected canceled watch, got %v", err) } } func TestDesktopTrayRegistryInvokesAttachedActionHandlers(t *testing.T) { // Build the request context with an attached action client. ctx := context.Background() client := newTestResourceClientContext(ctx) actionClient := &testActionClient{} client.attached[7] = actionClient reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() // Register an action entry referencing the attached handler resource. _, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ AttachedActionResourceId: 7, Entry: &DesktopTrayEntry{ Id: "open", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Label: "Open", Enabled: true, Action: &DesktopTrayAction{ Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER, Route: "/spaces", }, }, }) if err != nil { t.Fatal(err) } // Invoke the entry and expect one request to reach the handler. _, err = tray.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{EntryId: "open"}) if err != nil { t.Fatal(err) } if len(actionClient.requests) != 1 { t.Fatalf("expected 1 action request, got %d", len(actionClient.requests)) } if actionClient.requests[0].GetEntryId() != "open" { t.Fatalf("unexpected entry id: %s", actionClient.requests[0].GetEntryId()) } if actionClient.requests[0].GetAction().GetRoute() != "/spaces" { t.Fatalf("unexpected route: %s", actionClient.requests[0].GetAction().GetRoute()) } } func TestDesktopTrayRegistryRejectsNonInvokableEntries(t *testing.T) { // Build the request context with an attached action client. ctx := context.Background() client := newTestResourceClientContext(ctx) actionClient := &testActionClient{} client.attached[7] = actionClient reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() // Register a disabled action entry and a status entry. _, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ AttachedActionResourceId: 7, Entry: &DesktopTrayEntry{ Id: "disabled", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Label: "Disabled", Enabled: false, Action: &DesktopTrayAction{ Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER, }, }, }) if err != nil { t.Fatal(err) } _, err = tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "status", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Status", }, }) if err != nil { t.Fatal(err) } // Expect both entries to reject invocation without touching the handler. if _, err := tray.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{EntryId: "disabled"}); err != ErrDesktopTrayEntryNotInvokable { t.Fatalf("expected disabled entry to be non-invokable, got %v", err) } if _, err := tray.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{EntryId: "status"}); err != ErrDesktopTrayEntryNotInvokable { t.Fatalf("expected status entry to be non-invokable, got %v", err) } if len(actionClient.requests) != 0 { t.Fatalf("expected no action requests, got %d", len(actionClient.requests)) } } func TestDesktopTrayRegistryRejectsDuplicateEntryIDs(t *testing.T) { // Build the request context and the tray under test. ctx := context.Background() client := newTestResourceClientContext(ctx) reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() // Register an entry and expect a duplicate registration to fail. first, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "same", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "First", }, }) if err != nil { t.Fatal(err) } _, err = tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "same", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Second", }, }) if err != ErrDesktopTrayEntryDuplicate { t.Fatalf("expected duplicate entry error, got %v", err) } // Rename a separate entry onto the held ID through its resource. value, err := client.GetResourceValue(first.GetResourceId()) if err != nil { t.Fatal(err) } entryResource := value.(*DesktopTrayEntryResource) _, err = tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "other", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Other", }, }) if err != nil { t.Fatal(err) } _, err = entryResource.SetDesktopTrayEntry(ctx, &SetDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "other", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Renamed", }, }) if err != ErrDesktopTrayEntryDuplicate { t.Fatalf("expected duplicate update error, got %v", err) } } func TestDesktopTrayRegistrySortsTiesByEntryIDThenResourceID(t *testing.T) { // Build the request context and the tray under test. ctx := context.Background() client := newTestResourceClientContext(ctx) reqCtx := resource_server.WithResourceClientContext(ctx, client) tray := NewDesktopTray() // Register three entries that tie on path, group, and order. for _, id := range []string{"zeta", "alpha", "middle"} { _, err := tray.RegisterDesktopTrayEntry(reqCtx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: id, Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Path: []string{"Actions"}, Group: "primary", Order: 10, Label: id, Enabled: true, }, }) if err != nil { t.Fatal(err) } } // Expect the snapshot to order the entries by ID. entries := tray.snapshot().GetEntries() got := []string{entries[0].GetId(), entries[1].GetId(), entries[2].GetId()} want := []string{"alpha", "middle", "zeta"} for i := range want { if got[i] != want[i] { t.Fatalf("expected order %v, got %v", want, got) } } } func TestReconcileDesktopTrayMirrorsEntriesToTargetResource(t *testing.T) { // Cancel the context when the test finishes. ctx, cancel := context.WithCancel(t.Context()) defer cancel() // Build the source and target tray resource clients. sourceClient, source, sourceRelease := newTestDesktopTrayResourceClient(t) defer sourceRelease() targetClient, target, targetRelease := newTestDesktopTrayResourceClient(t) defer targetRelease() // Start the reconciler in the background. errCh := make(chan error, 1) go func() { errCh <- ReconcileDesktopTray(ctx, source, target, targetClient) }() // Drain the initial empty target snapshot. targetStream, err := target.WatchDesktopTray(ctx, &WatchDesktopTrayRequest{}) if err != nil { t.Fatalf("watch target tray: %v", err) } if _, err := targetStream.Recv(); err != nil { t.Fatalf("recv target initial snapshot: %v", err) } // Register a status entry on the source tray. first, err := source.RegisterDesktopTrayEntry(ctx, &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "status", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Runtime - Running", Enabled: false, }, }) if err != nil { t.Fatalf("register source entry: %v", err) } // Expect the entry to mirror onto the target. state := recvTrayState(t, targetStream) if len(state.GetEntries()) != 1 { t.Fatalf("target entries = %d, want 1", len(state.GetEntries())) } if state.GetEntries()[0].GetLabel() != "Runtime - Running" { t.Fatalf("target label = %q, want mirrored source label", state.GetEntries()[0].GetLabel()) } // Update the source entry label through its resource. firstRef := sourceClient.CreateResourceReference(first.GetResourceId()) defer firstRef.Release() firstClient, err := firstRef.GetClient() if err != nil { t.Fatalf("source entry client: %v", err) } firstEntry := NewSRPCDesktopTrayEntryResourceServiceClient(firstClient) _, err = firstEntry.SetDesktopTrayEntry(ctx, &SetDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "status", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_STATUS, Label: "Runtime - Busy", Enabled: false, }, }) if err != nil { t.Fatalf("update source entry: %v", err) } // Expect the updated label to mirror onto the target. state = recvTrayState(t, targetStream) if len(state.GetEntries()) != 1 { t.Fatalf("target entries after update = %d, want 1", len(state.GetEntries())) } if state.GetEntries()[0].GetLabel() != "Runtime - Busy" { t.Fatalf("target label after update = %q, want updated source label", state.GetEntries()[0].GetLabel()) } // Release the source entry and expect the target to drop it. firstRef.Release() state = recvTrayState(t, targetStream) if len(state.GetEntries()) != 0 { t.Fatalf("target entries after source release = %d, want 0", len(state.GetEntries())) } // Cancel the context and expect the reconciler to stop. cancel() if err := <-errCh; err == nil { t.Fatalf("expected reconciler to stop on context cancel") } } func TestReconcileDesktopTrayForwardsAttachedActionHandlers(t *testing.T) { // Cancel the context when the test finishes. ctx, cancel := context.WithCancel(t.Context()) defer cancel() // Build the source and target tray resource clients. sourceClient, source, sourceRelease := newTestDesktopTrayResourceClient(t) defer sourceRelease() targetClient, target, targetRelease := newTestDesktopTrayResourceClient(t) defer targetRelease() // Attach a test action handler to the source client. handler := &testActionHandler{} handlerMux := srpc.NewMux() if err := SRPCRegisterDesktopTrayActionHandlerService(handlerMux, handler); err != nil { t.Fatalf("register action handler: %v", err) } attachedActionResourceID, err := sourceClient.AttachRawInvoker(ctx, "test-tray-action", handlerMux) if err != nil { t.Fatalf("attach source action handler: %v", err) } defer func() { _ = sourceClient.DetachResource(context.Background(), attachedActionResourceID) }() // Register an attached-handler action entry on the source tray. _, err = source.RegisterDesktopTrayEntry(ctx, &RegisterDesktopTrayEntryRequest{ AttachedActionResourceId: attachedActionResourceID, Entry: &DesktopTrayEntry{ Id: "copy-diagnostics", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Label: "Copy Diagnostics", Enabled: true, Action: &DesktopTrayAction{ Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER, Value: "diagnostics", }, }, }) if err != nil { t.Fatalf("register source entry: %v", err) } // Start the reconciler in the background. errCh := make(chan error, 1) go func() { errCh <- ReconcileDesktopTray(ctx, source, target, targetClient) }() // Watch the target tray and drain the initial snapshot. targetStream, err := target.WatchDesktopTray(ctx, &WatchDesktopTrayRequest{}) if err != nil { t.Fatalf("watch target tray: %v", err) } state := recvTrayState(t, targetStream) if len(state.GetEntries()) == 0 { state = recvTrayState(t, targetStream) } if len(state.GetEntries()) != 1 || state.GetEntries()[0].GetId() != "copy-diagnostics" { t.Fatalf("target entries = %#v", state.GetEntries()) } // Invoke the entry on the target and expect the source handler to run. _, err = target.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{ EntryId: "copy-diagnostics", }) if err != nil { t.Fatalf("invoke target attached entry: %v", err) } if len(handler.requests) != 1 { t.Fatalf("source handler requests = %d, want 1", len(handler.requests)) } if handler.requests[0].GetEntryId() != "copy-diagnostics" { t.Fatalf("source handler entry id = %q", handler.requests[0].GetEntryId()) } // Cancel the context and expect the reconciler to stop. cancel() if err := <-errCh; err == nil { t.Fatalf("expected reconciler to stop on context cancel") } } func TestReconcileDesktopTrayReplacesEntryWhenAttachedHandlerModeChanges(t *testing.T) { // Cancel the context when the test finishes. ctx, cancel := context.WithCancel(t.Context()) defer cancel() // Build the source and target tray resource clients. sourceClient, source, sourceRelease := newTestDesktopTrayResourceClient(t) defer sourceRelease() targetClient, target, targetRelease := newTestDesktopTrayResourceClient(t) defer targetRelease() // Attach a test action handler to the source client. handler := &testActionHandler{} handlerMux := srpc.NewMux() if err := SRPCRegisterDesktopTrayActionHandlerService(handlerMux, handler); err != nil { t.Fatalf("register action handler: %v", err) } attachedActionResourceID, err := sourceClient.AttachRawInvoker(ctx, "test-dynamic-tray-action", handlerMux) if err != nil { t.Fatalf("attach source action handler: %v", err) } defer func() { _ = sourceClient.DetachResource(context.Background(), attachedActionResourceID) }() // Register an attached-handler action entry on the source tray. first, err := source.RegisterDesktopTrayEntry(ctx, &RegisterDesktopTrayEntryRequest{ AttachedActionResourceId: attachedActionResourceID, Entry: &DesktopTrayEntry{ Id: "dynamic", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Label: "Run Dynamic Action", Enabled: true, Action: &DesktopTrayAction{ Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER, Value: "dynamic", }, }, }) if err != nil { t.Fatalf("register source entry: %v", err) } firstRef := sourceClient.CreateResourceReference(first.GetResourceId()) defer firstRef.Release() // Start the reconciler in the background. errCh := make(chan error, 1) go func() { errCh <- ReconcileDesktopTray(ctx, source, target, targetClient) }() // Watch the target tray with a timeout. watchCtx, watchCancel := context.WithTimeout(ctx, 5*time.Second) defer watchCancel() targetStream, err := target.WatchDesktopTray(watchCtx, &WatchDesktopTrayRequest{}) if err != nil { t.Fatalf("watch target tray: %v", err) } // Expect the attached-handler entry to mirror onto the target. state := recvTrayStateWithActionKind( t, targetStream, "dynamic", DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_ATTACHED_HANDLER, ) if len(state.GetEntries()) != 1 { t.Fatalf("target initial entry = %#v", state.GetEntries()) } // Invoke the entry on the target and expect the source handler to run. _, err = target.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{ EntryId: "dynamic", }) if err != nil { t.Fatalf("invoke initial target entry: %v", err) } if len(handler.requests) != 1 { t.Fatalf("initial source handler requests = %d, want 1", len(handler.requests)) } // Replace the entry action with an open-route action. firstClient, err := firstRef.GetClient() if err != nil { t.Fatalf("source entry client: %v", err) } firstEntry := NewSRPCDesktopTrayEntryResourceServiceClient(firstClient) _, err = firstEntry.SetDesktopTrayEntry(ctx, &SetDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "dynamic", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Label: "Open Spacewave", Enabled: true, Action: &DesktopTrayAction{ Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_OPEN_ROUTE, Route: "/u/1/", }, }, }) if err != nil { t.Fatalf("update source entry: %v", err) } // Expect the replaced entry to mirror onto the target. state = recvTrayStateWithActionKind( t, targetStream, "dynamic", DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_OPEN_ROUTE, ) if len(state.GetEntries()) != 1 { t.Fatalf("target updated entry = %#v", state.GetEntries()) } // Expect the replaced route entry to reject attached-handler invocation. _, err = target.InvokeDesktopTrayEntry(ctx, &InvokeDesktopTrayEntryRequest{ EntryId: "dynamic", }) if err == nil { t.Fatal("expected replaced route entry to be non-invokable through attached handler path") } if len(handler.requests) != 1 { t.Fatalf("source handler requests after replace = %d, want 1", len(handler.requests)) } // Cancel the context and expect the reconciler to stop. cancel() if err := <-errCh; err == nil { t.Fatalf("expected reconciler to stop on context cancel") } } func TestReconcileDesktopTrayMirrorsExistingEntriesAcrossTargetReconnect(t *testing.T) { // Register an open-route entry on the source tray. _, source, sourceRelease := newTestDesktopTrayResourceClient(t) defer sourceRelease() // Register the "space" route entry on the source tray resource. _, err := source.RegisterDesktopTrayEntry(t.Context(), &RegisterDesktopTrayEntryRequest{ Entry: &DesktopTrayEntry{ Id: "space", Kind: DesktopTrayEntryKind_DESKTOP_TRAY_ENTRY_KIND_ACTION, Label: "My Drive", Enabled: true, Action: &DesktopTrayAction{ Kind: DesktopTrayActionKind_DESKTOP_TRAY_ACTION_KIND_OPEN_ROUTE, Route: "/u/1/so/space", }, }, }) if err != nil { t.Fatalf("register source entry: %v", err) } // Watch the first target and confirm it starts empty. firstTargetClient, firstTarget, firstTargetRelease := newTestDesktopTrayResourceClient(t) defer firstTargetRelease() firstStream, err := firstTarget.WatchDesktopTray(t.Context(), &WatchDesktopTrayRequest{}) if err != nil { t.Fatalf("watch first target tray: %v", err) } if state := recvTrayState(t, firstStream); len(state.GetEntries()) != 0 { t.Fatalf("first target initial entries = %d, want 0", len(state.GetEntries())) } // Run the first reconciler and expect the entry to mirror. firstCtx, firstCancel := context.WithCancel(t.Context()) firstErrCh := make(chan error, 1) go func() { firstErrCh <- ReconcileDesktopTray(firstCtx, source, firstTarget, firstTargetClient) }() // Receive the mirrored tray state from the first target stream. state := recvTrayState(t, firstStream) if len(state.GetEntries()) != 1 || state.GetEntries()[0].GetLabel() != "My Drive" { t.Fatalf("first target mirrored entries = %#v", state.GetEntries()) } // Stop the first reconciler and expect the target tray to clear. firstCancel() if err := <-firstErrCh; err == nil { t.Fatalf("expected first reconciler to stop on context cancel") } state = recvTrayState(t, firstStream) if len(state.GetEntries()) != 0 { t.Fatalf("first target entries after reconciler stop = %d, want 0", len(state.GetEntries())) } // Watch the second target and confirm it starts empty. secondTargetClient, secondTarget, secondTargetRelease := newTestDesktopTrayResourceClient(t) defer secondTargetRelease() secondStream, err := secondTarget.WatchDesktopTray(t.Context(), &WatchDesktopTrayRequest{}) if err != nil { t.Fatalf("watch second target tray: %v", err) } if state := recvTrayState(t, secondStream); len(state.GetEntries()) != 0 { t.Fatalf("second target initial entries = %d, want 0", len(state.GetEntries())) } // Run the second reconciler and expect the entry to mirror again. secondCtx, secondCancel := context.WithCancel(t.Context()) secondErrCh := make(chan error, 1) go func() { secondErrCh <- ReconcileDesktopTray(secondCtx, source, secondTarget, secondTargetClient) }() // Receive the mirrored tray state from the second target stream. state = recvTrayState(t, secondStream) if len(state.GetEntries()) != 1 || state.GetEntries()[0].GetLabel() != "My Drive" { t.Fatalf("second target mirrored entries = %#v", state.GetEntries()) } // Stop the second reconciler. secondCancel() if err := <-secondErrCh; err == nil { t.Fatalf("expected second reconciler to stop on context cancel") } } func newTestDesktopTrayResourceClient( t *testing.T, ) (*resource_client.Client, SRPCDesktopTrayResourceServiceClient, func()) { // Mark the helper so test failures attribute to the caller. t.Helper() // Serve the tray resource over an in-memory SRPC server. tray := NewDesktopTray() server := resource_server.NewResourceServer(tray.GetMux()) serverMux := srpc.NewMux() if err := server.Register(serverMux); err != nil { t.Fatalf("register resource server: %v", err) } // Connect a resource client to the server. resourceService := resource.NewSRPCResourceServiceClient( srpc.NewClient(srpc.NewServerPipe(srpc.NewServer(serverMux))), ) client, err := resource_client.NewClient(t.Context(), resourceService) if err != nil { t.Fatalf("new resource client: %v", err) } // Access the root resource and return the tray service client. rootRef := client.AccessRootResource() rootClient, err := rootRef.GetClient() if err != nil { rootRef.Release() client.Release() t.Fatalf("root resource client: %v", err) } service := NewSRPCDesktopTrayResourceServiceClient(rootClient) return client, service, func() { rootRef.Release() client.Release() } } func recvTrayState( t *testing.T, strm SRPCDesktopTrayResourceService_WatchDesktopTrayClient, ) *DesktopTrayState { t.Helper() resp, err := strm.Recv() if err != nil { t.Fatalf("recv tray state: %v", err) } return resp.GetState() } func recvTrayStateWithActionKind( t *testing.T, strm SRPCDesktopTrayResourceService_WatchDesktopTrayClient, entryID string, kind DesktopTrayActionKind, ) *DesktopTrayState { t.Helper() for range 16 { state := recvTrayState(t, strm) for _, entry := range state.GetEntries() { if entry.GetId() == entryID && entry.GetAction().GetKind() == kind { return state } } } t.Fatalf("did not receive entry %q with action kind %v", entryID, kind) return nil } // _ is a type assertion var ( _ resource_server.ResourceClientContext = (*testResourceClientContext)(nil) _ SRPCDesktopTrayResourceService_WatchDesktopTrayStream = (*testWatchStream)(nil) _ srpc.Client = (*testActionClient)(nil) )