refactor(daemon): remove daemon-owned picker control
- commit
- c56f581a941b048fe1bd427999e3f7bb763b7495
- author
- brice · · 2026-09-22 19:23 UTC
- parent
- 68ce008
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +41 -1412
M internal/usecase/daemon/attachment_effect_gate_test.go +7 -32
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 16 | 16 | "github.com/bnema/vev/internal/protocol/wire" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/keys" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | |
| @@ -782,39 +783,11 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 782 | 783 | require.Equal(t, 1, testAttachmentTabIndex(target)) | |
| 783 | 784 | } | |
| 784 | 785 | ||
| 785 | - | // openPickerForSession opens a navigation interaction on the attachment and | |
| 786 | - | // returns a kill selection for the named session. The open runs on a | |
| 787 | - | // short-lived effect: the interaction namespace lives on the attachment, not | |
| 788 | - | // on the effect that carried its offer. | |
| 789 | - | func openPickerForSession(t *testing.T, d *Daemon, sess *session, ac *attachedClient, killSession domain.SessionID) protocol.PickerSelection { | |
| 790 | - | t.Helper() | |
| 791 | - | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 792 | - | require.True(t, admitted) | |
| 793 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 794 | - | effect.End() | |
| 795 | - | ac.overlays.pickerMu.Lock() | |
| 796 | - | interaction := ac.overlays.pickerInteraction | |
| 797 | - | revision := ac.overlays.pickerRevisions[servingPickerSourceID] | |
| 798 | - | key := "" | |
| 799 | - | for candidate, target := range ac.overlays.pickerKeys { | |
| 800 | - | if target.Session == killSession { | |
| 801 | - | key = candidate | |
| 802 | - | break | |
| 803 | - | } | |
| 804 | - | } | |
| 805 | - | ac.overlays.pickerMu.Unlock() | |
| 806 | - | require.NotEmpty(t, key, "no picker key resolves to session %s", killSession) | |
| 807 | - | return protocol.PickerSelection{ | |
| 808 | - | InteractionID: interaction, SourceID: servingPickerSourceID, | |
| 809 | - | SourceRevision: revision, Key: key, Action: protocol.PickerActionKill, | |
| 810 | - | } | |
| 811 | - | } | |
| 812 | - | ||
| 813 | 786 | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | |
| 814 | 787 | p, releasePTY := newBlockingPTY(t) | |
| 815 | 788 | defer releasePTY() | |
| 816 | 789 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 817 | - | selection := openPickerForSession(t, d, sess, old, sess.id) | |
| 790 | + | target := picker.Target{Session: sess.id} | |
| 818 | 791 | ||
| 819 | 792 | oldTransport := old.transport() | |
| 820 | 793 | rc := d.attachCoordinator(sess, nil, old, true) | |
| @@ -837,7 +810,8 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 837 | 810 | } | |
| 838 | 811 | actionDone := make(chan struct{}) | |
| 839 | 812 | go func() { | |
| 840 | - | d.resolvePickerSelection(actionEffect, selection) | |
| 813 | + | actionEffect.bindActionEnd(d, "picker-delete") | |
| 814 | + | _ = d.killPickerTargetForAttachment(target, actionEffect) | |
| 841 | 815 | close(actionDone) | |
| 842 | 816 | }() | |
| 843 | 817 | <-admissionEnded | |
| @@ -862,7 +836,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||
| 862 | 836 | p, release := newBlockingPTY(t) | |
| 863 | 837 | defer release() | |
| 864 | 838 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 865 | - | selection := openPickerForSession(t, d, sess, ac, sess.id) | |
| 839 | + | target := picker.Target{Session: sess.id} | |
| 866 | 840 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 867 | 841 | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 868 | 842 | token.lease = rc.attachmentLease(ac) | |
| @@ -870,7 +844,8 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||
| 870 | 844 | actionEffect, admitted := ac.beginAttachmentEffect(token) | |
| 871 | 845 | require.True(t, admitted) | |
| 872 | 846 | ||
| 873 | - | d.resolvePickerSelection(actionEffect, selection) | |
| 847 | + | actionEffect.bindActionEnd(d, "picker-delete") | |
| 848 | + | _ = d.killPickerTargetForAttachment(target, actionEffect) | |
| 874 | 849 | ||
| 875 | 850 | d.mu.Lock() | |
| 876 | 851 | _, registered := d.sessions[sess.id] | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/protocol" | 15 | "github.com/bnema/vev/internal/protocol" | ||
| 16 | "github.com/bnema/vev/internal/protocol/wire" | 16 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 17 | "github.com/bnema/vev/internal/usecase/keys" | 17 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 18 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | 21 | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | ||
| @@ -782,39 +783,11 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||||
| 782 | require.Equal(t, 1, testAttachmentTabIndex(target)) | 783 | require.Equal(t, 1, testAttachmentTabIndex(target)) | ||
| 783 | } | 784 | } | ||
| 784 | 785 | ||||
| 785 | - | // openPickerForSession opens a navigation interaction on the attachment and | |||
| 786 | - | // returns a kill selection for the named session. The open runs on a | |||
| 787 | - | // short-lived effect: the interaction namespace lives on the attachment, not | |||
| 788 | - | // on the effect that carried its offer. | |||
| 789 | - | func openPickerForSession(t *testing.T, d *Daemon, sess *session, ac *attachedClient, killSession domain.SessionID) protocol.PickerSelection { | |||
| 790 | - | t.Helper() | |||
| 791 | - | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |||
| 792 | - | require.True(t, admitted) | |||
| 793 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |||
| 794 | - | effect.End() | |||
| 795 | - | ac.overlays.pickerMu.Lock() | |||
| 796 | - | interaction := ac.overlays.pickerInteraction | |||
| 797 | - | revision := ac.overlays.pickerRevisions[servingPickerSourceID] | |||
| 798 | - | key := "" | |||
| 799 | - | for candidate, target := range ac.overlays.pickerKeys { | |||
| 800 | - | if target.Session == killSession { | |||
| 801 | - | key = candidate | |||
| 802 | - | break | |||
| 803 | - | } | |||
| 804 | - | } | |||
| 805 | - | ac.overlays.pickerMu.Unlock() | |||
| 806 | - | require.NotEmpty(t, key, "no picker key resolves to session %s", killSession) | |||
| 807 | - | return protocol.PickerSelection{ | |||
| 808 | - | InteractionID: interaction, SourceID: servingPickerSourceID, | |||
| 809 | - | SourceRevision: revision, Key: key, Action: protocol.PickerActionKill, | |||
| 810 | - | } | |||
| 811 | - | } | |||
| 812 | - | ||||
| 813 | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | 786 | func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) { | ||
| 814 | p, releasePTY := newBlockingPTY(t) | 787 | p, releasePTY := newBlockingPTY(t) | ||
| 815 | defer releasePTY() | 788 | defer releasePTY() | ||
| 816 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | 789 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | ||
| 817 | - | selection := openPickerForSession(t, d, sess, old, sess.id) | 790 | + | target := picker.Target{Session: sess.id} |
| 818 | 791 | ||||
| 819 | oldTransport := old.transport() | 792 | oldTransport := old.transport() | ||
| 820 | rc := d.attachCoordinator(sess, nil, old, true) | 793 | rc := d.attachCoordinator(sess, nil, old, true) | ||
| @@ -837,7 +810,8 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||||
| 837 | } | 810 | } | ||
| 838 | actionDone := make(chan struct{}) | 811 | actionDone := make(chan struct{}) | ||
| 839 | go func() { | 812 | go func() { | ||
| 840 | - | d.resolvePickerSelection(actionEffect, selection) | 813 | + | actionEffect.bindActionEnd(d, "picker-delete") |
| 814 | + | _ = d.killPickerTargetForAttachment(target, actionEffect) | |||
| 841 | close(actionDone) | 815 | close(actionDone) | ||
| 842 | }() | 816 | }() | ||
| 843 | <-admissionEnded | 817 | <-admissionEnded | ||
| @@ -862,7 +836,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||||
| 862 | p, release := newBlockingPTY(t) | 836 | p, release := newBlockingPTY(t) | ||
| 863 | defer release() | 837 | defer release() | ||
| 864 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 838 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 865 | - | selection := openPickerForSession(t, d, sess, ac, sess.id) | 839 | + | target := picker.Target{Session: sess.id} |
| 866 | rc := d.attachCoordinator(sess, nil, ac, true) | 840 | rc := d.attachCoordinator(sess, nil, ac, true) | ||
| 867 | token := sess.captureAttachmentCapability(ac, ac.transport()) | 841 | token := sess.captureAttachmentCapability(ac, ac.transport()) | ||
| 868 | token.lease = rc.attachmentLease(ac) | 842 | token.lease = rc.attachmentLease(ac) | ||
| @@ -870,7 +844,8 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||||
| 870 | actionEffect, admitted := ac.beginAttachmentEffect(token) | 844 | actionEffect, admitted := ac.beginAttachmentEffect(token) | ||
| 871 | require.True(t, admitted) | 845 | require.True(t, admitted) | ||
| 872 | 846 | ||||
| 873 | - | d.resolvePickerSelection(actionEffect, selection) | 847 | + | actionEffect.bindActionEnd(d, "picker-delete") |
| 848 | + | _ = d.killPickerTargetForAttachment(target, actionEffect) | |||
| 874 | 849 | ||||
| 875 | d.mu.Lock() | 850 | d.mu.Lock() | ||
| 876 | _, registered := d.sessions[sess.id] | 851 | _, registered := d.sessions[sess.id] | ||
M internal/usecase/daemon/client_frame_routing.go +0 -7
| @@ -235,13 +235,6 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||
| 235 | 235 | d.refreshPalette(effect.ac) | |
| 236 | 236 | d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication") | |
| 237 | 237 | } | |
| 238 | - | case protocol.PickerBegin: | |
| 239 | - | if effect.ac == nil || effect.ac.overlays == nil { | |
| 240 | - | break | |
| 241 | - | } | |
| 242 | - | if err := d.openPickerForAttachment(effect.ac, effect, message.Intent, moveSourceLocator{}, message.RequestID); err != nil { | |
| 243 | - | d.reportAttachmentError(effect.sess, err) | |
| 244 | - | } | |
| 245 | 238 | case protocol.PickerClose: | |
| 246 | 239 | if effect.ac == nil || effect.ac.overlays == nil { | |
| 247 | 240 | break | |
| @@ -235,13 +235,6 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi | |||||
| 235 | d.refreshPalette(effect.ac) | 235 | d.refreshPalette(effect.ac) | ||
| 236 | d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication") | 236 | d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication") | ||
| 237 | } | 237 | } | ||
| 238 | - | case protocol.PickerBegin: | |||
| 239 | - | if effect.ac == nil || effect.ac.overlays == nil { | |||
| 240 | - | break | |||
| 241 | - | } | |||
| 242 | - | if err := d.openPickerForAttachment(effect.ac, effect, message.Intent, moveSourceLocator{}, message.RequestID); err != nil { | |||
| 243 | - | d.reportAttachmentError(effect.sess, err) | |||
| 244 | - | } | |||
| 245 | case protocol.PickerClose: | 238 | case protocol.PickerClose: | ||
| 246 | if effect.ac == nil || effect.ac.overlays == nil { | 239 | if effect.ac == nil || effect.ac.overlays == nil { | ||
| 247 | break | 240 | break | ||
M internal/usecase/daemon/client_transport_errors.go +10 -2
| @@ -49,6 +49,10 @@ func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool { | |
| 52 | + | return d.clientGoneForAttachmentReason(effect, explicit, protocol.ReasonDetach) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (d *Daemon) clientGoneForAttachmentReason(effect *attachmentEffect, explicit bool, reason uint8) bool { | |
| 52 | 56 | if effect == nil { | |
| 53 | 57 | return false | |
| 54 | 58 | } | |
| @@ -67,11 +71,15 @@ func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool | |||
| 67 | 71 | d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken) | |
| 68 | 72 | return false | |
| 69 | 73 | } | |
| 70 | - | d.finishClientGone(sess, capability.ac, capability.transport.transport, explicit, true) | |
| 74 | + | d.finishClientGoneReason(sess, capability.ac, capability.transport.transport, explicit, true, reason) | |
| 71 | 75 | return true | |
| 72 | 76 | } | |
| 73 | 77 | ||
| 74 | 78 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool) { | |
| 79 | + | d.finishClientGoneReason(sess, ac, failed, explicit, notice, protocol.ReasonDetach) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (d *Daemon) finishClientGoneReason(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool, reason uint8) { | |
| 75 | 83 | if sess == nil || ac == nil { | |
| 76 | 84 | return | |
| 77 | 85 | } | |
| @@ -116,7 +124,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||
| 116 | 124 | // (the client is actively awaiting it), but deadline-bounded so a | |
| 117 | 125 | // wedged client cannot pin this conn handler and hang Serve's | |
| 118 | 126 | // connWg.Wait. | |
| 119 | - | d.boundedSend(ac, serverDetached(protocol.ReasonDetach)) | |
| 127 | + | d.boundedSend(ac, serverDetached(reason)) | |
| 120 | 128 | } | |
| 121 | 129 | _ = ac.closeCapturedTransport(oldTr) | |
| 122 | 130 | d.log.Info("client detached", "session", name, "explicit", explicit) | |
| @@ -49,6 +49,10 @@ func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool { | 51 | func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool { | ||
| 52 | + | return d.clientGoneForAttachmentReason(effect, explicit, protocol.ReasonDetach) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (d *Daemon) clientGoneForAttachmentReason(effect *attachmentEffect, explicit bool, reason uint8) bool { | |||
| 52 | if effect == nil { | 56 | if effect == nil { | ||
| 53 | return false | 57 | return false | ||
| 54 | } | 58 | } | ||
| @@ -67,11 +71,15 @@ func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool | |||||
| 67 | d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken) | 71 | d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken) | ||
| 68 | return false | 72 | return false | ||
| 69 | } | 73 | } | ||
| 70 | - | d.finishClientGone(sess, capability.ac, capability.transport.transport, explicit, true) | 74 | + | d.finishClientGoneReason(sess, capability.ac, capability.transport.transport, explicit, true, reason) |
| 71 | return true | 75 | return true | ||
| 72 | } | 76 | } | ||
| 73 | 77 | ||||
| 74 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool) { | 78 | func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool) { | ||
| 79 | + | d.finishClientGoneReason(sess, ac, failed, explicit, notice, protocol.ReasonDetach) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (d *Daemon) finishClientGoneReason(sess *session, ac *attachedClient, failed ports.ServerConnection, explicit, notice bool, reason uint8) { | |||
| 75 | if sess == nil || ac == nil { | 83 | if sess == nil || ac == nil { | ||
| 76 | return | 84 | return | ||
| 77 | } | 85 | } | ||
| @@ -116,7 +124,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||||
| 116 | // (the client is actively awaiting it), but deadline-bounded so a | 124 | // (the client is actively awaiting it), but deadline-bounded so a | ||
| 117 | // wedged client cannot pin this conn handler and hang Serve's | 125 | // wedged client cannot pin this conn handler and hang Serve's | ||
| 118 | // connWg.Wait. | 126 | // connWg.Wait. | ||
| 119 | - | d.boundedSend(ac, serverDetached(protocol.ReasonDetach)) | 127 | + | d.boundedSend(ac, serverDetached(reason)) |
| 120 | } | 128 | } | ||
| 121 | _ = ac.closeCapturedTransport(oldTr) | 129 | _ = ac.closeCapturedTransport(oldTr) | ||
| 122 | d.log.Info("client detached", "session", name, "explicit", explicit) | 130 | d.log.Info("client detached", "session", name, "explicit", explicit) | ||
M internal/usecase/daemon/daemon.go +0 -4
| @@ -1222,10 +1222,6 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) { | |||
| 1222 | 1222 | stopTransport() | |
| 1223 | 1223 | finishHandshake() | |
| 1224 | 1224 | d.handleNavigationInventory(tr, message) | |
| 1225 | - | case protocol.PickerControlRequest: | |
| 1226 | - | stopTransport() | |
| 1227 | - | finishHandshake() | |
| 1228 | - | d.handlePickerControl(tr, message) | |
| 1229 | 1225 | case protocol.Kill: | |
| 1230 | 1226 | stopTransport() | |
| 1231 | 1227 | finishHandshake() | |
| @@ -1222,10 +1222,6 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) { | |||||
| 1222 | stopTransport() | 1222 | stopTransport() | ||
| 1223 | finishHandshake() | 1223 | finishHandshake() | ||
| 1224 | d.handleNavigationInventory(tr, message) | 1224 | d.handleNavigationInventory(tr, message) | ||
| 1225 | - | case protocol.PickerControlRequest: | |||
| 1226 | - | stopTransport() | |||
| 1227 | - | finishHandshake() | |||
| 1228 | - | d.handlePickerControl(tr, message) | |||
| 1229 | case protocol.Kill: | 1225 | case protocol.Kill: | ||
| 1230 | stopTransport() | 1226 | stopTransport() | ||
| 1231 | finishHandshake() | 1227 | finishHandshake() | ||
M internal/usecase/daemon/move_picker_test.go +3 -1
| @@ -518,8 +518,10 @@ func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *test | |||
| 518 | 518 | // concurrent reconnect or transition that won the gate after publication. It | |
| 519 | 519 | // republishes the current capability directly so it also works while a render | |
| 520 | 520 | // effect may still be in flight. | |
| 521 | + | type replacementServerConnection struct{ *mockServerConnection } | |
| 522 | + | ||
| 521 | 523 | func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) { | |
| 522 | - | wrapper := &pickerSnapshotFailTransport{mockServerConnection: base} | |
| 524 | + | wrapper := &replacementServerConnection{mockServerConnection: base} | |
| 523 | 525 | published.ac.replaceTransport(wrapper) | |
| 524 | 526 | current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper) | |
| 525 | 527 | gate := &published.ac.lifecycle | |
| @@ -518,8 +518,10 @@ func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *test | |||||
| 518 | // concurrent reconnect or transition that won the gate after publication. It | 518 | // concurrent reconnect or transition that won the gate after publication. It | ||
| 519 | // republishes the current capability directly so it also works while a render | 519 | // republishes the current capability directly so it also works while a render | ||
| 520 | // effect may still be in flight. | 520 | // effect may still be in flight. | ||
| 521 | + | type replacementServerConnection struct{ *mockServerConnection } | |||
| 522 | + | ||||
| 521 | func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) { | 523 | func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) { | ||
| 522 | - | wrapper := &pickerSnapshotFailTransport{mockServerConnection: base} | 524 | + | wrapper := &replacementServerConnection{mockServerConnection: base} |
| 523 | published.ac.replaceTransport(wrapper) | 525 | published.ac.replaceTransport(wrapper) | ||
| 524 | current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper) | 526 | current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper) | ||
| 525 | gate := &published.ac.lifecycle | 527 | gate := &published.ac.lifecycle | ||
M internal/usecase/daemon/navigation_attachment_handoff_test.go +0 -7
| @@ -20,13 +20,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 20 | 20 | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error | |
| 21 | 21 | check func(*testing.T, *Daemon, *session, *session) | |
| 22 | 22 | }{ | |
| 23 | - | { | |
| 24 | - | name: "picker selection", releasedAction: "detach", | |
| 25 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error { | |
| 26 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 27 | - | return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |
| 28 | - | }, | |
| 29 | - | }, | |
| 30 | 23 | { | |
| 31 | 24 | name: "palette session", releasedAction: "detach", | |
| 32 | 25 | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error { | |
| @@ -20,13 +20,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||||
| 20 | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error | 20 | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error | ||
| 21 | check func(*testing.T, *Daemon, *session, *session) | 21 | check func(*testing.T, *Daemon, *session, *session) | ||
| 22 | }{ | 22 | }{ | ||
| 23 | - | { | |||
| 24 | - | name: "picker selection", releasedAction: "detach", | |||
| 25 | - | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error { | |||
| 26 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |||
| 27 | - | return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select") | |||
| 28 | - | }, | |||
| 29 | - | }, | |||
| 30 | { | 23 | { | ||
| 31 | name: "palette session", releasedAction: "detach", | 24 | name: "palette session", releasedAction: "detach", | ||
| 32 | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error { | 25 | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error { | ||
M internal/usecase/daemon/overlay_runtime.go +1 -1
| @@ -33,7 +33,7 @@ type overlayRuntime struct { | |||
| 33 | 33 | // interactions reject); revisions are the per-source published versions | |
| 34 | 34 | // (older or duplicate revisions discard); keys maps opaque row keys of the | |
| 35 | 35 | // serving source to resolved targets; intent and moverSource are the | |
| 36 | - | // daemon-side facts captured at open; requestID echoes a client PickerBegin. | |
| 36 | + | // daemon-side facts captured at open; requestID echoes a client client picker request. | |
| 37 | 37 | pickerOpen bool | |
| 38 | 38 | pickerInteraction uint64 | |
| 39 | 39 | pickerRevisions map[string]uint64 | |
| @@ -33,7 +33,7 @@ type overlayRuntime struct { | |||||
| 33 | // interactions reject); revisions are the per-source published versions | 33 | // interactions reject); revisions are the per-source published versions | ||
| 34 | // (older or duplicate revisions discard); keys maps opaque row keys of the | 34 | // (older or duplicate revisions discard); keys maps opaque row keys of the | ||
| 35 | // serving source to resolved targets; intent and moverSource are the | 35 | // serving source to resolved targets; intent and moverSource are the | ||
| 36 | - | // daemon-side facts captured at open; requestID echoes a client PickerBegin. | 36 | + | // daemon-side facts captured at open; requestID echoes a client client picker request. |
| 37 | pickerOpen bool | 37 | pickerOpen bool | ||
| 38 | pickerInteraction uint64 | 38 | pickerInteraction uint64 | ||
| 39 | pickerRevisions map[string]uint64 | 39 | pickerRevisions map[string]uint64 | ||
M internal/usecase/daemon/palette.go +7 -31
| @@ -737,12 +737,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 737 | 737 | if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | |
| 738 | 738 | return | |
| 739 | 739 | } | |
| 740 | - | // A client-owned picker may acquire presentation as soon as its offer's | |
| 741 | - | // output barrier is displayed. Commit the palette-free base first so that | |
| 742 | - | // barrier can never name a frame which still contains the command palette. | |
| 743 | - | if cmd.Slug == "session-picker" { | |
| 744 | - | d.invalidateRenderNow(sess, ac, true, "palette.go:session-picker-base") | |
| 745 | - | } | |
| 746 | 740 | sess.dispatchMu.Lock() | |
| 747 | 741 | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) | |
| 748 | 742 | sess.dispatchMu.Unlock() | |
| @@ -1199,24 +1193,9 @@ func (e paletteExec) RenameTabTo(name string) error { | |||
| 1199 | 1193 | } | |
| 1200 | 1194 | ||
| 1201 | 1195 | func (e paletteExec) OpenSessionPicker() error { | |
| 1202 | - | if e.ac == nil || e.ac.overlays == nil { | |
| 1196 | + | if e.ac == nil { | |
| 1203 | 1197 | return errAttachmentTransition | |
| 1204 | 1198 | } | |
| 1205 | - | // The palette already closed on execute (see handlePaletteInput): | |
| 1206 | - | // only invalidate when the close actually changed overlay state, then | |
| 1207 | - | // open the picker interaction on a freshly admitted effect so the | |
| 1208 | - | // opener's fence retires on the authoritative repaint below. | |
| 1209 | - | overlays := e.ac.overlays | |
| 1210 | - | overlays.paletteMu.Lock() | |
| 1211 | - | generation := overlays.paletteGeneration | |
| 1212 | - | query := "" | |
| 1213 | - | if overlays.palette != nil { | |
| 1214 | - | query = overlays.palette.Query() | |
| 1215 | - | } | |
| 1216 | - | overlays.paletteMu.Unlock() | |
| 1217 | - | if e.d.closeExecutedPalette(e.ac, e.effect, generation, query) { | |
| 1218 | - | e.d.invalidateRender(e.sess, e.ac, true, "palette.go:session-picker") | |
| 1219 | - | } | |
| 1220 | 1199 | effect := e.effect | |
| 1221 | 1200 | if effect == nil { | |
| 1222 | 1201 | current := e.ac.transportSnapshot() | |
| @@ -1226,16 +1205,13 @@ func (e paletteExec) OpenSessionPicker() error { | |||
| 1226 | 1205 | } | |
| 1227 | 1206 | defer fresh.End() | |
| 1228 | 1207 | effect = fresh | |
| 1229 | - | } else { | |
| 1230 | - | fresh, admitted := e.ac.beginAttachmentEffect(effect.capability()) | |
| 1231 | - | if !admitted { | |
| 1232 | - | return errAttachmentTransition | |
| 1233 | - | } | |
| 1234 | - | fresh.uiActionID = effect.uiActionID | |
| 1235 | - | defer fresh.End() | |
| 1236 | - | effect = fresh | |
| 1237 | 1208 | } | |
| 1238 | - | return e.d.openPickerForAttachment(e.ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0) | |
| 1209 | + | // End the attachment; the client supervisor, not an interaction on this | |
| 1210 | + | // connection, owns the next picker presentation and its input. | |
| 1211 | + | if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) { | |
| 1212 | + | return errAttachmentTransition | |
| 1213 | + | } | |
| 1214 | + | return nil | |
| 1239 | 1215 | } | |
| 1240 | 1216 | ||
| 1241 | 1217 | func (e paletteExec) OpenNotifications() error { | |
| @@ -737,12 +737,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 737 | if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | 737 | if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) { | ||
| 738 | return | 738 | return | ||
| 739 | } | 739 | } | ||
| 740 | - | // A client-owned picker may acquire presentation as soon as its offer's | |||
| 741 | - | // output barrier is displayed. Commit the palette-free base first so that | |||
| 742 | - | // barrier can never name a frame which still contains the command palette. | |||
| 743 | - | if cmd.Slug == "session-picker" { | |||
| 744 | - | d.invalidateRenderNow(sess, ac, true, "palette.go:session-picker-base") | |||
| 745 | - | } | |||
| 746 | sess.dispatchMu.Lock() | 740 | sess.dispatchMu.Lock() | ||
| 747 | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) | 741 | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) | ||
| 748 | sess.dispatchMu.Unlock() | 742 | sess.dispatchMu.Unlock() | ||
| @@ -1199,24 +1193,9 @@ func (e paletteExec) RenameTabTo(name string) error { | |||||
| 1199 | } | 1193 | } | ||
| 1200 | 1194 | ||||
| 1201 | func (e paletteExec) OpenSessionPicker() error { | 1195 | func (e paletteExec) OpenSessionPicker() error { | ||
| 1202 | - | if e.ac == nil || e.ac.overlays == nil { | 1196 | + | if e.ac == nil { |
| 1203 | return errAttachmentTransition | 1197 | return errAttachmentTransition | ||
| 1204 | } | 1198 | } | ||
| 1205 | - | // The palette already closed on execute (see handlePaletteInput): | |||
| 1206 | - | // only invalidate when the close actually changed overlay state, then | |||
| 1207 | - | // open the picker interaction on a freshly admitted effect so the | |||
| 1208 | - | // opener's fence retires on the authoritative repaint below. | |||
| 1209 | - | overlays := e.ac.overlays | |||
| 1210 | - | overlays.paletteMu.Lock() | |||
| 1211 | - | generation := overlays.paletteGeneration | |||
| 1212 | - | query := "" | |||
| 1213 | - | if overlays.palette != nil { | |||
| 1214 | - | query = overlays.palette.Query() | |||
| 1215 | - | } | |||
| 1216 | - | overlays.paletteMu.Unlock() | |||
| 1217 | - | if e.d.closeExecutedPalette(e.ac, e.effect, generation, query) { | |||
| 1218 | - | e.d.invalidateRender(e.sess, e.ac, true, "palette.go:session-picker") | |||
| 1219 | - | } | |||
| 1220 | effect := e.effect | 1199 | effect := e.effect | ||
| 1221 | if effect == nil { | 1200 | if effect == nil { | ||
| 1222 | current := e.ac.transportSnapshot() | 1201 | current := e.ac.transportSnapshot() | ||
| @@ -1226,16 +1205,13 @@ func (e paletteExec) OpenSessionPicker() error { | |||||
| 1226 | } | 1205 | } | ||
| 1227 | defer fresh.End() | 1206 | defer fresh.End() | ||
| 1228 | effect = fresh | 1207 | effect = fresh | ||
| 1229 | - | } else { | |||
| 1230 | - | fresh, admitted := e.ac.beginAttachmentEffect(effect.capability()) | |||
| 1231 | - | if !admitted { | |||
| 1232 | - | return errAttachmentTransition | |||
| 1233 | - | } | |||
| 1234 | - | fresh.uiActionID = effect.uiActionID | |||
| 1235 | - | defer fresh.End() | |||
| 1236 | - | effect = fresh | |||
| 1237 | } | 1208 | } | ||
| 1238 | - | return e.d.openPickerForAttachment(e.ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0) | 1209 | + | // End the attachment; the client supervisor, not an interaction on this |
| 1210 | + | // connection, owns the next picker presentation and its input. | |||
| 1211 | + | if !e.d.clientGoneForAttachmentReason(effect, true, protocol.ReasonDetachToPicker) { | |||
| 1212 | + | return errAttachmentTransition | |||
| 1213 | + | } | |||
| 1214 | + | return nil | |||
| 1239 | } | 1215 | } | ||
| 1240 | 1216 | ||||
| 1241 | func (e paletteExec) OpenNotifications() error { | 1217 | func (e paletteExec) OpenNotifications() error { | ||
M internal/usecase/daemon/palette_test.go +2 -9
| @@ -1343,15 +1343,8 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 1343 | 1343 | require.True(t, ac.overlays.promptActive()) | |
| 1344 | 1344 | d.closePrompt(ac) | |
| 1345 | 1345 | require.True(t, sess.ephemeral) | |
| 1346 | - | require.NoError(t, exec.OpenSessionPicker()) | |
| 1347 | - | require.True(t, ac.overlays.pickerClientActive()) | |
| 1348 | - | ac.overlays.pickerMu.Lock() | |
| 1349 | - | interaction := ac.overlays.pickerInteraction | |
| 1350 | - | ac.overlays.pickerMu.Unlock() | |
| 1351 | - | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 1352 | - | require.True(t, admitted) | |
| 1353 | - | require.True(t, d.closePickerForAttachment(ac, effect, interaction)) | |
| 1354 | - | effect.End() | |
| 1346 | + | // OpenSessionPicker ends this attachment; its lifecycle contract is tested | |
| 1347 | + | // separately by TestSessionPickerCommandDetachesToClientPicker. | |
| 1355 | 1348 | require.NoError(t, exec.EnterVisualMode()) | |
| 1356 | 1349 | require.True(t, ac.overlays.copyActive()) | |
| 1357 | 1350 | d.exitCopyMode(ac) | |
| @@ -1343,15 +1343,8 @@ func TestPaletteExecMethods(t *testing.T) { | |||||
| 1343 | require.True(t, ac.overlays.promptActive()) | 1343 | require.True(t, ac.overlays.promptActive()) | ||
| 1344 | d.closePrompt(ac) | 1344 | d.closePrompt(ac) | ||
| 1345 | require.True(t, sess.ephemeral) | 1345 | require.True(t, sess.ephemeral) | ||
| 1346 | - | require.NoError(t, exec.OpenSessionPicker()) | 1346 | + | // OpenSessionPicker ends this attachment; its lifecycle contract is tested |
| 1347 | - | require.True(t, ac.overlays.pickerClientActive()) | 1347 | + | // separately by TestSessionPickerCommandDetachesToClientPicker. |
| 1348 | - | ac.overlays.pickerMu.Lock() | |||
| 1349 | - | interaction := ac.overlays.pickerInteraction | |||
| 1350 | - | ac.overlays.pickerMu.Unlock() | |||
| 1351 | - | effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |||
| 1352 | - | require.True(t, admitted) | |||
| 1353 | - | require.True(t, d.closePickerForAttachment(ac, effect, interaction)) | |||
| 1354 | - | effect.End() | |||
| 1355 | require.NoError(t, exec.EnterVisualMode()) | 1348 | require.NoError(t, exec.EnterVisualMode()) | ||
| 1356 | require.True(t, ac.overlays.copyActive()) | 1349 | require.True(t, ac.overlays.copyActive()) | ||
| 1357 | d.exitCopyMode(ac) | 1350 | d.exitCopyMode(ac) | ||
D internal/usecase/daemon/picker_control.go +0 -153
| @@ -1,153 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/internal/ports" | |
| 6 | - | "github.com/bnema/vev/internal/protocol" | |
| 7 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | const controlPickerInteractionID uint64 = 1 | |
| 11 | - | ||
| 12 | - | // handlePickerControl serves the stable local authority without creating an | |
| 13 | - | // attachment. The snapshot and resolve paths rebuild from the same daemon | |
| 14 | - | // projection, so eligibility and opaque-key ownership never move client-side. | |
| 15 | - | func (d *Daemon) handlePickerControl(tr ports.ServerConnection, request protocol.PickerControlRequest) { | |
| 16 | - | defer tr.Close() | |
| 17 | - | response := d.answerPickerControl(request) | |
| 18 | - | _ = d.boundedControlSend(tr, response) | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | func (d *Daemon) answerPickerControl(request protocol.PickerControlRequest) protocol.PickerControlResponse { | |
| 22 | - | response := protocol.PickerControlResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.PickerSourceUnavailable} | |
| 23 | - | if protocol.ValidatePickerControlRequest(request) != nil || request.Version != protocol.Version { | |
| 24 | - | return response | |
| 25 | - | } | |
| 26 | - | recentViews, groupedViews, _ := d.pickerViewProjections(nil, nil) | |
| 27 | - | recent := pickerLineSetFor(recentViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |
| 28 | - | grouped := pickerLineSetFor(groupedViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |
| 29 | - | // Resolution revalidates the opaque key against the current catalogue. | |
| 30 | - | // A constant revision avoids rejecting an otherwise current target merely | |
| 31 | - | // because unrelated presentation text changed after the client snapshot. | |
| 32 | - | revision := uint64(1) | |
| 33 | - | if request.Operation == protocol.PickerControlSnapshot { | |
| 34 | - | snapshot := protocol.PickerSnapshot{InteractionID: controlPickerInteractionID, SourceID: protocol.PickerHomeSourceID, SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: recent.lines, Cursor: recent.cursor, Recent: protocol.PickerProjection{Lines: recent.lines, Cursor: recent.cursor}, Grouped: protocol.PickerProjection{Lines: grouped.lines, Cursor: grouped.cursor}} | |
| 35 | - | response.Status, response.Snapshot = protocol.PickerSourceOK, &snapshot | |
| 36 | - | return response | |
| 37 | - | } | |
| 38 | - | if request.Operation == protocol.PickerControlObserve { | |
| 39 | - | response.Status = protocol.PickerSourceOK | |
| 40 | - | response.Observations = d.observePickerRoutes(request.Targets) | |
| 41 | - | return response | |
| 42 | - | } | |
| 43 | - | if request.SourceID != protocol.PickerHomeSourceID { | |
| 44 | - | return response | |
| 45 | - | } | |
| 46 | - | target, ok := recent.keys[request.Key] | |
| 47 | - | if !ok || !d.pickerTargetCurrent(target) { | |
| 48 | - | return response | |
| 49 | - | } | |
| 50 | - | resolved, ok := d.pickerControlAttachTarget(target) | |
| 51 | - | if !ok { | |
| 52 | - | return response | |
| 53 | - | } | |
| 54 | - | response.Status, response.Resolved = protocol.PickerSourceOK, &resolved | |
| 55 | - | return response | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []protocol.PickerRouteObservation { | |
| 59 | - | observations := make([]protocol.PickerRouteObservation, len(targets)) | |
| 60 | - | d.mu.Lock() | |
| 61 | - | sessions := d.sessionsSnapshotLocked() | |
| 62 | - | d.mu.Unlock() | |
| 63 | - | for i, target := range targets { | |
| 64 | - | observation := protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRouteAbsent} | |
| 65 | - | for _, sess := range sessions { | |
| 66 | - | view := sess.snapshotView(viewOptions{}) | |
| 67 | - | if view.incarnation != target.LifecycleID || view.name != target.SessionName { | |
| 68 | - | continue | |
| 69 | - | } | |
| 70 | - | observation.Presence = protocol.PickerRoutePresent | |
| 71 | - | observation.Attention = view.hasAttention | |
| 72 | - | break | |
| 73 | - | } | |
| 74 | - | observations[i] = observation | |
| 75 | - | } | |
| 76 | - | return observations | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |
| 80 | - | // A remote target names an endpoint-qualified remote session, not a local | |
| 81 | - | // one. The structured route and lifecycle are revalidated against the | |
| 82 | - | // remote catalogue by remoteCatalogTargetReady; the local resolver never | |
| 83 | - | // sees a foreign target. | |
| 84 | - | if target.RemoteTarget != nil && target.RemoteKey != nil { | |
| 85 | - | remote := *target.RemoteTarget | |
| 86 | - | if !d.remoteCatalogTargetReady(remote) { | |
| 87 | - | return protocol.AttachTarget{}, false | |
| 88 | - | } | |
| 89 | - | return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(remote)), EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |
| 90 | - | } | |
| 91 | - | return d.pickerControlAttachTargetLocal(target) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // pickerControlAttachTargetLocal preserves the hybrid live behavior for a | |
| 95 | - | // local target while keeping the prepared resolver strict. The picker | |
| 96 | - | // snapshot's tab is a best-effort cursor, so a tab that vanished between the | |
| 97 | - | // snapshot and the resolve must not fail the whole attach: the client's own | |
| 98 | - | // missing-tab fallback would have attached the session's first tab anyway. | |
| 99 | - | // The wrapper therefore retries once without a preferred tab, which can only | |
| 100 | - | // succeed when the exact local lifecycle is still current, so a replaced or | |
| 101 | - | // renamed session and a foreign target still reject. | |
| 102 | - | func (d *Daemon) pickerControlAttachTargetLocal(target picker.Target) (protocol.AttachTarget, bool) { | |
| 103 | - | resolved, ok := d.localPickerControlAttachTarget(target) | |
| 104 | - | if ok || target.TabID == "" { | |
| 105 | - | return resolved, ok | |
| 106 | - | } | |
| 107 | - | target.TabID = "" | |
| 108 | - | return d.localPickerControlAttachTarget(target) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // localPickerControlAttachTarget is the prepared local-only control resolver | |
| 112 | - | // (Plan 001 P4.3). It rejects a structured foreign target (remote route or | |
| 113 | - | // remote session key) before any lookup, so a local resolve can never be | |
| 114 | - | // satisfied by a foreign row. It then matches the exact session identity and | |
| 115 | - | // lifecycle; a replaced lifecycle or a renamed session never falls back to a | |
| 116 | - | // same-name session. A non-empty preferred tab must still exist in the current | |
| 117 | - | // session, so a stale tab rejects instead of forwarding a dangling tab ID; the | |
| 118 | - | // hybrid pickerControlAttachTarget retains its vanished-tab fallback on top of | |
| 119 | - | // this strict check. | |
| 120 | - | // Broken and purging stopped records are unreachable here: only live registry | |
| 121 | - | // sessions are eligible. | |
| 122 | - | func (d *Daemon) localPickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |
| 123 | - | if target.RemoteTarget != nil || target.RemoteKey != nil { | |
| 124 | - | return protocol.AttachTarget{}, false | |
| 125 | - | } | |
| 126 | - | d.mu.Lock() | |
| 127 | - | defer d.mu.Unlock() | |
| 128 | - | sess := d.sessions[target.Session] | |
| 129 | - | if sess == nil { | |
| 130 | - | return protocol.AttachTarget{}, false | |
| 131 | - | } | |
| 132 | - | sess.mu.Lock() | |
| 133 | - | defer sess.mu.Unlock() | |
| 134 | - | if !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { | |
| 135 | - | return protocol.AttachTarget{}, false | |
| 136 | - | } | |
| 137 | - | if target.TabID != "" && !sessionHasStableTabLocked(sess, target.TabID) { | |
| 138 | - | return protocol.AttachTarget{}, false | |
| 139 | - | } | |
| 140 | - | exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 141 | - | return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | // sessionHasStableTabLocked reports whether an exact stable tab identity is | |
| 145 | - | // still present on a session. Caller holds the session lock. | |
| 146 | - | func sessionHasStableTabLocked(sess *session, tabID domain.TabStableID) bool { | |
| 147 | - | for _, tab := range sess.tabs { | |
| 148 | - | if tab != nil && tab.stableID == string(tabID) { | |
| 149 | - | return true | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | return false | |
| 153 | - | } | |
| @@ -1,153 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/vev/internal/domain" | |||
| 5 | - | "github.com/bnema/vev/internal/ports" | |||
| 6 | - | "github.com/bnema/vev/internal/protocol" | |||
| 7 | - | "github.com/bnema/vev/internal/usecase/picker" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | const controlPickerInteractionID uint64 = 1 | |||
| 11 | - | ||||
| 12 | - | // handlePickerControl serves the stable local authority without creating an | |||
| 13 | - | // attachment. The snapshot and resolve paths rebuild from the same daemon | |||
| 14 | - | // projection, so eligibility and opaque-key ownership never move client-side. | |||
| 15 | - | func (d *Daemon) handlePickerControl(tr ports.ServerConnection, request protocol.PickerControlRequest) { | |||
| 16 | - | defer tr.Close() | |||
| 17 | - | response := d.answerPickerControl(request) | |||
| 18 | - | _ = d.boundedControlSend(tr, response) | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | func (d *Daemon) answerPickerControl(request protocol.PickerControlRequest) protocol.PickerControlResponse { | |||
| 22 | - | response := protocol.PickerControlResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.PickerSourceUnavailable} | |||
| 23 | - | if protocol.ValidatePickerControlRequest(request) != nil || request.Version != protocol.Version { | |||
| 24 | - | return response | |||
| 25 | - | } | |||
| 26 | - | recentViews, groupedViews, _ := d.pickerViewProjections(nil, nil) | |||
| 27 | - | recent := pickerLineSetFor(recentViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |||
| 28 | - | grouped := pickerLineSetFor(groupedViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}) | |||
| 29 | - | // Resolution revalidates the opaque key against the current catalogue. | |||
| 30 | - | // A constant revision avoids rejecting an otherwise current target merely | |||
| 31 | - | // because unrelated presentation text changed after the client snapshot. | |||
| 32 | - | revision := uint64(1) | |||
| 33 | - | if request.Operation == protocol.PickerControlSnapshot { | |||
| 34 | - | snapshot := protocol.PickerSnapshot{InteractionID: controlPickerInteractionID, SourceID: protocol.PickerHomeSourceID, SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: recent.lines, Cursor: recent.cursor, Recent: protocol.PickerProjection{Lines: recent.lines, Cursor: recent.cursor}, Grouped: protocol.PickerProjection{Lines: grouped.lines, Cursor: grouped.cursor}} | |||
| 35 | - | response.Status, response.Snapshot = protocol.PickerSourceOK, &snapshot | |||
| 36 | - | return response | |||
| 37 | - | } | |||
| 38 | - | if request.Operation == protocol.PickerControlObserve { | |||
| 39 | - | response.Status = protocol.PickerSourceOK | |||
| 40 | - | response.Observations = d.observePickerRoutes(request.Targets) | |||
| 41 | - | return response | |||
| 42 | - | } | |||
| 43 | - | if request.SourceID != protocol.PickerHomeSourceID { | |||
| 44 | - | return response | |||
| 45 | - | } | |||
| 46 | - | target, ok := recent.keys[request.Key] | |||
| 47 | - | if !ok || !d.pickerTargetCurrent(target) { | |||
| 48 | - | return response | |||
| 49 | - | } | |||
| 50 | - | resolved, ok := d.pickerControlAttachTarget(target) | |||
| 51 | - | if !ok { | |||
| 52 | - | return response | |||
| 53 | - | } | |||
| 54 | - | response.Status, response.Resolved = protocol.PickerSourceOK, &resolved | |||
| 55 | - | return response | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []protocol.PickerRouteObservation { | |||
| 59 | - | observations := make([]protocol.PickerRouteObservation, len(targets)) | |||
| 60 | - | d.mu.Lock() | |||
| 61 | - | sessions := d.sessionsSnapshotLocked() | |||
| 62 | - | d.mu.Unlock() | |||
| 63 | - | for i, target := range targets { | |||
| 64 | - | observation := protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRouteAbsent} | |||
| 65 | - | for _, sess := range sessions { | |||
| 66 | - | view := sess.snapshotView(viewOptions{}) | |||
| 67 | - | if view.incarnation != target.LifecycleID || view.name != target.SessionName { | |||
| 68 | - | continue | |||
| 69 | - | } | |||
| 70 | - | observation.Presence = protocol.PickerRoutePresent | |||
| 71 | - | observation.Attention = view.hasAttention | |||
| 72 | - | break | |||
| 73 | - | } | |||
| 74 | - | observations[i] = observation | |||
| 75 | - | } | |||
| 76 | - | return observations | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |||
| 80 | - | // A remote target names an endpoint-qualified remote session, not a local | |||
| 81 | - | // one. The structured route and lifecycle are revalidated against the | |||
| 82 | - | // remote catalogue by remoteCatalogTargetReady; the local resolver never | |||
| 83 | - | // sees a foreign target. | |||
| 84 | - | if target.RemoteTarget != nil && target.RemoteKey != nil { | |||
| 85 | - | remote := *target.RemoteTarget | |||
| 86 | - | if !d.remoteCatalogTargetReady(remote) { | |||
| 87 | - | return protocol.AttachTarget{}, false | |||
| 88 | - | } | |||
| 89 | - | return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTargetFromRemote(remote)), EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |||
| 90 | - | } | |||
| 91 | - | return d.pickerControlAttachTargetLocal(target) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | // pickerControlAttachTargetLocal preserves the hybrid live behavior for a | |||
| 95 | - | // local target while keeping the prepared resolver strict. The picker | |||
| 96 | - | // snapshot's tab is a best-effort cursor, so a tab that vanished between the | |||
| 97 | - | // snapshot and the resolve must not fail the whole attach: the client's own | |||
| 98 | - | // missing-tab fallback would have attached the session's first tab anyway. | |||
| 99 | - | // The wrapper therefore retries once without a preferred tab, which can only | |||
| 100 | - | // succeed when the exact local lifecycle is still current, so a replaced or | |||
| 101 | - | // renamed session and a foreign target still reject. | |||
| 102 | - | func (d *Daemon) pickerControlAttachTargetLocal(target picker.Target) (protocol.AttachTarget, bool) { | |||
| 103 | - | resolved, ok := d.localPickerControlAttachTarget(target) | |||
| 104 | - | if ok || target.TabID == "" { | |||
| 105 | - | return resolved, ok | |||
| 106 | - | } | |||
| 107 | - | target.TabID = "" | |||
| 108 | - | return d.localPickerControlAttachTarget(target) | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // localPickerControlAttachTarget is the prepared local-only control resolver | |||
| 112 | - | // (Plan 001 P4.3). It rejects a structured foreign target (remote route or | |||
| 113 | - | // remote session key) before any lookup, so a local resolve can never be | |||
| 114 | - | // satisfied by a foreign row. It then matches the exact session identity and | |||
| 115 | - | // lifecycle; a replaced lifecycle or a renamed session never falls back to a | |||
| 116 | - | // same-name session. A non-empty preferred tab must still exist in the current | |||
| 117 | - | // session, so a stale tab rejects instead of forwarding a dangling tab ID; the | |||
| 118 | - | // hybrid pickerControlAttachTarget retains its vanished-tab fallback on top of | |||
| 119 | - | // this strict check. | |||
| 120 | - | // Broken and purging stopped records are unreachable here: only live registry | |||
| 121 | - | // sessions are eligible. | |||
| 122 | - | func (d *Daemon) localPickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) { | |||
| 123 | - | if target.RemoteTarget != nil || target.RemoteKey != nil { | |||
| 124 | - | return protocol.AttachTarget{}, false | |||
| 125 | - | } | |||
| 126 | - | d.mu.Lock() | |||
| 127 | - | defer d.mu.Unlock() | |||
| 128 | - | sess := d.sessions[target.Session] | |||
| 129 | - | if sess == nil { | |||
| 130 | - | return protocol.AttachTarget{}, false | |||
| 131 | - | } | |||
| 132 | - | sess.mu.Lock() | |||
| 133 | - | defer sess.mu.Unlock() | |||
| 134 | - | if !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { | |||
| 135 | - | return protocol.AttachTarget{}, false | |||
| 136 | - | } | |||
| 137 | - | if target.TabID != "" && !sessionHasStableTabLocked(sess, target.TabID) { | |||
| 138 | - | return protocol.AttachTarget{}, false | |||
| 139 | - | } | |||
| 140 | - | exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |||
| 141 | - | return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true | |||
| 142 | - | } | |||
| 143 | - | ||||
| 144 | - | // sessionHasStableTabLocked reports whether an exact stable tab identity is | |||
| 145 | - | // still present on a session. Caller holds the session lock. | |||
| 146 | - | func sessionHasStableTabLocked(sess *session, tabID domain.TabStableID) bool { | |||
| 147 | - | for _, tab := range sess.tabs { | |||
| 148 | - | if tab != nil && tab.stableID == string(tabID) { | |||
| 149 | - | return true | |||
| 150 | - | } | |||
| 151 | - | } | |||
| 152 | - | return false | |||
| 153 | - | } | |||
D internal/usecase/daemon/picker_control_test.go +0 -31
| @@ -1,31 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "testing" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/internal/protocol" | |
| 8 | - | "github.com/stretchr/testify/require" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | func TestPickerControlObserveReportsExactAttention(t *testing.T) { | |
| 12 | - | d, sess, _, _ := newManualSessionWithPTYs(t, nil) | |
| 13 | - | sess.mu.Lock() | |
| 14 | - | sess.incarnation = domain.SessionLifecycleID{9} | |
| 15 | - | sess.tabs[0].attention = true | |
| 16 | - | target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 17 | - | sess.mu.Unlock() | |
| 18 | - | missing := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: sess.name} | |
| 19 | - | ||
| 20 | - | response := d.answerPickerControl(protocol.PickerControlRequest{ | |
| 21 | - | Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve, | |
| 22 | - | Targets: []protocol.ExactSessionTarget{target, missing}, | |
| 23 | - | }) | |
| 24 | - | ||
| 25 | - | require.Equal(t, protocol.PickerSourceOK, response.Status) | |
| 26 | - | require.Equal(t, []protocol.PickerRouteObservation{ | |
| 27 | - | {Target: target, Presence: protocol.PickerRoutePresent, Attention: true}, | |
| 28 | - | {Target: missing, Presence: protocol.PickerRouteAbsent}, | |
| 29 | - | }, response.Observations) | |
| 30 | - | require.NoError(t, protocol.ValidatePickerControlResponse(response)) | |
| 31 | - | } | |
| @@ -1,31 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/domain" | |||
| 7 | - | "github.com/bnema/vev/internal/protocol" | |||
| 8 | - | "github.com/stretchr/testify/require" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func TestPickerControlObserveReportsExactAttention(t *testing.T) { | |||
| 12 | - | d, sess, _, _ := newManualSessionWithPTYs(t, nil) | |||
| 13 | - | sess.mu.Lock() | |||
| 14 | - | sess.incarnation = domain.SessionLifecycleID{9} | |||
| 15 | - | sess.tabs[0].attention = true | |||
| 16 | - | target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |||
| 17 | - | sess.mu.Unlock() | |||
| 18 | - | missing := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: sess.name} | |||
| 19 | - | ||||
| 20 | - | response := d.answerPickerControl(protocol.PickerControlRequest{ | |||
| 21 | - | Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve, | |||
| 22 | - | Targets: []protocol.ExactSessionTarget{target, missing}, | |||
| 23 | - | }) | |||
| 24 | - | ||||
| 25 | - | require.Equal(t, protocol.PickerSourceOK, response.Status) | |||
| 26 | - | require.Equal(t, []protocol.PickerRouteObservation{ | |||
| 27 | - | {Target: target, Presence: protocol.PickerRoutePresent, Attention: true}, | |||
| 28 | - | {Target: missing, Presence: protocol.PickerRouteAbsent}, | |||
| 29 | - | }, response.Observations) | |||
| 30 | - | require.NoError(t, protocol.ValidatePickerControlResponse(response)) | |||
| 31 | - | } | |||
M internal/usecase/daemon/picker_interaction.go +9 -4
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "slices" | |
| 6 | 7 | ||
| @@ -9,14 +10,15 @@ import ( | |||
| 9 | 10 | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | - | // This file owns the serving-daemon side of the picker interaction. The | |
| 13 | - | // daemon is the data and action authority: it publishes one structured line | |
| 14 | - | // set per source, resolves opaque keys, and performs the navigation, move, or | |
| 15 | - | // kill the client asked for. It never owns a presentation model. | |
| 13 | + | // This file retains the serving-daemon move-destination interaction. It must | |
| 14 | + | // never admit a navigation intent: session-picker ends the attachment and the | |
| 15 | + | // client supervisor owns all subsequent navigation and terminal input. | |
| 16 | 16 | ||
| 17 | 17 | // servingPickerSourceID names the source the serving daemon itself owns. | |
| 18 | 18 | const servingPickerSourceID = protocol.PickerServingSourceID | |
| 19 | 19 | ||
| 20 | + | var errClientOwnedNavigation = errors.New("session navigation is client-owned") | |
| 21 | + | ||
| 20 | 22 | // pickerDuplicateRows reports the first repeated row key with a readable | |
| 21 | 23 | // identity for both rows. A refused snapshot otherwise says only that a key | |
| 22 | 24 | // repeated, which is not enough to find the two rows that collided. | |
| @@ -79,6 +81,9 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE | |||
| 79 | 81 | if ac == nil || ac.overlays == nil || effect == nil || !effect.current() { | |
| 80 | 82 | return errAttachmentTransition | |
| 81 | 83 | } | |
| 84 | + | if intent != protocol.PickerIntentMoveTab && intent != protocol.PickerIntentMovePane { | |
| 85 | + | return errClientOwnedNavigation | |
| 86 | + | } | |
| 82 | 87 | sess := effect.sess | |
| 83 | 88 | if intent != protocol.PickerIntentNavigation { | |
| 84 | 89 | if sess == nil || !sess.capabilities().yieldsMoves() { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "slices" | 6 | "slices" | ||
| 6 | 7 | ||||
| @@ -9,14 +10,15 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/usecase/picker" | 10 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | - | // This file owns the serving-daemon side of the picker interaction. The | 13 | + | // This file retains the serving-daemon move-destination interaction. It must |
| 13 | - | // daemon is the data and action authority: it publishes one structured line | 14 | + | // never admit a navigation intent: session-picker ends the attachment and the |
| 14 | - | // set per source, resolves opaque keys, and performs the navigation, move, or | 15 | + | // client supervisor owns all subsequent navigation and terminal input. |
| 15 | - | // kill the client asked for. It never owns a presentation model. | |||
| 16 | 16 | ||||
| 17 | // servingPickerSourceID names the source the serving daemon itself owns. | 17 | // servingPickerSourceID names the source the serving daemon itself owns. | ||
| 18 | const servingPickerSourceID = protocol.PickerServingSourceID | 18 | const servingPickerSourceID = protocol.PickerServingSourceID | ||
| 19 | 19 | ||||
| 20 | + | var errClientOwnedNavigation = errors.New("session navigation is client-owned") | |||
| 21 | + | ||||
| 20 | // pickerDuplicateRows reports the first repeated row key with a readable | 22 | // pickerDuplicateRows reports the first repeated row key with a readable | ||
| 21 | // identity for both rows. A refused snapshot otherwise says only that a key | 23 | // identity for both rows. A refused snapshot otherwise says only that a key | ||
| 22 | // repeated, which is not enough to find the two rows that collided. | 24 | // repeated, which is not enough to find the two rows that collided. | ||
| @@ -79,6 +81,9 @@ func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentE | |||||
| 79 | if ac == nil || ac.overlays == nil || effect == nil || !effect.current() { | 81 | if ac == nil || ac.overlays == nil || effect == nil || !effect.current() { | ||
| 80 | return errAttachmentTransition | 82 | return errAttachmentTransition | ||
| 81 | } | 83 | } | ||
| 84 | + | if intent != protocol.PickerIntentMoveTab && intent != protocol.PickerIntentMovePane { | |||
| 85 | + | return errClientOwnedNavigation | |||
| 86 | + | } | |||
| 82 | sess := effect.sess | 87 | sess := effect.sess | ||
| 83 | if intent != protocol.PickerIntentNavigation { | 88 | if intent != protocol.PickerIntentNavigation { | ||
| 84 | if sess == nil || !sess.capabilities().yieldsMoves() { | 89 | if sess == nil || !sess.capabilities().yieldsMoves() { | ||
D internal/usecase/daemon/picker_interaction_test.go +0 -819
| @@ -1,819 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | - | "strings" | |
| 8 | - | "sync/atomic" | |
| 9 | - | "testing" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/vev/internal/domain" | |
| 13 | - | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/internal/protocol" | |
| 15 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 16 | - | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | - | "github.com/stretchr/testify/require" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | // pickerClientTestUnit builds a fixture attachment and one extra session so | |
| 21 | - | // snapshots are non-empty. It returns an admitted effect for the guarded | |
| 22 | - | // sends. | |
| 23 | - | func pickerClientTestUnit(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) { | |
| 24 | - | t.Helper() | |
| 25 | - | p, releasePTY := newBlockingPTY(t) | |
| 26 | - | return pickerClientTestUnitWithPTY(t, p, releasePTY) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // pickerClientTestUnitWithPTY builds the same fixture over a caller-owned | |
| 30 | - | // PTY, so tests can observe whether session input reached the child. | |
| 31 | - | func pickerClientTestUnitWithPTY(t *testing.T, p ports.PTY, releasePTY func()) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) { | |
| 32 | - | t.Helper() | |
| 33 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 34 | - | t.Cleanup(releasePTY) | |
| 35 | - | d.ptys = newFactorySeq(t, newQuietPTY()) | |
| 36 | - | _, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 37 | - | require.NoError(t, err) | |
| 38 | - | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 39 | - | require.True(t, ok) | |
| 40 | - | t.Cleanup(effect.End) | |
| 41 | - | return d, sess, ac, sends, effect | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func openTestPicker(t *testing.T, d *Daemon, ac *attachedClient, effect *attachmentEffect, sends chan wire.Envelope, intent protocol.PickerIntent) protocol.PickerOffer { | |
| 45 | - | t.Helper() | |
| 46 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, intent, moveSourceLocator{}, 0)) | |
| 47 | - | offer := awaitPickerOffer(t, sends) | |
| 48 | - | // The interaction namespace lives on the attachment, so the effect that | |
| 49 | - | // carried the offer is released here: a later transition or move must be | |
| 50 | - | // able to drain every other effect on this attachment. | |
| 51 | - | effect.End() | |
| 52 | - | return offer | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func awaitPickerOffer(t *testing.T, sends chan wire.Envelope) protocol.PickerOffer { | |
| 56 | - | t.Helper() | |
| 57 | - | for { | |
| 58 | - | frame := awaitTestValue(t, sends, "picker offer was not published") | |
| 59 | - | if offer, ok := decodeServerMessage(t, frame).(protocol.PickerOffer); ok { | |
| 60 | - | return offer | |
| 61 | - | } | |
| 62 | - | } | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | func awaitPickerSnapshot(t *testing.T, sends chan wire.Envelope) protocol.PickerSnapshot { | |
| 66 | - | t.Helper() | |
| 67 | - | for { | |
| 68 | - | frame := awaitTestValue(t, sends, "picker snapshot was not published") | |
| 69 | - | if snapshot, ok := decodeServerMessage(t, frame).(protocol.PickerSnapshot); ok { | |
| 70 | - | return snapshot | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func awaitPickerFailure(t *testing.T, sends chan wire.Envelope) protocol.PickerFailure { | |
| 76 | - | t.Helper() | |
| 77 | - | for { | |
| 78 | - | frame := awaitTestValue(t, sends, "picker failure was not published") | |
| 79 | - | if failure, ok := decodeServerMessage(t, frame).(protocol.PickerFailure); ok { | |
| 80 | - | return failure | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | func pickerClientCapability(t *testing.T, ac *attachedClient, sess *session) attachmentCapability { | |
| 86 | - | t.Helper() | |
| 87 | - | return captureAttachmentCapability(sess, ac, ac.transport()) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // navigateSelection builds one typed commit for the displayed snapshot. | |
| 91 | - | func navigateSelection(snapshot protocol.PickerSnapshot, key string) protocol.PickerSelection { | |
| 92 | - | return protocol.PickerSelection{ | |
| 93 | - | InteractionID: snapshot.InteractionID, SourceID: servingPickerSourceID, | |
| 94 | - | SourceRevision: snapshot.SourceRevision, Key: key, Action: protocol.PickerActionNavigate, | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | // firstSelectableKey returns the first line the source authorised for the | |
| 99 | - | // requested action. | |
| 100 | - | func firstSelectableKey(t *testing.T, snapshot protocol.PickerSnapshot, action protocol.PickerLineActions) string { | |
| 101 | - | t.Helper() | |
| 102 | - | for _, line := range snapshot.Lines { | |
| 103 | - | if line.Actions&action != 0 { | |
| 104 | - | return line.Key | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | t.Fatalf("snapshot published no line admitting %d", action) | |
| 108 | - | return "" | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func TestPickerOfferAndSnapshotPublishResolvableLines(t *testing.T) { | |
| 112 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 113 | - | ||
| 114 | - | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 115 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 116 | - | ||
| 117 | - | require.NotZero(t, offer.InteractionID) | |
| 118 | - | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |
| 119 | - | require.Equal(t, offer.InteractionID, snapshot.InteractionID) | |
| 120 | - | require.Equal(t, servingPickerSourceID, snapshot.SourceID) | |
| 121 | - | require.Equal(t, uint64(1), snapshot.SourceRevision) | |
| 122 | - | require.NotEmpty(t, snapshot.Lines) | |
| 123 | - | ||
| 124 | - | // Every selectable line resolves through the published keys, and the keys | |
| 125 | - | // carry session names for the unchanged handoff. | |
| 126 | - | ac.overlays.pickerMu.Lock() | |
| 127 | - | keys := ac.overlays.pickerKeys | |
| 128 | - | ac.overlays.pickerMu.Unlock() | |
| 129 | - | selectable := 0 | |
| 130 | - | for _, line := range snapshot.Lines { | |
| 131 | - | if line.Actions == 0 { | |
| 132 | - | continue | |
| 133 | - | } | |
| 134 | - | selectable++ | |
| 135 | - | target, ok := keys[line.Key] | |
| 136 | - | require.True(t, ok, "line %q has no resolved target", line.Key) | |
| 137 | - | require.NotEmpty(t, target.Name, "resolved target carries no session name") | |
| 138 | - | } | |
| 139 | - | require.NotZero(t, selectable) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | func TestPickerSnapshotPublishesFocusEligibility(t *testing.T) { | |
| 143 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 144 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 145 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 146 | - | ||
| 147 | - | headers, tabs := 0, 0 | |
| 148 | - | for _, line := range snapshot.Lines { | |
| 149 | - | switch line.Kind { | |
| 150 | - | case protocol.PickerLineSection: | |
| 151 | - | require.False(t, line.Focusable, "section %q is not a cursor destination", line.Label) | |
| 152 | - | require.Zero(t, line.Actions, "section %q admits no action", line.Label) | |
| 153 | - | case protocol.PickerLineSession: | |
| 154 | - | headers++ | |
| 155 | - | // A live session header names a group of tabs: navigation belongs | |
| 156 | - | // to its tab rows, so the header is rendered and skipped. | |
| 157 | - | require.False(t, line.Focusable, "session header %q is not a destination", line.Label) | |
| 158 | - | require.Zero(t, line.Actions) | |
| 159 | - | case protocol.PickerLineTab: | |
| 160 | - | tabs++ | |
| 161 | - | require.True(t, line.Focusable, "tab %q must be reachable", line.Label) | |
| 162 | - | require.NotZero(t, line.Actions, "a reachable tab admits the action that made it reachable") | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | require.NotZero(t, headers) | |
| 166 | - | require.NotZero(t, tabs) | |
| 167 | - | ||
| 168 | - | // The published cursor hint never names a row the client may not rest on. | |
| 169 | - | require.NotEmpty(t, snapshot.Cursor.Key) | |
| 170 | - | for _, line := range snapshot.Lines { | |
| 171 | - | if line.Key == snapshot.Cursor.Key { | |
| 172 | - | require.True(t, line.Focusable) | |
| 173 | - | } | |
| 174 | - | } | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | func TestPickerOpenInstallsNoPresentationModel(t *testing.T) { | |
| 178 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 179 | - | ||
| 180 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 181 | - | _ = awaitPickerSnapshot(t, sends) | |
| 182 | - | ||
| 183 | - | // The daemon publishes data and actions only: no model, no cursor, no | |
| 184 | - | // search state of its own. | |
| 185 | - | ac.overlays.pickerMu.Lock() | |
| 186 | - | require.True(t, ac.overlays.pickerOpen) | |
| 187 | - | require.NotZero(t, ac.overlays.pickerInteraction) | |
| 188 | - | require.NotEmpty(t, ac.overlays.pickerKeys) | |
| 189 | - | ac.overlays.pickerMu.Unlock() | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | func TestPickerSelectionRejectsStaleAndUnknown(t *testing.T) { | |
| 193 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 194 | - | ||
| 195 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 196 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 197 | - | ||
| 198 | - | capability := pickerClientCapability(t, ac, sess) | |
| 199 | - | // Unknown key on the open interaction reports UnknownKey without | |
| 200 | - | // closing the interaction. | |
| 201 | - | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, "ff00/ghost"))) | |
| 202 | - | failure := awaitPickerFailure(t, sends) | |
| 203 | - | require.Equal(t, protocol.PickerUnknownKey, failure.Code) | |
| 204 | - | require.Equal(t, snapshot.InteractionID, failure.InteractionID) | |
| 205 | - | ||
| 206 | - | // A foreign source is rejected before any key lookup. | |
| 207 | - | foreign := navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)) | |
| 208 | - | foreign.SourceID = "elsewhere" | |
| 209 | - | require.False(t, d.handleAttachmentClientMessage(capability, foreign)) | |
| 210 | - | require.Equal(t, protocol.PickerUnknownSource, awaitPickerFailure(t, sends).Code) | |
| 211 | - | ||
| 212 | - | // A retired interaction reports StaleRevision. | |
| 213 | - | d.closePickerForAttachment(ac, effect, snapshot.InteractionID) | |
| 214 | - | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)))) | |
| 215 | - | require.Equal(t, protocol.PickerStaleRevision, awaitPickerFailure(t, sends).Code) | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | func TestPickerMoveIntentPublishesMoveLinesOnly(t *testing.T) { | |
| 219 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 220 | - | source := moveSourceLocator{ | |
| 221 | - | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |
| 222 | - | TabID: domain.TabStableID(testAttachmentTab(sess).stableID), | |
| 223 | - | } | |
| 224 | - | ac.overlays.pickerMu.Lock() | |
| 225 | - | require.False(t, ac.overlays.pickerOpen) | |
| 226 | - | ac.overlays.pickerMu.Unlock() | |
| 227 | - | ||
| 228 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMoveTab, source, 0)) | |
| 229 | - | offer := awaitPickerOffer(t, sends) | |
| 230 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 231 | - | require.Equal(t, protocol.PickerIntentMoveTab, offer.Intent) | |
| 232 | - | ||
| 233 | - | // The source session never appears as its own destination, and rows that | |
| 234 | - | // cannot accept a move carry no action. | |
| 235 | - | for _, line := range snapshot.Lines { | |
| 236 | - | require.NotEqual(t, sourceMoveKey(source), line.Key) | |
| 237 | - | if line.Actions != 0 { | |
| 238 | - | require.Equal(t, protocol.PickerCanMove, line.Actions) | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | func sourceMoveKey(source moveSourceLocator) string { | |
| 244 | - | return pickerMoveSourceKey(source) | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | func TestPickerOpenRepaintsWithoutSessionChange(t *testing.T) { | |
| 248 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 249 | - | ||
| 250 | - | // The open invalidates the session even when nothing else changed: | |
| 251 | - | // without this repaint the opener's fence would never retire and | |
| 252 | - | // the palette Enter would hang until its deadline. | |
| 253 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 254 | - | _ = awaitPickerSnapshot(t, sends) | |
| 255 | - | select { | |
| 256 | - | case frame := <-sends: | |
| 257 | - | require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "open must trigger an authoritative repaint") | |
| 258 | - | default: | |
| 259 | - | t.Fatal("open produced no repaint after the snapshot") | |
| 260 | - | } | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | func TestPickerSessionPickerCommandOpensInteraction(t *testing.T) { | |
| 264 | - | p, release := newBlockingPTY(t) | |
| 265 | - | defer release() | |
| 266 | - | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 267 | - | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 268 | - | effect.uiActionID = 77 | |
| 269 | - | ||
| 270 | - | // Full path through overlay dispatch: palette query, then Enter on | |
| 271 | - | // the same effect. The Enter routes through handlePaletteInput via | |
| 272 | - | // the overlay runtime, exactly like a real client frame. | |
| 273 | - | d.enterPalette(current, ac) | |
| 274 | - | d.handlePaletteInput(ac, []byte("sessionpicker"), effect) | |
| 275 | - | require.NotNil(t, ac.overlays.palette) | |
| 276 | - | d.handlePaletteInput(ac, []byte("\r"), effect) | |
| 277 | - | ||
| 278 | - | offer := awaitPickerOffer(t, sends) | |
| 279 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 280 | - | require.NotZero(t, offer.InteractionID) | |
| 281 | - | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |
| 282 | - | require.NotEmpty(t, snapshot.Lines) | |
| 283 | - | ac.overlays.pickerMu.Lock() | |
| 284 | - | require.True(t, ac.overlays.pickerOpen) | |
| 285 | - | ac.overlays.pickerMu.Unlock() | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | func TestPickerCancelPublishesClosedAndAuthoritativeFullPaint(t *testing.T) { | |
| 289 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 290 | - | ||
| 291 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 292 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 293 | - | // The open publishes its own restore paint after the snapshot; the release | |
| 294 | - | // assertion below is about the paint that follows the close. | |
| 295 | - | drainAllFrames(sends) | |
| 296 | - | ||
| 297 | - | // The client cancels: the daemon retires the interaction, confirms the | |
| 298 | - | // close with its restore barrier, and publishes the authoritative full | |
| 299 | - | // paint the client's release path waits for. | |
| 300 | - | capability := pickerClientCapability(t, ac, sess) | |
| 301 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |
| 302 | - | ac.overlays.pickerMu.Lock() | |
| 303 | - | require.False(t, ac.overlays.pickerOpen) | |
| 304 | - | ac.overlays.pickerMu.Unlock() | |
| 305 | - | ||
| 306 | - | var sawClosed bool | |
| 307 | - | for settled := false; !settled; { | |
| 308 | - | select { | |
| 309 | - | case frame := <-sends: | |
| 310 | - | if closed, ok := decodeServerMessage(t, frame).(protocol.PickerClosed); ok { | |
| 311 | - | require.Equal(t, snapshot.InteractionID, closed.InteractionID) | |
| 312 | - | sawClosed = true | |
| 313 | - | continue | |
| 314 | - | } | |
| 315 | - | if output, ok := decodeServerMessage(t, frame).(protocol.Output); ok { | |
| 316 | - | require.True(t, sawClosed, "the restore paint must follow the close") | |
| 317 | - | require.True(t, output.Full, "cancel release paint must be authoritative") | |
| 318 | - | settled = true | |
| 319 | - | } | |
| 320 | - | case <-time.After(200 * time.Millisecond): | |
| 321 | - | settled = true | |
| 322 | - | } | |
| 323 | - | } | |
| 324 | - | require.True(t, sawClosed, "cancel published no close confirmation") | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestPickerPaletteFramesOpenInteraction(t *testing.T) { | |
| 328 | - | p, release := newBlockingPTY(t) | |
| 329 | - | defer release() | |
| 330 | - | d, _, ac, sends := newManualSessionWithPTYs(t, p) | |
| 331 | - | capability := pickerClientCapability(t, ac, testAttachmentSession(t, ac)) | |
| 332 | - | ||
| 333 | - | // Drive the real attached-client frame path instead of calling the | |
| 334 | - | // palette handler: Alt+Space opens the palette, the query selects the | |
| 335 | - | // session-picker command, and Enter executes it. | |
| 336 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 4, Data: []byte("\x1b ")})) | |
| 337 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 5, Data: []byte("SSP")})) | |
| 338 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 6, Data: []byte("\r")})) | |
| 339 | - | ||
| 340 | - | _ = awaitPickerOffer(t, sends) | |
| 341 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 342 | - | require.NotZero(t, snapshot.InteractionID) | |
| 343 | - | require.NotEmpty(t, snapshot.Lines) | |
| 344 | - | ac.overlays.pickerMu.Lock() | |
| 345 | - | require.True(t, ac.overlays.pickerOpen) | |
| 346 | - | ac.overlays.pickerMu.Unlock() | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | func testAttachmentSession(t *testing.T, ac *attachedClient) *session { | |
| 350 | - | t.Helper() | |
| 351 | - | sess := ac.currentAttachmentSession() | |
| 352 | - | require.NotNil(t, sess) | |
| 353 | - | return sess | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | // secondSessionForTest returns the fixture's extra session, the one the attach | |
| 357 | - | // itself does not display. | |
| 358 | - | func secondSessionForTest(t *testing.T, d *Daemon) *session { | |
| 359 | - | t.Helper() | |
| 360 | - | d.mu.Lock() | |
| 361 | - | defer d.mu.Unlock() | |
| 362 | - | for _, sess := range d.sessions { | |
| 363 | - | if sess == nil { | |
| 364 | - | continue | |
| 365 | - | } | |
| 366 | - | sess.mu.Lock() | |
| 367 | - | name := sess.name | |
| 368 | - | sess.mu.Unlock() | |
| 369 | - | if name == "second" { | |
| 370 | - | return sess | |
| 371 | - | } | |
| 372 | - | } | |
| 373 | - | t.Fatal("fixture second session missing") | |
| 374 | - | return nil | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | // pickerBellLines reports the session headers carrying the bell glyph and the | |
| 378 | - | // tab rows carrying the structured attention marker. | |
| 379 | - | func pickerBellLines(snapshot protocol.PickerSnapshot) (headers int, tabRows int) { | |
| 380 | - | bell := string(ui.AttentionGlyph) | |
| 381 | - | for _, line := range snapshot.Lines { | |
| 382 | - | switch line.Kind { | |
| 383 | - | case protocol.PickerLineSession: | |
| 384 | - | if strings.HasSuffix(line.Label, " "+bell) { | |
| 385 | - | headers++ | |
| 386 | - | } | |
| 387 | - | case protocol.PickerLineTab: | |
| 388 | - | if line.Attention { | |
| 389 | - | tabRows++ | |
| 390 | - | } | |
| 391 | - | } | |
| 392 | - | } | |
| 393 | - | return headers, tabRows | |
| 394 | - | } | |
| 395 | - | ||
| 396 | - | // TestPickerSnapshotCarriesAttentionBellsToTheClient pins the notification path | |
| 397 | - | // into the client-owned modal: the daemon publishes the bell on the ringing | |
| 398 | - | // session's header and the structured marker on its tab row, and the client | |
| 399 | - | // renders both. | |
| 400 | - | func TestPickerSnapshotCarriesAttentionBellsToTheClient(t *testing.T) { | |
| 401 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 402 | - | ringing := secondSessionForTest(t, d) | |
| 403 | - | ringing.mu.Lock() | |
| 404 | - | ringing.tabs[0].attention = true | |
| 405 | - | ringing.tabs[0].attentionAt = time.Unix(1, 0) | |
| 406 | - | ringing.mu.Unlock() | |
| 407 | - | ||
| 408 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 409 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 410 | - | ||
| 411 | - | headers, tabRows := pickerBellLines(snapshot) | |
| 412 | - | require.Equal(t, 1, headers, "the ringing session header must carry the bell glyph") | |
| 413 | - | require.Equal(t, 1, tabRows, "the ringing tab row must carry the structured attention marker") | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | // TestAttentionPulseRepublishesTheOpenPickerSource pins that a bell raised | |
| 417 | - | // while the client presents the modal reaches it: client-owned presentation | |
| 418 | - | // suppresses the daemon's paints, so the pulse must advance the picker source | |
| 419 | - | // the client presents from. A closed interaction republishes nothing. | |
| 420 | - | func TestAttentionPulseRepublishesTheOpenPickerSource(t *testing.T) { | |
| 421 | - | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 422 | - | defer releases[0]() | |
| 423 | - | defer releases[1]() | |
| 424 | - | clk := newManualAttentionClock() | |
| 425 | - | d.clock = clk | |
| 426 | - | require.True(t, selectTestAttachmentTab(sess, 0)) | |
| 427 | - | ||
| 428 | - | effect := admitPickerEffectForTest(t, sess, ac) | |
| 429 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 430 | - | require.Equal(t, protocol.PickerIntentNavigation, awaitPickerOffer(t, sends).Intent) | |
| 431 | - | first := awaitPickerSnapshot(t, sends) | |
| 432 | - | require.Equal(t, 0, pickerBellCount(first), "a quiet session must publish no bell") | |
| 433 | - | ||
| 434 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 435 | - | defer cancel() | |
| 436 | - | done := make(chan struct{}) | |
| 437 | - | go func() { | |
| 438 | - | d.attentionAnimator(ctx) | |
| 439 | - | close(done) | |
| 440 | - | }() | |
| 441 | - | ||
| 442 | - | // A background tab rings while the modal is open. The client owns | |
| 443 | - | // presentation, so only a newer source revision can update its rows. | |
| 444 | - | sess.mu.Lock() | |
| 445 | - | sess.tabs[1].attention = true | |
| 446 | - | sess.tabs[1].attentionAt = time.Unix(1, 0) | |
| 447 | - | sess.mu.Unlock() | |
| 448 | - | d.pokeAttentionTicker() | |
| 449 | - | ||
| 450 | - | timer := clk.nextTimer(t) | |
| 451 | - | timer.fire() | |
| 452 | - | second := awaitPickerSnapshot(t, sends) | |
| 453 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 454 | - | headers, tabRows := pickerBellLines(second) | |
| 455 | - | require.Equal(t, 1, headers, "the pulse must publish the new bell on the session header") | |
| 456 | - | require.Equal(t, 1, tabRows, "the pulse must publish the new bell on the tab row") | |
| 457 | - | ||
| 458 | - | // A retired interaction must not be reopened by a later pulse. The | |
| 459 | - | // animator keeps one timer while attention exists, so the same fired timer | |
| 460 | - | // carries the next pulse. | |
| 461 | - | retireEffect := admitPickerEffectForTest(t, sess, ac) | |
| 462 | - | require.True(t, d.closePickerForAttachment(ac, retireEffect, second.InteractionID)) | |
| 463 | - | _ = drainAllFrames(sends) | |
| 464 | - | sess.mu.Lock() | |
| 465 | - | sess.tabs[0].attention = true | |
| 466 | - | sess.tabs[0].attentionAt = time.Unix(2, 0) | |
| 467 | - | sess.mu.Unlock() | |
| 468 | - | d.pokeAttentionTicker() | |
| 469 | - | timer.fire() | |
| 470 | - | for _, frame := range drainAllFrames(sends) { | |
| 471 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "a closed interaction republished a snapshot") | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | cancel() | |
| 475 | - | require.Eventually(t, func() bool { | |
| 476 | - | select { | |
| 477 | - | case <-done: | |
| 478 | - | return true | |
| 479 | - | default: | |
| 480 | - | return false | |
| 481 | - | } | |
| 482 | - | }, time.Second, time.Millisecond) | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | func pickerBellCount(snapshot protocol.PickerSnapshot) int { | |
| 486 | - | headers, tabRows := pickerBellLines(snapshot) | |
| 487 | - | return headers + tabRows | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | func TestPickerSelectionRequiresExactSourceRevision(t *testing.T) { | |
| 491 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 492 | - | ||
| 493 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 494 | - | first := awaitPickerSnapshot(t, sends) | |
| 495 | - | // A refresh that observes a real row change publishes a newer revision: the | |
| 496 | - | // client may only commit the model it is displaying, so an older revision | |
| 497 | - | // must fail even though it is not newer than the daemon's current one. | |
| 498 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 499 | - | require.NoError(t, err) | |
| 500 | - | d.refreshPickerSnapshot(ac) | |
| 501 | - | second := awaitPickerSnapshot(t, sends) | |
| 502 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 503 | - | ||
| 504 | - | capability := pickerClientCapability(t, ac, sess) | |
| 505 | - | key := firstSelectableKey(t, second, protocol.PickerCanNavigate) | |
| 506 | - | for _, revision := range []uint64{second.SourceRevision - 1, second.SourceRevision + 1} { | |
| 507 | - | selection := navigateSelection(second, key) | |
| 508 | - | selection.SourceRevision = revision | |
| 509 | - | require.False(t, d.handleAttachmentClientMessage(capability, selection)) | |
| 510 | - | failure := awaitPickerFailure(t, sends) | |
| 511 | - | require.Equal(t, protocol.PickerStaleRevision, failure.Code, "revision %d must be rejected", revision) | |
| 512 | - | ac.overlays.pickerMu.Lock() | |
| 513 | - | require.True(t, ac.overlays.pickerOpen, "a rejected revision must not close the interaction") | |
| 514 | - | ac.overlays.pickerMu.Unlock() | |
| 515 | - | } | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | func TestPickerInteractionDropsRawInput(t *testing.T) { | |
| 519 | - | writes := make(chan []byte, 8) | |
| 520 | - | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 521 | - | d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY) | |
| 522 | - | capability := pickerClientCapability(t, ac, sess) | |
| 523 | - | ||
| 524 | - | // A normal attachment forwards keys to the session. | |
| 525 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: []byte("x")})) | |
| 526 | - | require.Equal(t, []byte("x"), awaitTestValue(t, writes, "session input never reached the pty")) | |
| 527 | - | ||
| 528 | - | // Positive control: with the pane reporting mouse mode, a mouse report | |
| 529 | - | // reaches the pane's pty. Without this control the negative assertion | |
| 530 | - | // below would pass even if mouse routing were broken outright. | |
| 531 | - | tb := testAttachmentTab(sess) | |
| 532 | - | require.NotNil(t, tb) | |
| 533 | - | pane := tb.panes["pane-1"] | |
| 534 | - | require.NotNil(t, pane) | |
| 535 | - | pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 536 | - | mouseReport := []byte(fmt.Sprintf("\x1b[<0;2;%dM", clientTopBarRows+2)) | |
| 537 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: mouseReport})) | |
| 538 | - | require.NotEmpty(t, awaitTestValue(t, writes, "positive control: mouse never reached the pane")) | |
| 539 | - | ||
| 540 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 541 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 542 | - | ||
| 543 | - | // While the picker is open it owns user input: keys and mouse reports | |
| 544 | - | // must not reach the session, and the interaction stays open. | |
| 545 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 2, Data: []byte("hidden")})) | |
| 546 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 3, Data: mouseReport})) | |
| 547 | - | select { | |
| 548 | - | case frame := <-writes: | |
| 549 | - | t.Fatalf("picker-owned input reached the pty: %q", frame) | |
| 550 | - | case <-time.After(100 * time.Millisecond): | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | // Closing the interaction restores normal routing, mouse included. | |
| 554 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |
| 555 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 4, ActionID: 4, Data: []byte("visible")})) | |
| 556 | - | require.Equal(t, []byte("visible"), awaitTestValue(t, writes, "session input did not resume after the close")) | |
| 557 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 5, ActionID: 5, Data: mouseReport})) | |
| 558 | - | require.NotEmpty(t, awaitTestValue(t, writes, "mouse routing did not resume after the close")) | |
| 559 | - | } | |
| 560 | - | ||
| 561 | - | func TestPickerSelectionClosesBeforeTheDestinationPaint(t *testing.T) { | |
| 562 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 563 | - | ||
| 564 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 565 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 566 | - | capability := pickerClientCapability(t, ac, sess) | |
| 567 | - | ||
| 568 | - | // Selecting the current session is a same-peer handoff. The interaction | |
| 569 | - | // must be retired before that handoff: the client releases only on a | |
| 570 | - | // paint accepted after the daemon's close, so the destination paint has | |
| 571 | - | // to follow it on the wire. Drain the open's own repaint first. | |
| 572 | - | drainAllFrames(sends) | |
| 573 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 574 | - | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, key))) | |
| 575 | - | ||
| 576 | - | // Collect the handoff frames for a short settle window, then require the | |
| 577 | - | // close to precede any paint. | |
| 578 | - | var order []string | |
| 579 | - | for settled := false; !settled; { | |
| 580 | - | select { | |
| 581 | - | case frame := <-sends: | |
| 582 | - | order = append(order, envelopeMessageName(t, frame.Payload)) | |
| 583 | - | case <-time.After(200 * time.Millisecond): | |
| 584 | - | settled = true | |
| 585 | - | } | |
| 586 | - | } | |
| 587 | - | require.NotEmpty(t, order, "the handoff published nothing") | |
| 588 | - | require.Equal(t, "PickerClosed", order[0], "the close must precede the handoff") | |
| 589 | - | for i, frameType := range order { | |
| 590 | - | if frameType == "Output" { | |
| 591 | - | require.NotZero(t, i, "a paint must not precede the close") | |
| 592 | - | } | |
| 593 | - | } | |
| 594 | - | ac.overlays.pickerMu.Lock() | |
| 595 | - | require.False(t, ac.overlays.pickerOpen) | |
| 596 | - | ac.overlays.pickerMu.Unlock() | |
| 597 | - | } | |
| 598 | - | ||
| 599 | - | func TestPickerInteractionDropsClipboardImage(t *testing.T) { | |
| 600 | - | writes := make(chan []byte, 8) | |
| 601 | - | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 602 | - | d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY) | |
| 603 | - | capability := pickerClientCapability(t, ac, sess) | |
| 604 | - | ||
| 605 | - | temp := t.TempDir() | |
| 606 | - | t.Setenv("TMPDIR", temp) | |
| 607 | - | push := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte("\x89PNG\r\n\x1a\n")} | |
| 608 | - | ||
| 609 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 610 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 611 | - | ||
| 612 | - | // An image push is user input too: the clipboard path must not reach the | |
| 613 | - | // session while the picker owns input. | |
| 614 | - | require.False(t, d.handleAttachmentClientMessage(capability, push)) | |
| 615 | - | select { | |
| 616 | - | case frame := <-writes: | |
| 617 | - | t.Fatalf("clipboard path reached the pty during the interaction: %q", frame) | |
| 618 | - | case <-time.After(100 * time.Millisecond): | |
| 619 | - | } | |
| 620 | - | ||
| 621 | - | // After the close the same push is delivered again. | |
| 622 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |
| 623 | - | push.InputSeq = 2 | |
| 624 | - | require.False(t, d.handleAttachmentClientMessage(capability, push)) | |
| 625 | - | select { | |
| 626 | - | case frame := <-writes: | |
| 627 | - | require.Contains(t, string(frame), ".png") | |
| 628 | - | case <-time.After(2 * time.Second): | |
| 629 | - | t.Fatal("clipboard path did not resume after the close") | |
| 630 | - | } | |
| 631 | - | } | |
| 632 | - | ||
| 633 | - | func TestPickerSnapshotRefreshThroughDirectoryNotification(t *testing.T) { | |
| 634 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 635 | - | ||
| 636 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 637 | - | first := awaitPickerSnapshot(t, sends) | |
| 638 | - | ||
| 639 | - | // The row set changes for real: a new session appears in the catalogue. | |
| 640 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 641 | - | require.NoError(t, err) | |
| 642 | - | ||
| 643 | - | // The catalogue notification path must reach the picker even though the | |
| 644 | - | // daemon installs no overlay model: refreshRemoteDirectoryViews is the | |
| 645 | - | // entry the directory subscription calls. | |
| 646 | - | d.refreshRemoteDirectoryViews() | |
| 647 | - | second := awaitPickerSnapshot(t, sends) | |
| 648 | - | require.Equal(t, first.InteractionID, second.InteractionID) | |
| 649 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 650 | - | require.Greater(t, len(second.Lines), len(first.Lines), "the new session must appear as a line") | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | // TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections pins that the | |
| 654 | - | // serving daemon publishes the actual Recent and Grouped projections instead of | |
| 655 | - | // one line set copied into both. Recent carries the flat recency order the | |
| 656 | - | // default client sort consumes; Grouped carries the sectioned order the grouped | |
| 657 | - | // sort consumes. Both stay one authority: the same keys admit the same actions. | |
| 658 | - | func TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections(t *testing.T) { | |
| 659 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 660 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 661 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 662 | - | ||
| 663 | - | recentSections, groupedSections := 0, 0 | |
| 664 | - | recentActions := make(map[string]protocol.PickerLineActions, len(snapshot.Recent.Lines)) | |
| 665 | - | groupedActions := make(map[string]protocol.PickerLineActions, len(snapshot.Grouped.Lines)) | |
| 666 | - | for _, line := range snapshot.Recent.Lines { | |
| 667 | - | if line.Kind == protocol.PickerLineSection { | |
| 668 | - | recentSections++ | |
| 669 | - | } | |
| 670 | - | if line.Key != "" { | |
| 671 | - | recentActions[line.Key] = line.Actions | |
| 672 | - | } | |
| 673 | - | } | |
| 674 | - | for _, line := range snapshot.Grouped.Lines { | |
| 675 | - | if line.Kind == protocol.PickerLineSection { | |
| 676 | - | groupedSections++ | |
| 677 | - | } | |
| 678 | - | if line.Key != "" { | |
| 679 | - | groupedActions[line.Key] = line.Actions | |
| 680 | - | } | |
| 681 | - | } | |
| 682 | - | ||
| 683 | - | // The sort inputs differ: the grouped order is sectioned so its run | |
| 684 | - | // boundaries reach the grouped sort, and the recent order stays flat so the | |
| 685 | - | // recency sort never inherits grouping it did not ask for. | |
| 686 | - | require.Zero(t, recentSections, "recent projection published grouped section runs") | |
| 687 | - | require.NotZero(t, groupedSections, "grouped projection published no section runs") | |
| 688 | - | require.NotEqual(t, snapshot.Recent.Lines, snapshot.Grouped.Lines, "both projections carry the same sort input") | |
| 689 | - | require.Equal(t, recentActions, groupedActions, "projections disagree on destination actions") | |
| 690 | - | } | |
| 691 | - | ||
| 692 | - | // TestPickerRefreshPublishesOnlyChangedSource pins the revision contract: a | |
| 693 | - | // repeated publish of an unchanged projection pair neither bumps the source | |
| 694 | - | // revision nor reaches the client, while a real row-set change republishes a | |
| 695 | - | // full snapshot with a newer revision. | |
| 696 | - | func TestPickerRefreshPublishesOnlyChangedSource(t *testing.T) { | |
| 697 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |
| 698 | - | ||
| 699 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 700 | - | first := awaitPickerSnapshot(t, sends) | |
| 701 | - | require.Equal(t, uint64(1), first.SourceRevision) | |
| 702 | - | _ = drainAllFrames(sends) | |
| 703 | - | ||
| 704 | - | // The rows did not change, so the revision the client is displaying stays | |
| 705 | - | // current: a refresh must publish nothing and send nothing. | |
| 706 | - | d.refreshPickerSnapshot(ac) | |
| 707 | - | d.refreshPickerSnapshot(ac) | |
| 708 | - | for _, frame := range drainAllFrames(sends) { | |
| 709 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "unchanged source republished a snapshot") | |
| 710 | - | } | |
| 711 | - | ac.overlays.pickerMu.Lock() | |
| 712 | - | require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "unchanged source bumped the revision") | |
| 713 | - | ac.overlays.pickerMu.Unlock() | |
| 714 | - | ||
| 715 | - | // A real row-set change republishes a full snapshot with a newer revision | |
| 716 | - | // through the current attachment capability. | |
| 717 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 718 | - | require.NoError(t, err) | |
| 719 | - | d.refreshPickerSnapshot(ac) | |
| 720 | - | second := awaitPickerSnapshot(t, sends) | |
| 721 | - | require.Equal(t, first.InteractionID, second.InteractionID) | |
| 722 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |
| 723 | - | require.Greater(t, len(second.Lines), len(first.Lines)) | |
| 724 | - | ||
| 725 | - | // A closed interaction republishes nothing: only the close confirmation | |
| 726 | - | // and the authoritative repaint may follow. | |
| 727 | - | effect = admitPickerEffectForTest(t, testAttachmentSession(t, ac), ac) | |
| 728 | - | require.True(t, d.closePickerForAttachment(ac, effect, first.InteractionID)) | |
| 729 | - | for _, frame := range drainAllFrames(sends) { | |
| 730 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "closed interaction republished a snapshot") | |
| 731 | - | } | |
| 732 | - | } | |
| 733 | - | ||
| 734 | - | // pickerSnapshotFailTransport delegates to the fixture's capturing transport | |
| 735 | - | // but rejects the next authoritative snapshot, modelling a link that drops one | |
| 736 | - | // publication while the daemon still owns it. | |
| 737 | - | type pickerSnapshotFailTransport struct { | |
| 738 | - | *mockServerConnection | |
| 739 | - | failNext atomic.Bool | |
| 740 | - | } | |
| 741 | - | ||
| 742 | - | func (t *pickerSnapshotFailTransport) SendServer(message protocol.ServerMessage) error { | |
| 743 | - | if _, ok := message.(protocol.PickerSnapshot); ok && t.failNext.CompareAndSwap(true, false) { | |
| 744 | - | return errPickerSnapshotSend | |
| 745 | - | } | |
| 746 | - | return t.mockServerConnection.SendServer(message) | |
| 747 | - | } | |
| 748 | - | ||
| 749 | - | var errPickerSnapshotSend = errors.New("picker snapshot send failed") | |
| 750 | - | ||
| 751 | - | // TestPickerSnapshotRetryAfterFailedSend pins the publication commit point: a | |
| 752 | - | // snapshot whose send fails must not advance the recorded revision or replace | |
| 753 | - | // the keys the client is still selecting against, and the identical retry must | |
| 754 | - | // republish instead of being suppressed as unchanged. | |
| 755 | - | func TestPickerSnapshotRetryAfterFailedSend(t *testing.T) { | |
| 756 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 757 | - | ||
| 758 | - | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 759 | - | first := awaitPickerSnapshot(t, sends) | |
| 760 | - | require.Equal(t, uint64(1), first.SourceRevision) | |
| 761 | - | ||
| 762 | - | // Swap in a link whose next snapshot send fails, then install it as the | |
| 763 | - | // attachment authority so publication effects are admitted against it. | |
| 764 | - | captured, ok := ac.transport().(*mockServerConnection) | |
| 765 | - | require.True(t, ok, "fixture transport is not the capturing mock") | |
| 766 | - | failTransport := &pickerSnapshotFailTransport{mockServerConnection: captured} | |
| 767 | - | failTransport.failNext.Store(true) | |
| 768 | - | ac.replaceTransport(failTransport) | |
| 769 | - | ac.installTestAttachmentCapability(captureAttachmentCapability(sess, ac, ac.transport())) | |
| 770 | - | ||
| 771 | - | // A real row-set change is attempted while the send fails. | |
| 772 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 773 | - | require.NoError(t, err) | |
| 774 | - | failedEffect := admitPickerEffectForTest(t, sess, ac) | |
| 775 | - | failErr := d.publishPickerSourceForAttachment(ac, failedEffect, offer.InteractionID) | |
| 776 | - | require.ErrorIs(t, failErr, errPickerSnapshotSend) | |
| 777 | - | failedEffect.End() | |
| 778 | - | ||
| 779 | - | // The failed publication committed nothing: the revision the client is | |
| 780 | - | // displaying still validates, and its keys still resolve. | |
| 781 | - | ac.overlays.pickerMu.Lock() | |
| 782 | - | require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "failed send advanced the revision") | |
| 783 | - | require.True(t, ac.overlays.pickerSourcePublished, "failed send dropped the last published source") | |
| 784 | - | displayedKeys := ac.overlays.pickerKeys | |
| 785 | - | ac.overlays.pickerMu.Unlock() | |
| 786 | - | for _, line := range first.Lines { | |
| 787 | - | if line.Actions != 0 { | |
| 788 | - | require.Contains(t, displayedKeys, line.Key, "failed send invalidated a displayed key") | |
| 789 | - | } | |
| 790 | - | } | |
| 791 | - | ||
| 792 | - | // The identical retry must reach the client with the next revision, and the | |
| 793 | - | // committed keys must resolve every row of that snapshot. | |
| 794 | - | failTransport.failNext.Store(false) | |
| 795 | - | retryEffect := admitPickerEffectForTest(t, sess, ac) | |
| 796 | - | require.NoError(t, d.publishPickerSourceForAttachment(ac, retryEffect, offer.InteractionID)) | |
| 797 | - | second := awaitPickerSnapshot(t, sends) | |
| 798 | - | require.Equal(t, offer.InteractionID, second.InteractionID) | |
| 799 | - | require.Equal(t, first.SourceRevision+1, second.SourceRevision) | |
| 800 | - | ||
| 801 | - | ac.overlays.pickerMu.Lock() | |
| 802 | - | require.Equal(t, second.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "retry revision and client revision diverged") | |
| 803 | - | require.Equal(t, second.Recent, ac.overlays.pickerRecent) | |
| 804 | - | require.Equal(t, second.Grouped, ac.overlays.pickerGrouped) | |
| 805 | - | retryKeys := ac.overlays.pickerKeys | |
| 806 | - | ac.overlays.pickerMu.Unlock() | |
| 807 | - | for _, line := range second.Lines { | |
| 808 | - | if line.Actions != 0 { | |
| 809 | - | require.Contains(t, retryKeys, line.Key, "retry committed no key for a displayed row") | |
| 810 | - | } | |
| 811 | - | } | |
| 812 | - | ||
| 813 | - | // A further identical publish is genuinely unchanged and must stay silent. | |
| 814 | - | quietEffect := admitPickerEffectForTest(t, sess, ac) | |
| 815 | - | require.NoError(t, d.publishPickerSourceForAttachment(ac, quietEffect, offer.InteractionID)) | |
| 816 | - | for _, frame := range drainAllFrames(sends) { | |
| 817 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "committed retry republished an unchanged source") | |
| 818 | - | } | |
| 819 | - | } | |
| @@ -1,819 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "fmt" | |||
| 7 | - | "strings" | |||
| 8 | - | "sync/atomic" | |||
| 9 | - | "testing" | |||
| 10 | - | "time" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/vev/internal/domain" | |||
| 13 | - | "github.com/bnema/vev/internal/ports" | |||
| 14 | - | "github.com/bnema/vev/internal/protocol" | |||
| 15 | - | "github.com/bnema/vev/internal/protocol/wire" | |||
| 16 | - | "github.com/bnema/vev/internal/usecase/ui" | |||
| 17 | - | "github.com/stretchr/testify/require" | |||
| 18 | - | ) | |||
| 19 | - | ||||
| 20 | - | // pickerClientTestUnit builds a fixture attachment and one extra session so | |||
| 21 | - | // snapshots are non-empty. It returns an admitted effect for the guarded | |||
| 22 | - | // sends. | |||
| 23 | - | func pickerClientTestUnit(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) { | |||
| 24 | - | t.Helper() | |||
| 25 | - | p, releasePTY := newBlockingPTY(t) | |||
| 26 | - | return pickerClientTestUnitWithPTY(t, p, releasePTY) | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | // pickerClientTestUnitWithPTY builds the same fixture over a caller-owned | |||
| 30 | - | // PTY, so tests can observe whether session input reached the child. | |||
| 31 | - | func pickerClientTestUnitWithPTY(t *testing.T, p ports.PTY, releasePTY func()) (*Daemon, *session, *attachedClient, chan wire.Envelope, *attachmentEffect) { | |||
| 32 | - | t.Helper() | |||
| 33 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 34 | - | t.Cleanup(releasePTY) | |||
| 35 | - | d.ptys = newFactorySeq(t, newQuietPTY()) | |||
| 36 | - | _, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |||
| 37 | - | require.NoError(t, err) | |||
| 38 | - | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport())) | |||
| 39 | - | require.True(t, ok) | |||
| 40 | - | t.Cleanup(effect.End) | |||
| 41 | - | return d, sess, ac, sends, effect | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | func openTestPicker(t *testing.T, d *Daemon, ac *attachedClient, effect *attachmentEffect, sends chan wire.Envelope, intent protocol.PickerIntent) protocol.PickerOffer { | |||
| 45 | - | t.Helper() | |||
| 46 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, intent, moveSourceLocator{}, 0)) | |||
| 47 | - | offer := awaitPickerOffer(t, sends) | |||
| 48 | - | // The interaction namespace lives on the attachment, so the effect that | |||
| 49 | - | // carried the offer is released here: a later transition or move must be | |||
| 50 | - | // able to drain every other effect on this attachment. | |||
| 51 | - | effect.End() | |||
| 52 | - | return offer | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func awaitPickerOffer(t *testing.T, sends chan wire.Envelope) protocol.PickerOffer { | |||
| 56 | - | t.Helper() | |||
| 57 | - | for { | |||
| 58 | - | frame := awaitTestValue(t, sends, "picker offer was not published") | |||
| 59 | - | if offer, ok := decodeServerMessage(t, frame).(protocol.PickerOffer); ok { | |||
| 60 | - | return offer | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func awaitPickerSnapshot(t *testing.T, sends chan wire.Envelope) protocol.PickerSnapshot { | |||
| 66 | - | t.Helper() | |||
| 67 | - | for { | |||
| 68 | - | frame := awaitTestValue(t, sends, "picker snapshot was not published") | |||
| 69 | - | if snapshot, ok := decodeServerMessage(t, frame).(protocol.PickerSnapshot); ok { | |||
| 70 | - | return snapshot | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func awaitPickerFailure(t *testing.T, sends chan wire.Envelope) protocol.PickerFailure { | |||
| 76 | - | t.Helper() | |||
| 77 | - | for { | |||
| 78 | - | frame := awaitTestValue(t, sends, "picker failure was not published") | |||
| 79 | - | if failure, ok := decodeServerMessage(t, frame).(protocol.PickerFailure); ok { | |||
| 80 | - | return failure | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func pickerClientCapability(t *testing.T, ac *attachedClient, sess *session) attachmentCapability { | |||
| 86 | - | t.Helper() | |||
| 87 | - | return captureAttachmentCapability(sess, ac, ac.transport()) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | // navigateSelection builds one typed commit for the displayed snapshot. | |||
| 91 | - | func navigateSelection(snapshot protocol.PickerSnapshot, key string) protocol.PickerSelection { | |||
| 92 | - | return protocol.PickerSelection{ | |||
| 93 | - | InteractionID: snapshot.InteractionID, SourceID: servingPickerSourceID, | |||
| 94 | - | SourceRevision: snapshot.SourceRevision, Key: key, Action: protocol.PickerActionNavigate, | |||
| 95 | - | } | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | // firstSelectableKey returns the first line the source authorised for the | |||
| 99 | - | // requested action. | |||
| 100 | - | func firstSelectableKey(t *testing.T, snapshot protocol.PickerSnapshot, action protocol.PickerLineActions) string { | |||
| 101 | - | t.Helper() | |||
| 102 | - | for _, line := range snapshot.Lines { | |||
| 103 | - | if line.Actions&action != 0 { | |||
| 104 | - | return line.Key | |||
| 105 | - | } | |||
| 106 | - | } | |||
| 107 | - | t.Fatalf("snapshot published no line admitting %d", action) | |||
| 108 | - | return "" | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func TestPickerOfferAndSnapshotPublishResolvableLines(t *testing.T) { | |||
| 112 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 113 | - | ||||
| 114 | - | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 115 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 116 | - | ||||
| 117 | - | require.NotZero(t, offer.InteractionID) | |||
| 118 | - | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |||
| 119 | - | require.Equal(t, offer.InteractionID, snapshot.InteractionID) | |||
| 120 | - | require.Equal(t, servingPickerSourceID, snapshot.SourceID) | |||
| 121 | - | require.Equal(t, uint64(1), snapshot.SourceRevision) | |||
| 122 | - | require.NotEmpty(t, snapshot.Lines) | |||
| 123 | - | ||||
| 124 | - | // Every selectable line resolves through the published keys, and the keys | |||
| 125 | - | // carry session names for the unchanged handoff. | |||
| 126 | - | ac.overlays.pickerMu.Lock() | |||
| 127 | - | keys := ac.overlays.pickerKeys | |||
| 128 | - | ac.overlays.pickerMu.Unlock() | |||
| 129 | - | selectable := 0 | |||
| 130 | - | for _, line := range snapshot.Lines { | |||
| 131 | - | if line.Actions == 0 { | |||
| 132 | - | continue | |||
| 133 | - | } | |||
| 134 | - | selectable++ | |||
| 135 | - | target, ok := keys[line.Key] | |||
| 136 | - | require.True(t, ok, "line %q has no resolved target", line.Key) | |||
| 137 | - | require.NotEmpty(t, target.Name, "resolved target carries no session name") | |||
| 138 | - | } | |||
| 139 | - | require.NotZero(t, selectable) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func TestPickerSnapshotPublishesFocusEligibility(t *testing.T) { | |||
| 143 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 144 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 145 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 146 | - | ||||
| 147 | - | headers, tabs := 0, 0 | |||
| 148 | - | for _, line := range snapshot.Lines { | |||
| 149 | - | switch line.Kind { | |||
| 150 | - | case protocol.PickerLineSection: | |||
| 151 | - | require.False(t, line.Focusable, "section %q is not a cursor destination", line.Label) | |||
| 152 | - | require.Zero(t, line.Actions, "section %q admits no action", line.Label) | |||
| 153 | - | case protocol.PickerLineSession: | |||
| 154 | - | headers++ | |||
| 155 | - | // A live session header names a group of tabs: navigation belongs | |||
| 156 | - | // to its tab rows, so the header is rendered and skipped. | |||
| 157 | - | require.False(t, line.Focusable, "session header %q is not a destination", line.Label) | |||
| 158 | - | require.Zero(t, line.Actions) | |||
| 159 | - | case protocol.PickerLineTab: | |||
| 160 | - | tabs++ | |||
| 161 | - | require.True(t, line.Focusable, "tab %q must be reachable", line.Label) | |||
| 162 | - | require.NotZero(t, line.Actions, "a reachable tab admits the action that made it reachable") | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | require.NotZero(t, headers) | |||
| 166 | - | require.NotZero(t, tabs) | |||
| 167 | - | ||||
| 168 | - | // The published cursor hint never names a row the client may not rest on. | |||
| 169 | - | require.NotEmpty(t, snapshot.Cursor.Key) | |||
| 170 | - | for _, line := range snapshot.Lines { | |||
| 171 | - | if line.Key == snapshot.Cursor.Key { | |||
| 172 | - | require.True(t, line.Focusable) | |||
| 173 | - | } | |||
| 174 | - | } | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | func TestPickerOpenInstallsNoPresentationModel(t *testing.T) { | |||
| 178 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 179 | - | ||||
| 180 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 181 | - | _ = awaitPickerSnapshot(t, sends) | |||
| 182 | - | ||||
| 183 | - | // The daemon publishes data and actions only: no model, no cursor, no | |||
| 184 | - | // search state of its own. | |||
| 185 | - | ac.overlays.pickerMu.Lock() | |||
| 186 | - | require.True(t, ac.overlays.pickerOpen) | |||
| 187 | - | require.NotZero(t, ac.overlays.pickerInteraction) | |||
| 188 | - | require.NotEmpty(t, ac.overlays.pickerKeys) | |||
| 189 | - | ac.overlays.pickerMu.Unlock() | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | func TestPickerSelectionRejectsStaleAndUnknown(t *testing.T) { | |||
| 193 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 194 | - | ||||
| 195 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 196 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 197 | - | ||||
| 198 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 199 | - | // Unknown key on the open interaction reports UnknownKey without | |||
| 200 | - | // closing the interaction. | |||
| 201 | - | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, "ff00/ghost"))) | |||
| 202 | - | failure := awaitPickerFailure(t, sends) | |||
| 203 | - | require.Equal(t, protocol.PickerUnknownKey, failure.Code) | |||
| 204 | - | require.Equal(t, snapshot.InteractionID, failure.InteractionID) | |||
| 205 | - | ||||
| 206 | - | // A foreign source is rejected before any key lookup. | |||
| 207 | - | foreign := navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)) | |||
| 208 | - | foreign.SourceID = "elsewhere" | |||
| 209 | - | require.False(t, d.handleAttachmentClientMessage(capability, foreign)) | |||
| 210 | - | require.Equal(t, protocol.PickerUnknownSource, awaitPickerFailure(t, sends).Code) | |||
| 211 | - | ||||
| 212 | - | // A retired interaction reports StaleRevision. | |||
| 213 | - | d.closePickerForAttachment(ac, effect, snapshot.InteractionID) | |||
| 214 | - | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)))) | |||
| 215 | - | require.Equal(t, protocol.PickerStaleRevision, awaitPickerFailure(t, sends).Code) | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | func TestPickerMoveIntentPublishesMoveLinesOnly(t *testing.T) { | |||
| 219 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 220 | - | source := moveSourceLocator{ | |||
| 221 | - | Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name}, | |||
| 222 | - | TabID: domain.TabStableID(testAttachmentTab(sess).stableID), | |||
| 223 | - | } | |||
| 224 | - | ac.overlays.pickerMu.Lock() | |||
| 225 | - | require.False(t, ac.overlays.pickerOpen) | |||
| 226 | - | ac.overlays.pickerMu.Unlock() | |||
| 227 | - | ||||
| 228 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMoveTab, source, 0)) | |||
| 229 | - | offer := awaitPickerOffer(t, sends) | |||
| 230 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 231 | - | require.Equal(t, protocol.PickerIntentMoveTab, offer.Intent) | |||
| 232 | - | ||||
| 233 | - | // The source session never appears as its own destination, and rows that | |||
| 234 | - | // cannot accept a move carry no action. | |||
| 235 | - | for _, line := range snapshot.Lines { | |||
| 236 | - | require.NotEqual(t, sourceMoveKey(source), line.Key) | |||
| 237 | - | if line.Actions != 0 { | |||
| 238 | - | require.Equal(t, protocol.PickerCanMove, line.Actions) | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | func sourceMoveKey(source moveSourceLocator) string { | |||
| 244 | - | return pickerMoveSourceKey(source) | |||
| 245 | - | } | |||
| 246 | - | ||||
| 247 | - | func TestPickerOpenRepaintsWithoutSessionChange(t *testing.T) { | |||
| 248 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 249 | - | ||||
| 250 | - | // The open invalidates the session even when nothing else changed: | |||
| 251 | - | // without this repaint the opener's fence would never retire and | |||
| 252 | - | // the palette Enter would hang until its deadline. | |||
| 253 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 254 | - | _ = awaitPickerSnapshot(t, sends) | |||
| 255 | - | select { | |||
| 256 | - | case frame := <-sends: | |||
| 257 | - | require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "open must trigger an authoritative repaint") | |||
| 258 | - | default: | |||
| 259 | - | t.Fatal("open produced no repaint after the snapshot") | |||
| 260 | - | } | |||
| 261 | - | } | |||
| 262 | - | ||||
| 263 | - | func TestPickerSessionPickerCommandOpensInteraction(t *testing.T) { | |||
| 264 | - | p, release := newBlockingPTY(t) | |||
| 265 | - | defer release() | |||
| 266 | - | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 267 | - | effect := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 268 | - | effect.uiActionID = 77 | |||
| 269 | - | ||||
| 270 | - | // Full path through overlay dispatch: palette query, then Enter on | |||
| 271 | - | // the same effect. The Enter routes through handlePaletteInput via | |||
| 272 | - | // the overlay runtime, exactly like a real client frame. | |||
| 273 | - | d.enterPalette(current, ac) | |||
| 274 | - | d.handlePaletteInput(ac, []byte("sessionpicker"), effect) | |||
| 275 | - | require.NotNil(t, ac.overlays.palette) | |||
| 276 | - | d.handlePaletteInput(ac, []byte("\r"), effect) | |||
| 277 | - | ||||
| 278 | - | offer := awaitPickerOffer(t, sends) | |||
| 279 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 280 | - | require.NotZero(t, offer.InteractionID) | |||
| 281 | - | require.Equal(t, protocol.PickerIntentNavigation, offer.Intent) | |||
| 282 | - | require.NotEmpty(t, snapshot.Lines) | |||
| 283 | - | ac.overlays.pickerMu.Lock() | |||
| 284 | - | require.True(t, ac.overlays.pickerOpen) | |||
| 285 | - | ac.overlays.pickerMu.Unlock() | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | func TestPickerCancelPublishesClosedAndAuthoritativeFullPaint(t *testing.T) { | |||
| 289 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 290 | - | ||||
| 291 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 292 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 293 | - | // The open publishes its own restore paint after the snapshot; the release | |||
| 294 | - | // assertion below is about the paint that follows the close. | |||
| 295 | - | drainAllFrames(sends) | |||
| 296 | - | ||||
| 297 | - | // The client cancels: the daemon retires the interaction, confirms the | |||
| 298 | - | // close with its restore barrier, and publishes the authoritative full | |||
| 299 | - | // paint the client's release path waits for. | |||
| 300 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 301 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |||
| 302 | - | ac.overlays.pickerMu.Lock() | |||
| 303 | - | require.False(t, ac.overlays.pickerOpen) | |||
| 304 | - | ac.overlays.pickerMu.Unlock() | |||
| 305 | - | ||||
| 306 | - | var sawClosed bool | |||
| 307 | - | for settled := false; !settled; { | |||
| 308 | - | select { | |||
| 309 | - | case frame := <-sends: | |||
| 310 | - | if closed, ok := decodeServerMessage(t, frame).(protocol.PickerClosed); ok { | |||
| 311 | - | require.Equal(t, snapshot.InteractionID, closed.InteractionID) | |||
| 312 | - | sawClosed = true | |||
| 313 | - | continue | |||
| 314 | - | } | |||
| 315 | - | if output, ok := decodeServerMessage(t, frame).(protocol.Output); ok { | |||
| 316 | - | require.True(t, sawClosed, "the restore paint must follow the close") | |||
| 317 | - | require.True(t, output.Full, "cancel release paint must be authoritative") | |||
| 318 | - | settled = true | |||
| 319 | - | } | |||
| 320 | - | case <-time.After(200 * time.Millisecond): | |||
| 321 | - | settled = true | |||
| 322 | - | } | |||
| 323 | - | } | |||
| 324 | - | require.True(t, sawClosed, "cancel published no close confirmation") | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | func TestPickerPaletteFramesOpenInteraction(t *testing.T) { | |||
| 328 | - | p, release := newBlockingPTY(t) | |||
| 329 | - | defer release() | |||
| 330 | - | d, _, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 331 | - | capability := pickerClientCapability(t, ac, testAttachmentSession(t, ac)) | |||
| 332 | - | ||||
| 333 | - | // Drive the real attached-client frame path instead of calling the | |||
| 334 | - | // palette handler: Alt+Space opens the palette, the query selects the | |||
| 335 | - | // session-picker command, and Enter executes it. | |||
| 336 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 4, Data: []byte("\x1b ")})) | |||
| 337 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 5, Data: []byte("SSP")})) | |||
| 338 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 6, Data: []byte("\r")})) | |||
| 339 | - | ||||
| 340 | - | _ = awaitPickerOffer(t, sends) | |||
| 341 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 342 | - | require.NotZero(t, snapshot.InteractionID) | |||
| 343 | - | require.NotEmpty(t, snapshot.Lines) | |||
| 344 | - | ac.overlays.pickerMu.Lock() | |||
| 345 | - | require.True(t, ac.overlays.pickerOpen) | |||
| 346 | - | ac.overlays.pickerMu.Unlock() | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | func testAttachmentSession(t *testing.T, ac *attachedClient) *session { | |||
| 350 | - | t.Helper() | |||
| 351 | - | sess := ac.currentAttachmentSession() | |||
| 352 | - | require.NotNil(t, sess) | |||
| 353 | - | return sess | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | // secondSessionForTest returns the fixture's extra session, the one the attach | |||
| 357 | - | // itself does not display. | |||
| 358 | - | func secondSessionForTest(t *testing.T, d *Daemon) *session { | |||
| 359 | - | t.Helper() | |||
| 360 | - | d.mu.Lock() | |||
| 361 | - | defer d.mu.Unlock() | |||
| 362 | - | for _, sess := range d.sessions { | |||
| 363 | - | if sess == nil { | |||
| 364 | - | continue | |||
| 365 | - | } | |||
| 366 | - | sess.mu.Lock() | |||
| 367 | - | name := sess.name | |||
| 368 | - | sess.mu.Unlock() | |||
| 369 | - | if name == "second" { | |||
| 370 | - | return sess | |||
| 371 | - | } | |||
| 372 | - | } | |||
| 373 | - | t.Fatal("fixture second session missing") | |||
| 374 | - | return nil | |||
| 375 | - | } | |||
| 376 | - | ||||
| 377 | - | // pickerBellLines reports the session headers carrying the bell glyph and the | |||
| 378 | - | // tab rows carrying the structured attention marker. | |||
| 379 | - | func pickerBellLines(snapshot protocol.PickerSnapshot) (headers int, tabRows int) { | |||
| 380 | - | bell := string(ui.AttentionGlyph) | |||
| 381 | - | for _, line := range snapshot.Lines { | |||
| 382 | - | switch line.Kind { | |||
| 383 | - | case protocol.PickerLineSession: | |||
| 384 | - | if strings.HasSuffix(line.Label, " "+bell) { | |||
| 385 | - | headers++ | |||
| 386 | - | } | |||
| 387 | - | case protocol.PickerLineTab: | |||
| 388 | - | if line.Attention { | |||
| 389 | - | tabRows++ | |||
| 390 | - | } | |||
| 391 | - | } | |||
| 392 | - | } | |||
| 393 | - | return headers, tabRows | |||
| 394 | - | } | |||
| 395 | - | ||||
| 396 | - | // TestPickerSnapshotCarriesAttentionBellsToTheClient pins the notification path | |||
| 397 | - | // into the client-owned modal: the daemon publishes the bell on the ringing | |||
| 398 | - | // session's header and the structured marker on its tab row, and the client | |||
| 399 | - | // renders both. | |||
| 400 | - | func TestPickerSnapshotCarriesAttentionBellsToTheClient(t *testing.T) { | |||
| 401 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 402 | - | ringing := secondSessionForTest(t, d) | |||
| 403 | - | ringing.mu.Lock() | |||
| 404 | - | ringing.tabs[0].attention = true | |||
| 405 | - | ringing.tabs[0].attentionAt = time.Unix(1, 0) | |||
| 406 | - | ringing.mu.Unlock() | |||
| 407 | - | ||||
| 408 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 409 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 410 | - | ||||
| 411 | - | headers, tabRows := pickerBellLines(snapshot) | |||
| 412 | - | require.Equal(t, 1, headers, "the ringing session header must carry the bell glyph") | |||
| 413 | - | require.Equal(t, 1, tabRows, "the ringing tab row must carry the structured attention marker") | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | - | // TestAttentionPulseRepublishesTheOpenPickerSource pins that a bell raised | |||
| 417 | - | // while the client presents the modal reaches it: client-owned presentation | |||
| 418 | - | // suppresses the daemon's paints, so the pulse must advance the picker source | |||
| 419 | - | // the client presents from. A closed interaction republishes nothing. | |||
| 420 | - | func TestAttentionPulseRepublishesTheOpenPickerSource(t *testing.T) { | |||
| 421 | - | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 422 | - | defer releases[0]() | |||
| 423 | - | defer releases[1]() | |||
| 424 | - | clk := newManualAttentionClock() | |||
| 425 | - | d.clock = clk | |||
| 426 | - | require.True(t, selectTestAttachmentTab(sess, 0)) | |||
| 427 | - | ||||
| 428 | - | effect := admitPickerEffectForTest(t, sess, ac) | |||
| 429 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |||
| 430 | - | require.Equal(t, protocol.PickerIntentNavigation, awaitPickerOffer(t, sends).Intent) | |||
| 431 | - | first := awaitPickerSnapshot(t, sends) | |||
| 432 | - | require.Equal(t, 0, pickerBellCount(first), "a quiet session must publish no bell") | |||
| 433 | - | ||||
| 434 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 435 | - | defer cancel() | |||
| 436 | - | done := make(chan struct{}) | |||
| 437 | - | go func() { | |||
| 438 | - | d.attentionAnimator(ctx) | |||
| 439 | - | close(done) | |||
| 440 | - | }() | |||
| 441 | - | ||||
| 442 | - | // A background tab rings while the modal is open. The client owns | |||
| 443 | - | // presentation, so only a newer source revision can update its rows. | |||
| 444 | - | sess.mu.Lock() | |||
| 445 | - | sess.tabs[1].attention = true | |||
| 446 | - | sess.tabs[1].attentionAt = time.Unix(1, 0) | |||
| 447 | - | sess.mu.Unlock() | |||
| 448 | - | d.pokeAttentionTicker() | |||
| 449 | - | ||||
| 450 | - | timer := clk.nextTimer(t) | |||
| 451 | - | timer.fire() | |||
| 452 | - | second := awaitPickerSnapshot(t, sends) | |||
| 453 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |||
| 454 | - | headers, tabRows := pickerBellLines(second) | |||
| 455 | - | require.Equal(t, 1, headers, "the pulse must publish the new bell on the session header") | |||
| 456 | - | require.Equal(t, 1, tabRows, "the pulse must publish the new bell on the tab row") | |||
| 457 | - | ||||
| 458 | - | // A retired interaction must not be reopened by a later pulse. The | |||
| 459 | - | // animator keeps one timer while attention exists, so the same fired timer | |||
| 460 | - | // carries the next pulse. | |||
| 461 | - | retireEffect := admitPickerEffectForTest(t, sess, ac) | |||
| 462 | - | require.True(t, d.closePickerForAttachment(ac, retireEffect, second.InteractionID)) | |||
| 463 | - | _ = drainAllFrames(sends) | |||
| 464 | - | sess.mu.Lock() | |||
| 465 | - | sess.tabs[0].attention = true | |||
| 466 | - | sess.tabs[0].attentionAt = time.Unix(2, 0) | |||
| 467 | - | sess.mu.Unlock() | |||
| 468 | - | d.pokeAttentionTicker() | |||
| 469 | - | timer.fire() | |||
| 470 | - | for _, frame := range drainAllFrames(sends) { | |||
| 471 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "a closed interaction republished a snapshot") | |||
| 472 | - | } | |||
| 473 | - | ||||
| 474 | - | cancel() | |||
| 475 | - | require.Eventually(t, func() bool { | |||
| 476 | - | select { | |||
| 477 | - | case <-done: | |||
| 478 | - | return true | |||
| 479 | - | default: | |||
| 480 | - | return false | |||
| 481 | - | } | |||
| 482 | - | }, time.Second, time.Millisecond) | |||
| 483 | - | } | |||
| 484 | - | ||||
| 485 | - | func pickerBellCount(snapshot protocol.PickerSnapshot) int { | |||
| 486 | - | headers, tabRows := pickerBellLines(snapshot) | |||
| 487 | - | return headers + tabRows | |||
| 488 | - | } | |||
| 489 | - | ||||
| 490 | - | func TestPickerSelectionRequiresExactSourceRevision(t *testing.T) { | |||
| 491 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 492 | - | ||||
| 493 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 494 | - | first := awaitPickerSnapshot(t, sends) | |||
| 495 | - | // A refresh that observes a real row change publishes a newer revision: the | |||
| 496 | - | // client may only commit the model it is displaying, so an older revision | |||
| 497 | - | // must fail even though it is not newer than the daemon's current one. | |||
| 498 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |||
| 499 | - | require.NoError(t, err) | |||
| 500 | - | d.refreshPickerSnapshot(ac) | |||
| 501 | - | second := awaitPickerSnapshot(t, sends) | |||
| 502 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |||
| 503 | - | ||||
| 504 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 505 | - | key := firstSelectableKey(t, second, protocol.PickerCanNavigate) | |||
| 506 | - | for _, revision := range []uint64{second.SourceRevision - 1, second.SourceRevision + 1} { | |||
| 507 | - | selection := navigateSelection(second, key) | |||
| 508 | - | selection.SourceRevision = revision | |||
| 509 | - | require.False(t, d.handleAttachmentClientMessage(capability, selection)) | |||
| 510 | - | failure := awaitPickerFailure(t, sends) | |||
| 511 | - | require.Equal(t, protocol.PickerStaleRevision, failure.Code, "revision %d must be rejected", revision) | |||
| 512 | - | ac.overlays.pickerMu.Lock() | |||
| 513 | - | require.True(t, ac.overlays.pickerOpen, "a rejected revision must not close the interaction") | |||
| 514 | - | ac.overlays.pickerMu.Unlock() | |||
| 515 | - | } | |||
| 516 | - | } | |||
| 517 | - | ||||
| 518 | - | func TestPickerInteractionDropsRawInput(t *testing.T) { | |||
| 519 | - | writes := make(chan []byte, 8) | |||
| 520 | - | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 521 | - | d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY) | |||
| 522 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 523 | - | ||||
| 524 | - | // A normal attachment forwards keys to the session. | |||
| 525 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: []byte("x")})) | |||
| 526 | - | require.Equal(t, []byte("x"), awaitTestValue(t, writes, "session input never reached the pty")) | |||
| 527 | - | ||||
| 528 | - | // Positive control: with the pane reporting mouse mode, a mouse report | |||
| 529 | - | // reaches the pane's pty. Without this control the negative assertion | |||
| 530 | - | // below would pass even if mouse routing were broken outright. | |||
| 531 | - | tb := testAttachmentTab(sess) | |||
| 532 | - | require.NotNil(t, tb) | |||
| 533 | - | pane := tb.panes["pane-1"] | |||
| 534 | - | require.NotNil(t, pane) | |||
| 535 | - | pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 536 | - | mouseReport := []byte(fmt.Sprintf("\x1b[<0;2;%dM", clientTopBarRows+2)) | |||
| 537 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: mouseReport})) | |||
| 538 | - | require.NotEmpty(t, awaitTestValue(t, writes, "positive control: mouse never reached the pane")) | |||
| 539 | - | ||||
| 540 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 541 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 542 | - | ||||
| 543 | - | // While the picker is open it owns user input: keys and mouse reports | |||
| 544 | - | // must not reach the session, and the interaction stays open. | |||
| 545 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 2, Data: []byte("hidden")})) | |||
| 546 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 3, Data: mouseReport})) | |||
| 547 | - | select { | |||
| 548 | - | case frame := <-writes: | |||
| 549 | - | t.Fatalf("picker-owned input reached the pty: %q", frame) | |||
| 550 | - | case <-time.After(100 * time.Millisecond): | |||
| 551 | - | } | |||
| 552 | - | ||||
| 553 | - | // Closing the interaction restores normal routing, mouse included. | |||
| 554 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |||
| 555 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 4, ActionID: 4, Data: []byte("visible")})) | |||
| 556 | - | require.Equal(t, []byte("visible"), awaitTestValue(t, writes, "session input did not resume after the close")) | |||
| 557 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 5, ActionID: 5, Data: mouseReport})) | |||
| 558 | - | require.NotEmpty(t, awaitTestValue(t, writes, "mouse routing did not resume after the close")) | |||
| 559 | - | } | |||
| 560 | - | ||||
| 561 | - | func TestPickerSelectionClosesBeforeTheDestinationPaint(t *testing.T) { | |||
| 562 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 563 | - | ||||
| 564 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 565 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 566 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 567 | - | ||||
| 568 | - | // Selecting the current session is a same-peer handoff. The interaction | |||
| 569 | - | // must be retired before that handoff: the client releases only on a | |||
| 570 | - | // paint accepted after the daemon's close, so the destination paint has | |||
| 571 | - | // to follow it on the wire. Drain the open's own repaint first. | |||
| 572 | - | drainAllFrames(sends) | |||
| 573 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |||
| 574 | - | require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, key))) | |||
| 575 | - | ||||
| 576 | - | // Collect the handoff frames for a short settle window, then require the | |||
| 577 | - | // close to precede any paint. | |||
| 578 | - | var order []string | |||
| 579 | - | for settled := false; !settled; { | |||
| 580 | - | select { | |||
| 581 | - | case frame := <-sends: | |||
| 582 | - | order = append(order, envelopeMessageName(t, frame.Payload)) | |||
| 583 | - | case <-time.After(200 * time.Millisecond): | |||
| 584 | - | settled = true | |||
| 585 | - | } | |||
| 586 | - | } | |||
| 587 | - | require.NotEmpty(t, order, "the handoff published nothing") | |||
| 588 | - | require.Equal(t, "PickerClosed", order[0], "the close must precede the handoff") | |||
| 589 | - | for i, frameType := range order { | |||
| 590 | - | if frameType == "Output" { | |||
| 591 | - | require.NotZero(t, i, "a paint must not precede the close") | |||
| 592 | - | } | |||
| 593 | - | } | |||
| 594 | - | ac.overlays.pickerMu.Lock() | |||
| 595 | - | require.False(t, ac.overlays.pickerOpen) | |||
| 596 | - | ac.overlays.pickerMu.Unlock() | |||
| 597 | - | } | |||
| 598 | - | ||||
| 599 | - | func TestPickerInteractionDropsClipboardImage(t *testing.T) { | |||
| 600 | - | writes := make(chan []byte, 8) | |||
| 601 | - | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 602 | - | d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY) | |||
| 603 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 604 | - | ||||
| 605 | - | temp := t.TempDir() | |||
| 606 | - | t.Setenv("TMPDIR", temp) | |||
| 607 | - | push := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte("\x89PNG\r\n\x1a\n")} | |||
| 608 | - | ||||
| 609 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 610 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 611 | - | ||||
| 612 | - | // An image push is user input too: the clipboard path must not reach the | |||
| 613 | - | // session while the picker owns input. | |||
| 614 | - | require.False(t, d.handleAttachmentClientMessage(capability, push)) | |||
| 615 | - | select { | |||
| 616 | - | case frame := <-writes: | |||
| 617 | - | t.Fatalf("clipboard path reached the pty during the interaction: %q", frame) | |||
| 618 | - | case <-time.After(100 * time.Millisecond): | |||
| 619 | - | } | |||
| 620 | - | ||||
| 621 | - | // After the close the same push is delivered again. | |||
| 622 | - | require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID})) | |||
| 623 | - | push.InputSeq = 2 | |||
| 624 | - | require.False(t, d.handleAttachmentClientMessage(capability, push)) | |||
| 625 | - | select { | |||
| 626 | - | case frame := <-writes: | |||
| 627 | - | require.Contains(t, string(frame), ".png") | |||
| 628 | - | case <-time.After(2 * time.Second): | |||
| 629 | - | t.Fatal("clipboard path did not resume after the close") | |||
| 630 | - | } | |||
| 631 | - | } | |||
| 632 | - | ||||
| 633 | - | func TestPickerSnapshotRefreshThroughDirectoryNotification(t *testing.T) { | |||
| 634 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 635 | - | ||||
| 636 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 637 | - | first := awaitPickerSnapshot(t, sends) | |||
| 638 | - | ||||
| 639 | - | // The row set changes for real: a new session appears in the catalogue. | |||
| 640 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |||
| 641 | - | require.NoError(t, err) | |||
| 642 | - | ||||
| 643 | - | // The catalogue notification path must reach the picker even though the | |||
| 644 | - | // daemon installs no overlay model: refreshRemoteDirectoryViews is the | |||
| 645 | - | // entry the directory subscription calls. | |||
| 646 | - | d.refreshRemoteDirectoryViews() | |||
| 647 | - | second := awaitPickerSnapshot(t, sends) | |||
| 648 | - | require.Equal(t, first.InteractionID, second.InteractionID) | |||
| 649 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |||
| 650 | - | require.Greater(t, len(second.Lines), len(first.Lines), "the new session must appear as a line") | |||
| 651 | - | } | |||
| 652 | - | ||||
| 653 | - | // TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections pins that the | |||
| 654 | - | // serving daemon publishes the actual Recent and Grouped projections instead of | |||
| 655 | - | // one line set copied into both. Recent carries the flat recency order the | |||
| 656 | - | // default client sort consumes; Grouped carries the sectioned order the grouped | |||
| 657 | - | // sort consumes. Both stay one authority: the same keys admit the same actions. | |||
| 658 | - | func TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections(t *testing.T) { | |||
| 659 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 660 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 661 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 662 | - | ||||
| 663 | - | recentSections, groupedSections := 0, 0 | |||
| 664 | - | recentActions := make(map[string]protocol.PickerLineActions, len(snapshot.Recent.Lines)) | |||
| 665 | - | groupedActions := make(map[string]protocol.PickerLineActions, len(snapshot.Grouped.Lines)) | |||
| 666 | - | for _, line := range snapshot.Recent.Lines { | |||
| 667 | - | if line.Kind == protocol.PickerLineSection { | |||
| 668 | - | recentSections++ | |||
| 669 | - | } | |||
| 670 | - | if line.Key != "" { | |||
| 671 | - | recentActions[line.Key] = line.Actions | |||
| 672 | - | } | |||
| 673 | - | } | |||
| 674 | - | for _, line := range snapshot.Grouped.Lines { | |||
| 675 | - | if line.Kind == protocol.PickerLineSection { | |||
| 676 | - | groupedSections++ | |||
| 677 | - | } | |||
| 678 | - | if line.Key != "" { | |||
| 679 | - | groupedActions[line.Key] = line.Actions | |||
| 680 | - | } | |||
| 681 | - | } | |||
| 682 | - | ||||
| 683 | - | // The sort inputs differ: the grouped order is sectioned so its run | |||
| 684 | - | // boundaries reach the grouped sort, and the recent order stays flat so the | |||
| 685 | - | // recency sort never inherits grouping it did not ask for. | |||
| 686 | - | require.Zero(t, recentSections, "recent projection published grouped section runs") | |||
| 687 | - | require.NotZero(t, groupedSections, "grouped projection published no section runs") | |||
| 688 | - | require.NotEqual(t, snapshot.Recent.Lines, snapshot.Grouped.Lines, "both projections carry the same sort input") | |||
| 689 | - | require.Equal(t, recentActions, groupedActions, "projections disagree on destination actions") | |||
| 690 | - | } | |||
| 691 | - | ||||
| 692 | - | // TestPickerRefreshPublishesOnlyChangedSource pins the revision contract: a | |||
| 693 | - | // repeated publish of an unchanged projection pair neither bumps the source | |||
| 694 | - | // revision nor reaches the client, while a real row-set change republishes a | |||
| 695 | - | // full snapshot with a newer revision. | |||
| 696 | - | func TestPickerRefreshPublishesOnlyChangedSource(t *testing.T) { | |||
| 697 | - | d, _, ac, sends, effect := pickerClientTestUnit(t) | |||
| 698 | - | ||||
| 699 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 700 | - | first := awaitPickerSnapshot(t, sends) | |||
| 701 | - | require.Equal(t, uint64(1), first.SourceRevision) | |||
| 702 | - | _ = drainAllFrames(sends) | |||
| 703 | - | ||||
| 704 | - | // The rows did not change, so the revision the client is displaying stays | |||
| 705 | - | // current: a refresh must publish nothing and send nothing. | |||
| 706 | - | d.refreshPickerSnapshot(ac) | |||
| 707 | - | d.refreshPickerSnapshot(ac) | |||
| 708 | - | for _, frame := range drainAllFrames(sends) { | |||
| 709 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "unchanged source republished a snapshot") | |||
| 710 | - | } | |||
| 711 | - | ac.overlays.pickerMu.Lock() | |||
| 712 | - | require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "unchanged source bumped the revision") | |||
| 713 | - | ac.overlays.pickerMu.Unlock() | |||
| 714 | - | ||||
| 715 | - | // A real row-set change republishes a full snapshot with a newer revision | |||
| 716 | - | // through the current attachment capability. | |||
| 717 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |||
| 718 | - | require.NoError(t, err) | |||
| 719 | - | d.refreshPickerSnapshot(ac) | |||
| 720 | - | second := awaitPickerSnapshot(t, sends) | |||
| 721 | - | require.Equal(t, first.InteractionID, second.InteractionID) | |||
| 722 | - | require.Greater(t, second.SourceRevision, first.SourceRevision) | |||
| 723 | - | require.Greater(t, len(second.Lines), len(first.Lines)) | |||
| 724 | - | ||||
| 725 | - | // A closed interaction republishes nothing: only the close confirmation | |||
| 726 | - | // and the authoritative repaint may follow. | |||
| 727 | - | effect = admitPickerEffectForTest(t, testAttachmentSession(t, ac), ac) | |||
| 728 | - | require.True(t, d.closePickerForAttachment(ac, effect, first.InteractionID)) | |||
| 729 | - | for _, frame := range drainAllFrames(sends) { | |||
| 730 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "closed interaction republished a snapshot") | |||
| 731 | - | } | |||
| 732 | - | } | |||
| 733 | - | ||||
| 734 | - | // pickerSnapshotFailTransport delegates to the fixture's capturing transport | |||
| 735 | - | // but rejects the next authoritative snapshot, modelling a link that drops one | |||
| 736 | - | // publication while the daemon still owns it. | |||
| 737 | - | type pickerSnapshotFailTransport struct { | |||
| 738 | - | *mockServerConnection | |||
| 739 | - | failNext atomic.Bool | |||
| 740 | - | } | |||
| 741 | - | ||||
| 742 | - | func (t *pickerSnapshotFailTransport) SendServer(message protocol.ServerMessage) error { | |||
| 743 | - | if _, ok := message.(protocol.PickerSnapshot); ok && t.failNext.CompareAndSwap(true, false) { | |||
| 744 | - | return errPickerSnapshotSend | |||
| 745 | - | } | |||
| 746 | - | return t.mockServerConnection.SendServer(message) | |||
| 747 | - | } | |||
| 748 | - | ||||
| 749 | - | var errPickerSnapshotSend = errors.New("picker snapshot send failed") | |||
| 750 | - | ||||
| 751 | - | // TestPickerSnapshotRetryAfterFailedSend pins the publication commit point: a | |||
| 752 | - | // snapshot whose send fails must not advance the recorded revision or replace | |||
| 753 | - | // the keys the client is still selecting against, and the identical retry must | |||
| 754 | - | // republish instead of being suppressed as unchanged. | |||
| 755 | - | func TestPickerSnapshotRetryAfterFailedSend(t *testing.T) { | |||
| 756 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 757 | - | ||||
| 758 | - | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 759 | - | first := awaitPickerSnapshot(t, sends) | |||
| 760 | - | require.Equal(t, uint64(1), first.SourceRevision) | |||
| 761 | - | ||||
| 762 | - | // Swap in a link whose next snapshot send fails, then install it as the | |||
| 763 | - | // attachment authority so publication effects are admitted against it. | |||
| 764 | - | captured, ok := ac.transport().(*mockServerConnection) | |||
| 765 | - | require.True(t, ok, "fixture transport is not the capturing mock") | |||
| 766 | - | failTransport := &pickerSnapshotFailTransport{mockServerConnection: captured} | |||
| 767 | - | failTransport.failNext.Store(true) | |||
| 768 | - | ac.replaceTransport(failTransport) | |||
| 769 | - | ac.installTestAttachmentCapability(captureAttachmentCapability(sess, ac, ac.transport())) | |||
| 770 | - | ||||
| 771 | - | // A real row-set change is attempted while the send fails. | |||
| 772 | - | _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |||
| 773 | - | require.NoError(t, err) | |||
| 774 | - | failedEffect := admitPickerEffectForTest(t, sess, ac) | |||
| 775 | - | failErr := d.publishPickerSourceForAttachment(ac, failedEffect, offer.InteractionID) | |||
| 776 | - | require.ErrorIs(t, failErr, errPickerSnapshotSend) | |||
| 777 | - | failedEffect.End() | |||
| 778 | - | ||||
| 779 | - | // The failed publication committed nothing: the revision the client is | |||
| 780 | - | // displaying still validates, and its keys still resolve. | |||
| 781 | - | ac.overlays.pickerMu.Lock() | |||
| 782 | - | require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "failed send advanced the revision") | |||
| 783 | - | require.True(t, ac.overlays.pickerSourcePublished, "failed send dropped the last published source") | |||
| 784 | - | displayedKeys := ac.overlays.pickerKeys | |||
| 785 | - | ac.overlays.pickerMu.Unlock() | |||
| 786 | - | for _, line := range first.Lines { | |||
| 787 | - | if line.Actions != 0 { | |||
| 788 | - | require.Contains(t, displayedKeys, line.Key, "failed send invalidated a displayed key") | |||
| 789 | - | } | |||
| 790 | - | } | |||
| 791 | - | ||||
| 792 | - | // The identical retry must reach the client with the next revision, and the | |||
| 793 | - | // committed keys must resolve every row of that snapshot. | |||
| 794 | - | failTransport.failNext.Store(false) | |||
| 795 | - | retryEffect := admitPickerEffectForTest(t, sess, ac) | |||
| 796 | - | require.NoError(t, d.publishPickerSourceForAttachment(ac, retryEffect, offer.InteractionID)) | |||
| 797 | - | second := awaitPickerSnapshot(t, sends) | |||
| 798 | - | require.Equal(t, offer.InteractionID, second.InteractionID) | |||
| 799 | - | require.Equal(t, first.SourceRevision+1, second.SourceRevision) | |||
| 800 | - | ||||
| 801 | - | ac.overlays.pickerMu.Lock() | |||
| 802 | - | require.Equal(t, second.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "retry revision and client revision diverged") | |||
| 803 | - | require.Equal(t, second.Recent, ac.overlays.pickerRecent) | |||
| 804 | - | require.Equal(t, second.Grouped, ac.overlays.pickerGrouped) | |||
| 805 | - | retryKeys := ac.overlays.pickerKeys | |||
| 806 | - | ac.overlays.pickerMu.Unlock() | |||
| 807 | - | for _, line := range second.Lines { | |||
| 808 | - | if line.Actions != 0 { | |||
| 809 | - | require.Contains(t, retryKeys, line.Key, "retry committed no key for a displayed row") | |||
| 810 | - | } | |||
| 811 | - | } | |||
| 812 | - | ||||
| 813 | - | // A further identical publish is genuinely unchanged and must stay silent. | |||
| 814 | - | quietEffect := admitPickerEffectForTest(t, sess, ac) | |||
| 815 | - | require.NoError(t, d.publishPickerSourceForAttachment(ac, quietEffect, offer.InteractionID)) | |||
| 816 | - | for _, frame := range drainAllFrames(sends) { | |||
| 817 | - | require.NotEqual(t, "PickerSnapshot", envelopeMessageName(t, frame.Payload), "committed retry republished an unchanged source") | |||
| 818 | - | } | |||
| 819 | - | } | |||
D internal/usecase/daemon/picker_preview_test.go +0 -307
| @@ -1,307 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | "testing" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | renderer "github.com/bnema/vev-vt" | |
| 9 | - | "github.com/stretchr/testify/require" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/vev/internal/domain" | |
| 12 | - | "github.com/bnema/vev/internal/ports" | |
| 13 | - | "github.com/bnema/vev/internal/protocol" | |
| 14 | - | "github.com/bnema/vev/internal/protocol/wire" | |
| 15 | - | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | func awaitPickerPreview(t *testing.T, sends chan wire.Envelope) protocol.PickerPreview { | |
| 19 | - | t.Helper() | |
| 20 | - | for { | |
| 21 | - | frame := awaitTestValue(t, sends, "picker preview was not published") | |
| 22 | - | if preview, ok := decodeServerMessage(t, frame).(protocol.PickerPreview); ok { | |
| 23 | - | return preview | |
| 24 | - | } | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func pickerPreviewRequestFor(snapshot protocol.PickerSnapshot, key string, width, height uint16) protocol.PickerPreviewRequest { | |
| 29 | - | return protocol.PickerPreviewRequest{ | |
| 30 | - | Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID, | |
| 31 | - | SourceID: servingPickerSourceID, Key: key, Width: width, Height: height, | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | func TestPickerPreviewRequestPublishesCapturedViewport(t *testing.T) { | |
| 36 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 37 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 38 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 39 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 40 | - | ||
| 41 | - | capability := pickerClientCapability(t, ac, sess) | |
| 42 | - | request := pickerPreviewRequestFor(snapshot, key, 20, 5) | |
| 43 | - | require.NoError(t, protocol.ValidatePickerPreviewRequest(request)) | |
| 44 | - | d.handleAttachmentClientMessage(capability, request) | |
| 45 | - | ||
| 46 | - | preview := awaitPickerPreview(t, sends) | |
| 47 | - | require.Equal(t, snapshot.InteractionID, preview.InteractionID) | |
| 48 | - | require.Equal(t, servingPickerSourceID, preview.SourceID) | |
| 49 | - | require.Equal(t, key, preview.Key) | |
| 50 | - | require.Equal(t, protocol.PickerPreviewOK, preview.Status, "the fixture session must capture a viewport") | |
| 51 | - | require.Equal(t, uint16(20), preview.Width) | |
| 52 | - | require.LessOrEqual(t, preview.Height, uint16(5)) | |
| 53 | - | require.Len(t, preview.Cells, int(preview.Width)*int(preview.Height)) | |
| 54 | - | require.NotNil(t, preview.FrameRows()) | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | func TestPickerPreviewUnknownKeyReportsNoSuchTarget(t *testing.T) { | |
| 58 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 59 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 60 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 61 | - | ||
| 62 | - | capability := pickerClientCapability(t, ac, sess) | |
| 63 | - | request := pickerPreviewRequestFor(snapshot, "zz/retired", 20, 5) | |
| 64 | - | d.handleAttachmentClientMessage(capability, request) | |
| 65 | - | ||
| 66 | - | preview := awaitPickerPreview(t, sends) | |
| 67 | - | require.Equal(t, protocol.PickerPreviewNoSuchTarget, preview.Status) | |
| 68 | - | require.Zero(t, preview.Width) | |
| 69 | - | require.Empty(t, preview.Cells) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | func TestPickerPreviewStaleRequestPublishesNothing(t *testing.T) { | |
| 73 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 74 | - | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 75 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 76 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 77 | - | capability := pickerClientCapability(t, ac, sess) | |
| 78 | - | ||
| 79 | - | for range 3 { | |
| 80 | - | request := pickerPreviewRequestFor(snapshot, key, 20, 5) | |
| 81 | - | request.InteractionID = offer.InteractionID + 1 | |
| 82 | - | d.handleAttachmentClientMessage(capability, request) | |
| 83 | - | } | |
| 84 | - | for _, frame := range drainAllFrames(sends) { | |
| 85 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a request for another interaction must publish nothing") | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | // A superseded generation is dropped even though its request was valid. | |
| 89 | - | live := pickerPreviewRequestFor(snapshot, key, 20, 5) | |
| 90 | - | d.handleAttachmentClientMessage(capability, live) | |
| 91 | - | first := awaitPickerPreview(t, sends) | |
| 92 | - | ac.overlays.pickerMu.Lock() | |
| 93 | - | generation := ac.overlays.pickerPreviewGeneration | |
| 94 | - | target, known := ac.overlays.pickerKeys[live.Key] | |
| 95 | - | ac.overlays.pickerMu.Unlock() | |
| 96 | - | require.True(t, known) | |
| 97 | - | d.publishPickerPreviewForAttachment(ac, live, generation-1, target, protocol.PickerIntentNavigation, known) | |
| 98 | - | for _, frame := range drainAllFrames(sends) { | |
| 99 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a superseded generation must not publish") | |
| 100 | - | } | |
| 101 | - | require.Equal(t, key, first.Key) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | func TestPickerPreviewRetiredInteractionStopsPublishing(t *testing.T) { | |
| 105 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |
| 106 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 107 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 108 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |
| 109 | - | capability := pickerClientCapability(t, ac, sess) | |
| 110 | - | d.handleAttachmentClientMessage(capability, pickerPreviewRequestFor(snapshot, key, 20, 5)) | |
| 111 | - | require.Equal(t, protocol.PickerPreviewOK, awaitPickerPreview(t, sends).Status) | |
| 112 | - | ||
| 113 | - | require.True(t, d.closePickerForAttachment(ac, admitPickerEffectForTest(t, sess, ac), snapshot.InteractionID)) | |
| 114 | - | _ = drainAllFrames(sends) | |
| 115 | - | ac.overlays.pickerMu.Lock() | |
| 116 | - | generation := ac.overlays.pickerPreviewGeneration | |
| 117 | - | ac.overlays.pickerMu.Unlock() | |
| 118 | - | require.Zero(t, generation, "retiring the interaction must clear the preview generation") | |
| 119 | - | ||
| 120 | - | for _, frame := range drainAllFrames(sends) { | |
| 121 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload)) | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | func TestClampPickerPreviewFitsTheRequestedBounds(t *testing.T) { | |
| 126 | - | captured := picker.Preview{ | |
| 127 | - | Width: 4, Height: 2, | |
| 128 | - | Rows: [][]renderer.Cell{ | |
| 129 | - | {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}, {Rune: 'd'}}, | |
| 130 | - | {{Rune: 'e'}, {Rune: '界'}, {Continuation: true}, {Rune: 'h'}}, | |
| 131 | - | }, | |
| 132 | - | } | |
| 133 | - | fitted := clampPickerPreview(captured, 3, 1) | |
| 134 | - | require.Equal(t, protocol.PickerPreviewOK, fitted.Status) | |
| 135 | - | require.Equal(t, uint16(3), fitted.Width) | |
| 136 | - | require.Equal(t, uint16(1), fitted.Height) | |
| 137 | - | require.Len(t, fitted.Cells, 3) | |
| 138 | - | require.Equal(t, 'a', fitted.Cells[0].Rune) | |
| 139 | - | ||
| 140 | - | // A double-width cell whose continuation column is cropped out becomes | |
| 141 | - | // blank, so the run never ends on a wide cell. | |
| 142 | - | wide := clampPickerPreview(captured, 2, 2) | |
| 143 | - | require.Equal(t, protocol.PickerPreviewOK, wide.Status) | |
| 144 | - | require.Len(t, wide.Cells, 4) | |
| 145 | - | require.Equal(t, 'e', wide.Cells[2].Rune) | |
| 146 | - | require.Zero(t, wide.Cells[3].Rune) | |
| 147 | - | require.False(t, wide.Cells[3].Continuation) | |
| 148 | - | ||
| 149 | - | // Every row pads to the requested width, and the height stays the number of | |
| 150 | - | // captured rows (never more than the request). | |
| 151 | - | padded := clampPickerPreview(captured, 4, 3) | |
| 152 | - | require.Equal(t, protocol.PickerPreviewOK, padded.Status) | |
| 153 | - | require.Len(t, padded.Cells, 8) | |
| 154 | - | require.Equal(t, uint16(2), padded.Height) | |
| 155 | - | ||
| 156 | - | require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(picker.Preview{}, 4, 2).Status) | |
| 157 | - | require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(captured, 0, 2).Status) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | // TestPickerPreviewCapturesRemoteRowsThroughThePreviewClient pins the daemon | |
| 161 | - | // half of the coexistence contract: a remote picker row is still previewed | |
| 162 | - | // through the daemon's own remote viewport client and its cache, independent of | |
| 163 | - | // whatever the launching client resolves for itself. | |
| 164 | - | ||
| 165 | - | // pickerPreviewKeyForSession returns the opaque row key whose target resolves | |
| 166 | - | // to sess. The preview request names a key, so a cross-session test must read | |
| 167 | - | // the same mapping the serving source published. | |
| 168 | - | func pickerPreviewKeyForSession(t *testing.T, ac *attachedClient, sess *session) string { | |
| 169 | - | t.Helper() | |
| 170 | - | ac.overlays.pickerMu.Lock() | |
| 171 | - | defer ac.overlays.pickerMu.Unlock() | |
| 172 | - | for key, target := range ac.overlays.pickerKeys { | |
| 173 | - | if target.RemoteTarget == nil && target.Session == sess.id { | |
| 174 | - | return key | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | return "" | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator pins the | |
| 181 | - | // cross-session contract: the subscription that refreshes the displayed row | |
| 182 | - | // lives on the target session's coordinator. A target render wakes the preview | |
| 183 | - | // and keeps the headless target renderable, while a viewer-session render must | |
| 184 | - | // not drive the target's preview at all. | |
| 185 | - | func TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator(t *testing.T) { | |
| 186 | - | viewerPTY, releaseViewer := newBlockingPTY(t) | |
| 187 | - | t.Cleanup(releaseViewer) | |
| 188 | - | d, viewerSess, ac, sends := newManualSessionWithPTYs(t, viewerPTY) | |
| 189 | - | d.ptys = newFactorySeq(t, newQuietPTY()) | |
| 190 | - | targetSess, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |
| 191 | - | require.NoError(t, err) | |
| 192 | - | ||
| 193 | - | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(viewerSess, ac, ac.transport())) | |
| 194 | - | require.True(t, ok) | |
| 195 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |
| 196 | - | snapshot := awaitPickerSnapshot(t, sends) | |
| 197 | - | key := pickerPreviewKeyForSession(t, ac, targetSess) | |
| 198 | - | require.NotEmpty(t, key, "the snapshot must offer a navigable row for the target session") | |
| 199 | - | ||
| 200 | - | clock := newCoordinatorMockClock(t, 16) | |
| 201 | - | d.clock = clock.clock | |
| 202 | - | viewerCoordinator := attachmentRenderCoordinator(viewerSess) | |
| 203 | - | require.Nil(t, attachmentRenderCoordinator(targetSess), "an un-previewed target owns no coordinator") | |
| 204 | - | ||
| 205 | - | d.handleAttachmentClientMessage(pickerClientCapability(t, ac, viewerSess), pickerPreviewRequestFor(snapshot, key, 20, 5)) | |
| 206 | - | first := awaitPickerPreview(t, sends) | |
| 207 | - | require.Equal(t, protocol.PickerPreviewOK, first.Status) | |
| 208 | - | ||
| 209 | - | targetCoordinator := attachmentRenderCoordinator(targetSess) | |
| 210 | - | require.NotNil(t, targetCoordinator, "the preview must subscribe on the target session's coordinator") | |
| 211 | - | require.True(t, targetCoordinator.hasPreviewSubscribers()) | |
| 212 | - | if viewerCoordinator != nil { | |
| 213 | - | require.False(t, viewerCoordinator.hasPreviewSubscribers(), "the viewer session must not own the target's subscription") | |
| 214 | - | } | |
| 215 | - | targetTab := targetSess.tabs[0] | |
| 216 | - | require.True(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()), | |
| 217 | - | "a previewed headless target must stay renderable") | |
| 218 | - | ||
| 219 | - | // A target render wakes the target's subscription and republishes. | |
| 220 | - | d.processPTYData(targetSess, targetTab, targetTab.focusedPane(), []byte("target repaint"), false) | |
| 221 | - | fireCoordinatorTimer(t, targetCoordinator, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 222 | - | refreshed := awaitPickerPreview(t, sends) | |
| 223 | - | require.Equal(t, key, refreshed.Key) | |
| 224 | - | require.Equal(t, protocol.PickerPreviewOK, refreshed.Status) | |
| 225 | - | ||
| 226 | - | // A viewer-session render must not publish the target's preview. The direct | |
| 227 | - | // invalidateRender path is urgent, so it arms the urgent deadline. | |
| 228 | - | viewerCoordinator = d.attachCoordinator(viewerSess, nil, ac, true) | |
| 229 | - | d.invalidateRender(viewerSess, ac, true, "picker_preview_test.go:viewer") | |
| 230 | - | fireCoordinatorTimer(t, viewerCoordinator, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| 231 | - | for _, frame := range drainAllFrames(sends) { | |
| 232 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), | |
| 233 | - | "a viewer-session render must not publish the target's preview") | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | // Retiring the interaction removes the subscription from the target's own | |
| 237 | - | // coordinator, so the headless target stops being renderable. A nil effect | |
| 238 | - | // retires the interaction without a close confirmation. | |
| 239 | - | require.True(t, d.closePickerForAttachment(ac, nil, snapshot.InteractionID)) | |
| 240 | - | require.False(t, targetCoordinator.hasPreviewSubscribers(), | |
| 241 | - | "teardown must remove the exact target subscription") | |
| 242 | - | require.False(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()), | |
| 243 | - | "a retired preview must stop keeping the target renderable") | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | // manualPreviewClock is a deterministic ports.Clock for the remote preview | |
| 247 | - | // refresher. Timers never fire on their own, so a test can prove the worker | |
| 248 | - | // armed one and then advance it explicitly without any wall-clock sleep. | |
| 249 | - | type manualPreviewClock struct { | |
| 250 | - | mu sync.Mutex | |
| 251 | - | now time.Time | |
| 252 | - | waiting chan time.Time | |
| 253 | - | armed chan struct{} | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | func newManualPreviewClock(now time.Time) *manualPreviewClock { | |
| 257 | - | return &manualPreviewClock{now: now, armed: make(chan struct{}, 64)} | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | func (c *manualPreviewClock) Now() time.Time { | |
| 261 | - | c.mu.Lock() | |
| 262 | - | defer c.mu.Unlock() | |
| 263 | - | return c.now | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | func (c *manualPreviewClock) NewTimer(time.Duration) ports.Timer { | |
| 267 | - | ch := make(chan time.Time, 1) | |
| 268 | - | c.mu.Lock() | |
| 269 | - | c.waiting = ch | |
| 270 | - | c.mu.Unlock() | |
| 271 | - | select { | |
| 272 | - | case c.armed <- struct{}{}: | |
| 273 | - | default: | |
| 274 | - | } | |
| 275 | - | return manualPreviewTimer{ch: ch} | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | func (c *manualPreviewClock) awaitTimer(t *testing.T) { | |
| 279 | - | t.Helper() | |
| 280 | - | select { | |
| 281 | - | case <-c.armed: | |
| 282 | - | case <-time.After(2 * time.Second): | |
| 283 | - | t.Fatal("quiet viewer never armed a remote preview refresh") | |
| 284 | - | } | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func (c *manualPreviewClock) fire(now time.Time) { | |
| 288 | - | c.mu.Lock() | |
| 289 | - | ch := c.waiting | |
| 290 | - | c.waiting = nil | |
| 291 | - | c.now = now | |
| 292 | - | c.mu.Unlock() | |
| 293 | - | if ch != nil { | |
| 294 | - | ch <- now | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | type manualPreviewTimer struct{ ch chan time.Time } | |
| 299 | - | ||
| 300 | - | func (t manualPreviewTimer) C() <-chan time.Time { return t.ch } | |
| 301 | - | func (t manualPreviewTimer) Reset(time.Duration) bool { return false } | |
| 302 | - | func (t manualPreviewTimer) Stop() bool { return true } | |
| 303 | - | ||
| 304 | - | // TestRemotePickerPreviewRefreshesWithoutViewerRenderWake is the regression for | |
| 305 | - | // the quiet-viewer stale delay: a selected remote row must keep refreshing on | |
| 306 | - | // its own cadence even when the viewer renders nothing at all. Relying on a | |
| 307 | - | // viewer render wake left a quiet viewer stale indefinitely. | |
| @@ -1,307 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "sync" | |||
| 5 | - | "testing" | |||
| 6 | - | "time" | |||
| 7 | - | ||||
| 8 | - | renderer "github.com/bnema/vev-vt" | |||
| 9 | - | "github.com/stretchr/testify/require" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/vev/internal/domain" | |||
| 12 | - | "github.com/bnema/vev/internal/ports" | |||
| 13 | - | "github.com/bnema/vev/internal/protocol" | |||
| 14 | - | "github.com/bnema/vev/internal/protocol/wire" | |||
| 15 | - | "github.com/bnema/vev/internal/usecase/picker" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | func awaitPickerPreview(t *testing.T, sends chan wire.Envelope) protocol.PickerPreview { | |||
| 19 | - | t.Helper() | |||
| 20 | - | for { | |||
| 21 | - | frame := awaitTestValue(t, sends, "picker preview was not published") | |||
| 22 | - | if preview, ok := decodeServerMessage(t, frame).(protocol.PickerPreview); ok { | |||
| 23 | - | return preview | |||
| 24 | - | } | |||
| 25 | - | } | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | func pickerPreviewRequestFor(snapshot protocol.PickerSnapshot, key string, width, height uint16) protocol.PickerPreviewRequest { | |||
| 29 | - | return protocol.PickerPreviewRequest{ | |||
| 30 | - | Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID, | |||
| 31 | - | SourceID: servingPickerSourceID, Key: key, Width: width, Height: height, | |||
| 32 | - | } | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | func TestPickerPreviewRequestPublishesCapturedViewport(t *testing.T) { | |||
| 36 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 37 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 38 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 39 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |||
| 40 | - | ||||
| 41 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 42 | - | request := pickerPreviewRequestFor(snapshot, key, 20, 5) | |||
| 43 | - | require.NoError(t, protocol.ValidatePickerPreviewRequest(request)) | |||
| 44 | - | d.handleAttachmentClientMessage(capability, request) | |||
| 45 | - | ||||
| 46 | - | preview := awaitPickerPreview(t, sends) | |||
| 47 | - | require.Equal(t, snapshot.InteractionID, preview.InteractionID) | |||
| 48 | - | require.Equal(t, servingPickerSourceID, preview.SourceID) | |||
| 49 | - | require.Equal(t, key, preview.Key) | |||
| 50 | - | require.Equal(t, protocol.PickerPreviewOK, preview.Status, "the fixture session must capture a viewport") | |||
| 51 | - | require.Equal(t, uint16(20), preview.Width) | |||
| 52 | - | require.LessOrEqual(t, preview.Height, uint16(5)) | |||
| 53 | - | require.Len(t, preview.Cells, int(preview.Width)*int(preview.Height)) | |||
| 54 | - | require.NotNil(t, preview.FrameRows()) | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | func TestPickerPreviewUnknownKeyReportsNoSuchTarget(t *testing.T) { | |||
| 58 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 59 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 60 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 61 | - | ||||
| 62 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 63 | - | request := pickerPreviewRequestFor(snapshot, "zz/retired", 20, 5) | |||
| 64 | - | d.handleAttachmentClientMessage(capability, request) | |||
| 65 | - | ||||
| 66 | - | preview := awaitPickerPreview(t, sends) | |||
| 67 | - | require.Equal(t, protocol.PickerPreviewNoSuchTarget, preview.Status) | |||
| 68 | - | require.Zero(t, preview.Width) | |||
| 69 | - | require.Empty(t, preview.Cells) | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | func TestPickerPreviewStaleRequestPublishesNothing(t *testing.T) { | |||
| 73 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 74 | - | offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 75 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 76 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |||
| 77 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 78 | - | ||||
| 79 | - | for range 3 { | |||
| 80 | - | request := pickerPreviewRequestFor(snapshot, key, 20, 5) | |||
| 81 | - | request.InteractionID = offer.InteractionID + 1 | |||
| 82 | - | d.handleAttachmentClientMessage(capability, request) | |||
| 83 | - | } | |||
| 84 | - | for _, frame := range drainAllFrames(sends) { | |||
| 85 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a request for another interaction must publish nothing") | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | // A superseded generation is dropped even though its request was valid. | |||
| 89 | - | live := pickerPreviewRequestFor(snapshot, key, 20, 5) | |||
| 90 | - | d.handleAttachmentClientMessage(capability, live) | |||
| 91 | - | first := awaitPickerPreview(t, sends) | |||
| 92 | - | ac.overlays.pickerMu.Lock() | |||
| 93 | - | generation := ac.overlays.pickerPreviewGeneration | |||
| 94 | - | target, known := ac.overlays.pickerKeys[live.Key] | |||
| 95 | - | ac.overlays.pickerMu.Unlock() | |||
| 96 | - | require.True(t, known) | |||
| 97 | - | d.publishPickerPreviewForAttachment(ac, live, generation-1, target, protocol.PickerIntentNavigation, known) | |||
| 98 | - | for _, frame := range drainAllFrames(sends) { | |||
| 99 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), "a superseded generation must not publish") | |||
| 100 | - | } | |||
| 101 | - | require.Equal(t, key, first.Key) | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | func TestPickerPreviewRetiredInteractionStopsPublishing(t *testing.T) { | |||
| 105 | - | d, sess, ac, sends, effect := pickerClientTestUnit(t) | |||
| 106 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 107 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 108 | - | key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate) | |||
| 109 | - | capability := pickerClientCapability(t, ac, sess) | |||
| 110 | - | d.handleAttachmentClientMessage(capability, pickerPreviewRequestFor(snapshot, key, 20, 5)) | |||
| 111 | - | require.Equal(t, protocol.PickerPreviewOK, awaitPickerPreview(t, sends).Status) | |||
| 112 | - | ||||
| 113 | - | require.True(t, d.closePickerForAttachment(ac, admitPickerEffectForTest(t, sess, ac), snapshot.InteractionID)) | |||
| 114 | - | _ = drainAllFrames(sends) | |||
| 115 | - | ac.overlays.pickerMu.Lock() | |||
| 116 | - | generation := ac.overlays.pickerPreviewGeneration | |||
| 117 | - | ac.overlays.pickerMu.Unlock() | |||
| 118 | - | require.Zero(t, generation, "retiring the interaction must clear the preview generation") | |||
| 119 | - | ||||
| 120 | - | for _, frame := range drainAllFrames(sends) { | |||
| 121 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload)) | |||
| 122 | - | } | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | func TestClampPickerPreviewFitsTheRequestedBounds(t *testing.T) { | |||
| 126 | - | captured := picker.Preview{ | |||
| 127 | - | Width: 4, Height: 2, | |||
| 128 | - | Rows: [][]renderer.Cell{ | |||
| 129 | - | {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}, {Rune: 'd'}}, | |||
| 130 | - | {{Rune: 'e'}, {Rune: '界'}, {Continuation: true}, {Rune: 'h'}}, | |||
| 131 | - | }, | |||
| 132 | - | } | |||
| 133 | - | fitted := clampPickerPreview(captured, 3, 1) | |||
| 134 | - | require.Equal(t, protocol.PickerPreviewOK, fitted.Status) | |||
| 135 | - | require.Equal(t, uint16(3), fitted.Width) | |||
| 136 | - | require.Equal(t, uint16(1), fitted.Height) | |||
| 137 | - | require.Len(t, fitted.Cells, 3) | |||
| 138 | - | require.Equal(t, 'a', fitted.Cells[0].Rune) | |||
| 139 | - | ||||
| 140 | - | // A double-width cell whose continuation column is cropped out becomes | |||
| 141 | - | // blank, so the run never ends on a wide cell. | |||
| 142 | - | wide := clampPickerPreview(captured, 2, 2) | |||
| 143 | - | require.Equal(t, protocol.PickerPreviewOK, wide.Status) | |||
| 144 | - | require.Len(t, wide.Cells, 4) | |||
| 145 | - | require.Equal(t, 'e', wide.Cells[2].Rune) | |||
| 146 | - | require.Zero(t, wide.Cells[3].Rune) | |||
| 147 | - | require.False(t, wide.Cells[3].Continuation) | |||
| 148 | - | ||||
| 149 | - | // Every row pads to the requested width, and the height stays the number of | |||
| 150 | - | // captured rows (never more than the request). | |||
| 151 | - | padded := clampPickerPreview(captured, 4, 3) | |||
| 152 | - | require.Equal(t, protocol.PickerPreviewOK, padded.Status) | |||
| 153 | - | require.Len(t, padded.Cells, 8) | |||
| 154 | - | require.Equal(t, uint16(2), padded.Height) | |||
| 155 | - | ||||
| 156 | - | require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(picker.Preview{}, 4, 2).Status) | |||
| 157 | - | require.Equal(t, protocol.PickerPreviewUnavailable, clampPickerPreview(captured, 0, 2).Status) | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // TestPickerPreviewCapturesRemoteRowsThroughThePreviewClient pins the daemon | |||
| 161 | - | // half of the coexistence contract: a remote picker row is still previewed | |||
| 162 | - | // through the daemon's own remote viewport client and its cache, independent of | |||
| 163 | - | // whatever the launching client resolves for itself. | |||
| 164 | - | ||||
| 165 | - | // pickerPreviewKeyForSession returns the opaque row key whose target resolves | |||
| 166 | - | // to sess. The preview request names a key, so a cross-session test must read | |||
| 167 | - | // the same mapping the serving source published. | |||
| 168 | - | func pickerPreviewKeyForSession(t *testing.T, ac *attachedClient, sess *session) string { | |||
| 169 | - | t.Helper() | |||
| 170 | - | ac.overlays.pickerMu.Lock() | |||
| 171 | - | defer ac.overlays.pickerMu.Unlock() | |||
| 172 | - | for key, target := range ac.overlays.pickerKeys { | |||
| 173 | - | if target.RemoteTarget == nil && target.Session == sess.id { | |||
| 174 | - | return key | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | return "" | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | // TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator pins the | |||
| 181 | - | // cross-session contract: the subscription that refreshes the displayed row | |||
| 182 | - | // lives on the target session's coordinator. A target render wakes the preview | |||
| 183 | - | // and keeps the headless target renderable, while a viewer-session render must | |||
| 184 | - | // not drive the target's preview at all. | |||
| 185 | - | func TestPickerPreviewCrossSessionFollowsTheTargetsRenderCoordinator(t *testing.T) { | |||
| 186 | - | viewerPTY, releaseViewer := newBlockingPTY(t) | |||
| 187 | - | t.Cleanup(releaseViewer) | |||
| 188 | - | d, viewerSess, ac, sends := newManualSessionWithPTYs(t, viewerPTY) | |||
| 189 | - | d.ptys = newFactorySeq(t, newQuietPTY()) | |||
| 190 | - | targetSess, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil) | |||
| 191 | - | require.NoError(t, err) | |||
| 192 | - | ||||
| 193 | - | effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(viewerSess, ac, ac.transport())) | |||
| 194 | - | require.True(t, ok) | |||
| 195 | - | openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation) | |||
| 196 | - | snapshot := awaitPickerSnapshot(t, sends) | |||
| 197 | - | key := pickerPreviewKeyForSession(t, ac, targetSess) | |||
| 198 | - | require.NotEmpty(t, key, "the snapshot must offer a navigable row for the target session") | |||
| 199 | - | ||||
| 200 | - | clock := newCoordinatorMockClock(t, 16) | |||
| 201 | - | d.clock = clock.clock | |||
| 202 | - | viewerCoordinator := attachmentRenderCoordinator(viewerSess) | |||
| 203 | - | require.Nil(t, attachmentRenderCoordinator(targetSess), "an un-previewed target owns no coordinator") | |||
| 204 | - | ||||
| 205 | - | d.handleAttachmentClientMessage(pickerClientCapability(t, ac, viewerSess), pickerPreviewRequestFor(snapshot, key, 20, 5)) | |||
| 206 | - | first := awaitPickerPreview(t, sends) | |||
| 207 | - | require.Equal(t, protocol.PickerPreviewOK, first.Status) | |||
| 208 | - | ||||
| 209 | - | targetCoordinator := attachmentRenderCoordinator(targetSess) | |||
| 210 | - | require.NotNil(t, targetCoordinator, "the preview must subscribe on the target session's coordinator") | |||
| 211 | - | require.True(t, targetCoordinator.hasPreviewSubscribers()) | |||
| 212 | - | if viewerCoordinator != nil { | |||
| 213 | - | require.False(t, viewerCoordinator.hasPreviewSubscribers(), "the viewer session must not own the target's subscription") | |||
| 214 | - | } | |||
| 215 | - | targetTab := targetSess.tabs[0] | |||
| 216 | - | require.True(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()), | |||
| 217 | - | "a previewed headless target must stay renderable") | |||
| 218 | - | ||||
| 219 | - | // A target render wakes the target's subscription and republishes. | |||
| 220 | - | d.processPTYData(targetSess, targetTab, targetTab.focusedPane(), []byte("target repaint"), false) | |||
| 221 | - | fireCoordinatorTimer(t, targetCoordinator, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 222 | - | refreshed := awaitPickerPreview(t, sends) | |||
| 223 | - | require.Equal(t, key, refreshed.Key) | |||
| 224 | - | require.Equal(t, protocol.PickerPreviewOK, refreshed.Status) | |||
| 225 | - | ||||
| 226 | - | // A viewer-session render must not publish the target's preview. The direct | |||
| 227 | - | // invalidateRender path is urgent, so it arms the urgent deadline. | |||
| 228 | - | viewerCoordinator = d.attachCoordinator(viewerSess, nil, ac, true) | |||
| 229 | - | d.invalidateRender(viewerSess, ac, true, "picker_preview_test.go:viewer") | |||
| 230 | - | fireCoordinatorTimer(t, viewerCoordinator, drainCoordinatorTimers(clock), urgentRenderDeadline) | |||
| 231 | - | for _, frame := range drainAllFrames(sends) { | |||
| 232 | - | require.NotEqual(t, "PickerPreview", envelopeMessageName(t, frame.Payload), | |||
| 233 | - | "a viewer-session render must not publish the target's preview") | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // Retiring the interaction removes the subscription from the target's own | |||
| 237 | - | // coordinator, so the headless target stops being renderable. A nil effect | |||
| 238 | - | // retires the interaction without a close confirmation. | |||
| 239 | - | require.True(t, d.closePickerForAttachment(ac, nil, snapshot.InteractionID)) | |||
| 240 | - | require.False(t, targetCoordinator.hasPreviewSubscribers(), | |||
| 241 | - | "teardown must remove the exact target subscription") | |||
| 242 | - | require.False(t, d.paneRenderable(targetSess, targetTab, targetTab.focusedPane()), | |||
| 243 | - | "a retired preview must stop keeping the target renderable") | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | // manualPreviewClock is a deterministic ports.Clock for the remote preview | |||
| 247 | - | // refresher. Timers never fire on their own, so a test can prove the worker | |||
| 248 | - | // armed one and then advance it explicitly without any wall-clock sleep. | |||
| 249 | - | type manualPreviewClock struct { | |||
| 250 | - | mu sync.Mutex | |||
| 251 | - | now time.Time | |||
| 252 | - | waiting chan time.Time | |||
| 253 | - | armed chan struct{} | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | func newManualPreviewClock(now time.Time) *manualPreviewClock { | |||
| 257 | - | return &manualPreviewClock{now: now, armed: make(chan struct{}, 64)} | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | func (c *manualPreviewClock) Now() time.Time { | |||
| 261 | - | c.mu.Lock() | |||
| 262 | - | defer c.mu.Unlock() | |||
| 263 | - | return c.now | |||
| 264 | - | } | |||
| 265 | - | ||||
| 266 | - | func (c *manualPreviewClock) NewTimer(time.Duration) ports.Timer { | |||
| 267 | - | ch := make(chan time.Time, 1) | |||
| 268 | - | c.mu.Lock() | |||
| 269 | - | c.waiting = ch | |||
| 270 | - | c.mu.Unlock() | |||
| 271 | - | select { | |||
| 272 | - | case c.armed <- struct{}{}: | |||
| 273 | - | default: | |||
| 274 | - | } | |||
| 275 | - | return manualPreviewTimer{ch: ch} | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | func (c *manualPreviewClock) awaitTimer(t *testing.T) { | |||
| 279 | - | t.Helper() | |||
| 280 | - | select { | |||
| 281 | - | case <-c.armed: | |||
| 282 | - | case <-time.After(2 * time.Second): | |||
| 283 | - | t.Fatal("quiet viewer never armed a remote preview refresh") | |||
| 284 | - | } | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | func (c *manualPreviewClock) fire(now time.Time) { | |||
| 288 | - | c.mu.Lock() | |||
| 289 | - | ch := c.waiting | |||
| 290 | - | c.waiting = nil | |||
| 291 | - | c.now = now | |||
| 292 | - | c.mu.Unlock() | |||
| 293 | - | if ch != nil { | |||
| 294 | - | ch <- now | |||
| 295 | - | } | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | type manualPreviewTimer struct{ ch chan time.Time } | |||
| 299 | - | ||||
| 300 | - | func (t manualPreviewTimer) C() <-chan time.Time { return t.ch } | |||
| 301 | - | func (t manualPreviewTimer) Reset(time.Duration) bool { return false } | |||
| 302 | - | func (t manualPreviewTimer) Stop() bool { return true } | |||
| 303 | - | ||||
| 304 | - | // TestRemotePickerPreviewRefreshesWithoutViewerRenderWake is the regression for | |||
| 305 | - | // the quiet-viewer stale delay: a selected remote row must keep refreshing on | |||
| 306 | - | // its own cadence even when the viewer renders nothing at all. Relying on a | |||
| 307 | - | // viewer render wake left a quiet viewer stale indefinitely. | |||
M internal/usecase/daemon/render_coordinator_test.go +1 -1
| @@ -1226,7 +1226,7 @@ func TestProducerInvalidations(t *testing.T) { | |||
| 1226 | 1226 | interaction: true, | |
| 1227 | 1227 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | |
| 1228 | 1228 | effect := admitPickerEffectForTest(t, sess, ac) | |
| 1229 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | |
| 1229 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMovePane, moveSourceLocator{}, 0)) | |
| 1230 | 1230 | }, | |
| 1231 | 1231 | }, | |
| 1232 | 1232 | { | |
| @@ -1226,7 +1226,7 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1226 | interaction: true, | 1226 | interaction: true, | ||
| 1227 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | 1227 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | ||
| 1228 | effect := admitPickerEffectForTest(t, sess, ac) | 1228 | effect := admitPickerEffectForTest(t, sess, ac) | ||
| 1229 | - | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)) | 1229 | + | require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMovePane, moveSourceLocator{}, 0)) |
| 1230 | }, | 1230 | }, | ||
| 1231 | }, | 1231 | }, | ||
| 1232 | { | 1232 | { | ||
M internal/usecase/daemon/session_capabilities_test.go +1 -1
| @@ -28,7 +28,7 @@ func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) { | |||
| 28 | 28 | wantErr error | |
| 29 | 29 | }{ | |
| 30 | 30 | {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves}, | |
| 31 | - | {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: nil}, | |
| 31 | + | {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: errClientOwnedNavigation}, | |
| 32 | 32 | } | |
| 33 | 33 | for _, tt := range tests { | |
| 34 | 34 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -28,7 +28,7 @@ func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) { | |||||
| 28 | wantErr error | 28 | wantErr error | ||
| 29 | }{ | 29 | }{ | ||
| 30 | {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves}, | 30 | {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves}, | ||
| 31 | - | {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: nil}, | 31 | + | {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: errClientOwnedNavigation}, |
| 32 | } | 32 | } | ||
| 33 | for _, tt := range tests { | 33 | for _, tt := range tests { | ||
| 34 | t.Run(tt.name, func(t *testing.T) { | 34 | t.Run(tt.name, func(t *testing.T) { | ||
M internal/usecase/daemon/typed_connection_test.go +0 -2
| @@ -201,8 +201,6 @@ func serverMessageName(message protocol.ServerMessage) string { | |||
| 201 | 201 | return "PickerFailure" | |
| 202 | 202 | case protocol.PickerPreview: | |
| 203 | 203 | return "PickerPreview" | |
| 204 | - | case protocol.PickerControlResponse: | |
| 205 | - | return "PickerControlResponse" | |
| 206 | 204 | default: | |
| 207 | 205 | return "" | |
| 208 | 206 | } | |
| @@ -201,8 +201,6 @@ func serverMessageName(message protocol.ServerMessage) string { | |||||
| 201 | return "PickerFailure" | 201 | return "PickerFailure" | ||
| 202 | case protocol.PickerPreview: | 202 | case protocol.PickerPreview: | ||
| 203 | return "PickerPreview" | 203 | return "PickerPreview" | ||
| 204 | - | case protocol.PickerControlResponse: | |||
| 205 | - | return "PickerControlResponse" | |||
| 206 | default: | 204 | default: | ||
| 207 | return "" | 205 | return "" | ||
| 208 | } | 206 | } | ||