Files
MyGoNavi/internal/nativewindow/visibility_gate_test.go
Syngnat c19fcdd183 🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复
- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件
- 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡
- 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点
- 拒绝复用终态子进程,并恢复失败的附加与关闭操作
- 串行展示、隐藏和终态退出,消除关闭门闩并发竞态
- 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
2026-07-22 18:45:55 +08:00

706 lines
22 KiB
Go

package nativewindow
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestDetachedChildQueuesFocusUntilFrontendReadyHandshake(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := control.Focus(); !result.Success {
t.Fatalf("pre-ready Focus result = %#v", result)
}
if shows != 0 || focuses != 0 {
t.Fatalf("pre-ready presentation = show %d focus %d, want 0/0", shows, focuses)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if shows != 1 || focuses != 1 {
t.Fatalf("ready presentation = show %d focus %d, want 1/1", shows, focuses)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("repeated ready Action result = %#v", result)
}
if shows != 1 || focuses != 1 {
t.Fatalf("repeated ready presentation = show %d focus %d, want 1/1", shows, focuses)
}
}
func TestDetachedChildShowsAfterReadyAndFocusesWithoutShowingAgain(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if shows != 1 || focuses != 0 {
t.Fatalf("ready presentation = show %d focus %d, want 1/0", shows, focuses)
}
if result := control.Focus(); !result.Success {
t.Fatalf("post-ready Focus result = %#v", result)
}
if shows != 1 || focuses != 1 {
t.Fatalf("post-ready presentation = show %d focus %d, want 1/1", shows, focuses)
}
if result := control.Focus(); !result.Success {
t.Fatalf("later Focus result = %#v", result)
}
if shows != 1 || focuses != 2 {
t.Fatalf("later presentation = show %d focus %d, want 1/2", shows, focuses)
}
}
func TestDetachedChildAcknowledgesFocusOnlyAfterDelayedPresentation(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
steps := make([]string, 0, 4)
control.showWindow = func(context.Context) { steps = append(steps, "show") }
control.focusWindow = func(context.Context) { steps = append(steps, "focus") }
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Path {
case CommandStatePath:
var acknowledgement commandStateRequest
if err := json.NewDecoder(request.Body).Decode(&acknowledgement); err != nil {
return nil, err
}
steps = append(steps, "ack-focus")
if acknowledgement.Action != "ack-focus" || acknowledgement.VisibilityRevision != 7 {
t.Fatalf("focus acknowledgement = %#v", acknowledgement)
}
case ActionPath:
steps = append(steps, "post-ready")
}
return successfulVisibilityResponse(), nil
})
if result := control.FocusRevision(7); !result.Success {
t.Fatalf("pre-ready FocusRevision result = %#v", result)
}
if len(steps) != 0 {
t.Fatalf("pre-ready steps = %#v, want none", steps)
}
control.markDOMReady(ctx)
if len(steps) != 0 {
t.Fatalf("DOM-ready steps = %#v, want none before frontend presentation", steps)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if got := strings.Join(steps, ","); got != "show,focus,ack-focus,post-ready" {
t.Fatalf("delayed focus sequence = %q", got)
}
}
func TestDetachedChildFailedFocusAcknowledgementLeavesParentPendingForRetry(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true},
visibilityRevision: 7,
pendingFocusRevision: 7,
}
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: manager.token,
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
focuses := 0
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
failAcknowledgement := true
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if failAcknowledgement {
return nil, errors.New("temporary parent connection failure")
}
request.RemoteAddr = "127.0.0.1:51003"
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
return recorder.Result(), nil
})
if result := control.FocusRevision(7); !result.Success {
t.Fatalf("FocusRevision with failed acknowledgement = %#v", result)
}
manager.mu.RLock()
pendingAfterFailure := manager.windows["ai-chat"].pendingFocusRevision
manager.mu.RUnlock()
if pendingAfterFailure != 7 {
t.Fatalf("pending focus after failed acknowledgement = %d, want 7", pendingAfterFailure)
}
failAcknowledgement = false
if result := control.FocusRevision(7); !result.Success {
t.Fatalf("FocusRevision retry result = %#v", result)
}
manager.mu.RLock()
pendingAfterRetry := manager.windows["ai-chat"].pendingFocusRevision
manager.mu.RUnlock()
if pendingAfterRetry != 0 || focuses != 2 {
t.Fatalf("retry state = pending %d focuses %d, want 0/2", pendingAfterRetry, focuses)
}
}
func TestDetachedChildIgnoresLateHideAfterNewerFocus(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
hides := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.hideWindow = func(context.Context) { hides++ }
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if result := control.Hide(1); !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("Hide result = %#v", result)
}
if hides != 1 {
t.Fatalf("hides after revision 1 = %d, want 1", hides)
}
if result := control.FocusRevision(2); !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("FocusRevision result = %#v", result)
}
if shows != 2 || focuses != 1 {
t.Fatalf("presentation after focus = show %d focus %d, want 2/1", shows, focuses)
}
lateHide := control.Hide(1)
if !lateHide.Success || lateHide.VisibilityRevision != 2 ||
!strings.Contains(lateHide.Message, "stale") {
t.Fatalf("late Hide result = %#v", lateHide)
}
if hides != 1 {
t.Fatalf("late hide reached native window: hides = %d, want 1", hides)
}
control.mu.RLock()
visible := control.visible
revision := control.visibilityRevision
control.mu.RUnlock()
if !visible || revision != 2 {
t.Fatalf("final visibility state = visible %v revision %d", visible, revision)
}
}
func TestDetachedChildSerializesNativeHideBeforeNewerFocus(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
steps := make(chan string, 8)
hideStarted := make(chan struct{})
releaseHide := make(chan struct{})
control.showWindow = func(context.Context) { steps <- "show" }
control.hideWindow = func(context.Context) {
steps <- "hide-start"
close(hideStarted)
<-releaseHide
steps <- "hide-end"
}
control.focusWindow = func(context.Context) { steps <- "focus" }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
if step := <-steps; step != "show" {
t.Fatalf("initial presentation step = %q, want show", step)
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case <-hideStarted:
case <-time.After(time.Second):
t.Fatal("native hide did not start")
}
focusCallStarted := make(chan struct{})
focusDone := make(chan OperationResult, 1)
go func() {
close(focusCallStarted)
focusDone <- control.FocusRevision(2)
}()
<-focusCallStarted
select {
case result := <-focusDone:
close(releaseHide)
<-hideDone
t.Fatalf("newer focus completed before the older native hide: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseHide)
if result := <-hideDone; !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("Hide result = %#v", result)
}
if result := <-focusDone; !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("FocusRevision result = %#v", result)
}
sequence := make([]string, 0, 4)
for len(sequence) < 4 {
select {
case step := <-steps:
sequence = append(sequence, step)
case <-time.After(time.Second):
t.Fatalf("native visibility sequence stopped at %#v", sequence)
}
}
if got := strings.Join(sequence, ","); got != "hide-start,hide-end,show,focus" {
t.Fatalf("native visibility sequence = %q", got)
}
}
func TestDetachedChildDoesNotBlockHideWhileAcknowledgingFocus(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
control.focusWindow = func(context.Context) {}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
acknowledgementStarted := make(chan struct{})
releaseAcknowledgement := make(chan struct{})
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == CommandStatePath {
close(acknowledgementStarted)
<-releaseAcknowledgement
}
return successfulVisibilityResponse(), nil
})
focusDone := make(chan OperationResult, 1)
go func() {
focusDone <- control.FocusRevision(1)
}()
select {
case <-acknowledgementStarted:
case <-time.After(time.Second):
t.Fatal("focus acknowledgement did not start")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(2)
}()
select {
case result := <-hideDone:
if !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("Hide result = %#v", result)
}
case <-time.After(25 * time.Millisecond):
close(releaseAcknowledgement)
<-focusDone
<-hideDone
t.Fatal("native hide waited for the focus acknowledgement network request")
}
select {
case <-hideCalled:
case <-time.After(time.Second):
t.Fatal("native hide callback was not called")
}
close(releaseAcknowledgement)
if result := <-focusDone; !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("FocusRevision result = %#v", result)
}
}
func TestDetachedChildClosePreventsConcurrentHideFromCancellingExit(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
quitStarted := make(chan struct{})
releaseQuit := make(chan struct{})
control.quit = func(context.Context) {
close(quitStarted)
<-releaseQuit
}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
closeDone := make(chan OperationResult, 1)
go func() {
closeDone <- control.Close()
}()
select {
case <-quitStarted:
case <-time.After(time.Second):
t.Fatal("native close did not reach quit")
}
if !control.closeGate.isAllowed() {
t.Fatal("close gate was not opened before quit")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case result := <-hideDone:
close(releaseQuit)
<-closeDone
t.Fatalf("hide completed while native close was in progress: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseQuit)
if result := <-closeDone; !result.Success {
t.Fatalf("Close result = %#v", result)
}
if result := <-hideDone; result.Success || !strings.Contains(result.Message, "already committed") {
t.Fatalf("Hide result after committed close = %#v", result)
}
if !control.closeGate.isAllowed() {
t.Fatal("concurrent hide cancelled the committed close gate")
}
select {
case <-hideCalled:
t.Fatal("concurrent hide reached the native window after close committed")
default:
}
}
func TestDetachedChildCloseFallbackPreventsConcurrentHideFromCancellingExit(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.closeFallbackDelay = time.Millisecond
quitStarted := make(chan struct{})
releaseQuit := make(chan struct{})
control.quit = func(context.Context) {
close(quitStarted)
<-releaseQuit
}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
control.scheduleCloseFallback(ctx)
select {
case <-quitStarted:
case <-time.After(time.Second):
t.Fatal("native close fallback did not reach quit")
}
if !control.closeGate.isAllowed() {
t.Fatal("close fallback did not open the gate before quit")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case result := <-hideDone:
close(releaseQuit)
t.Fatalf("hide completed while native close fallback was in progress: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseQuit)
if result := <-hideDone; result.Success || !strings.Contains(result.Message, "already committed") {
t.Fatalf("Hide result after fallback committed close = %#v", result)
}
if !control.closeGate.isAllowed() {
t.Fatal("concurrent hide cancelled the fallback close gate")
}
select {
case <-hideCalled:
t.Fatal("concurrent hide reached the native window after fallback close committed")
default:
}
}
func TestDetachedChildStaleCloseFallbackKeepsNewerTimer(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.closeFallbackDelay = time.Hour
quits := 0
control.quit = func(context.Context) { quits++ }
control.mu.Lock()
control.closeFallbackGeneration = 1
control.closeFallback = time.AfterFunc(time.Hour, func() {})
control.mu.Unlock()
control.visibilityOpMu.Lock()
staleFallbackDone := make(chan struct{})
go func() {
control.runCloseFallback(1, ctx)
close(staleFallbackDone)
}()
if result := control.CancelClose(); !result.Success {
control.visibilityOpMu.Unlock()
t.Fatalf("CancelClose result = %#v", result)
}
control.scheduleCloseFallback(ctx)
control.mu.RLock()
newGeneration := control.closeFallbackGeneration
newFallback := control.closeFallback
control.mu.RUnlock()
if newGeneration != 3 || newFallback == nil {
control.visibilityOpMu.Unlock()
t.Fatalf("new fallback state = generation %d timer %p, want 3/non-nil", newGeneration, newFallback)
}
control.visibilityOpMu.Unlock()
select {
case <-staleFallbackDone:
case <-time.After(time.Second):
t.Fatal("stale close fallback did not complete")
}
control.mu.RLock()
retainedFallback := control.closeFallback
retainedGeneration := control.closeFallbackGeneration
closeCommitted := control.closeCommitted
control.mu.RUnlock()
if retainedFallback != newFallback || retainedGeneration != newGeneration {
t.Fatalf(
"fallback after stale callback = generation %d timer %p, want %d/%p",
retainedGeneration,
retainedFallback,
newGeneration,
newFallback,
)
}
if closeCommitted || control.closeGate.isAllowed() || quits != 0 {
t.Fatalf(
"stale fallback exit state = committed %v gate %v quits %d, want false/false/0",
closeCommitted,
control.closeGate.isAllowed(),
quits,
)
}
if result := control.CancelClose(); !result.Success {
t.Fatalf("final CancelClose result = %#v", result)
}
}
func TestDetachedChildDoesNotPresentOrFocusAfterCloseCommitted(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
control.quit = func(context.Context) {}
control.markDOMReady(ctx)
if result := control.Close(); !result.Success {
t.Fatalf("Close result = %#v", result)
}
if result := control.markFrontendReady(); result.Success {
t.Fatalf("markFrontendReady after close = %#v, want failure", result)
}
if result := control.Present(); result.Success {
t.Fatalf("Present after close = %#v, want failure", result)
}
if result := control.FocusRevision(1); result.Success {
t.Fatalf("FocusRevision after close = %#v, want failure", result)
}
if shows != 0 || focuses != 0 {
t.Fatalf("post-close presentation = show %d focus %d, want 0/0", shows, focuses)
}
}
func TestDetachedChildPresentsBeforePaintReadyWithoutShowingTwice(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.markDOMReady(ctx)
shows := 0
control.showWindow = func(context.Context) { shows++ }
if result := control.Present(); !result.Success {
t.Fatalf("Present result = %#v", result)
}
if shows != 1 {
t.Fatalf("present shows = %d, want 1", shows)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if shows != 1 {
t.Fatalf("post-ready shows = %d, want 1", shows)
}
}
func TestDetachedChildPresentConsumesQueuedFocus(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := control.Focus(); !result.Success {
t.Fatalf("pre-present Focus result = %#v", result)
}
if shows != 0 || focuses != 0 {
t.Fatalf("queued focus presentation = show %d focus %d, want 0/0", shows, focuses)
}
if result := control.Present(); !result.Success {
t.Fatalf("Present result = %#v", result)
}
if shows != 1 || focuses != 1 {
t.Fatalf("presented queued focus = show %d focus %d, want 1/1", shows, focuses)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if shows != 1 || focuses != 1 {
t.Fatalf("post-ready presentation = show %d focus %d, want 1/1", shows, focuses)
}
if result := control.Focus(); !result.Success {
t.Fatalf("post-ready Focus result = %#v", result)
}
if shows != 1 || focuses != 2 {
t.Fatalf("repeated focus presentation = show %d focus %d, want 1/2", shows, focuses)
}
}
func TestDetachedChildWaitsForDOMReadyEvenAfterFrontendHandshake(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
posts := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
posts++
return successfulVisibilityResponse(), nil
})
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); result.Success {
t.Fatalf("pre-DOM ready Action result = %#v, want failure", result)
}
if result := control.Focus(); !result.Success {
t.Fatalf("pre-DOM Focus result = %#v", result)
}
if shows != 0 || focuses != 0 || posts != 0 {
t.Fatalf("pre-DOM state = show %d focus %d post %d, want 0/0/0", shows, focuses, posts)
}
control.markDOMReady(ctx)
if shows != 0 || focuses != 0 || posts != 0 {
t.Fatalf("DOM-ready state before retry = show %d focus %d post %d, want 0/0/0", shows, focuses, posts)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("retried ready Action result = %#v", result)
}
if shows != 1 || focuses != 1 {
t.Fatalf("DOM-ready presentation = show %d focus %d, want 1/1", shows, focuses)
}
if posts != 1 {
t.Fatalf("parent ready posts = %d, want 1", posts)
}
}
func TestDetachedChildShowsBeforePostingParentReady(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.markDOMReady(ctx)
steps := make([]string, 0, 2)
control.showWindow = func(context.Context) {
steps = append(steps, "show")
}
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
steps = append(steps, "post-parent-ready")
return successfulVisibilityResponse(), nil
})
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if got := strings.Join(steps, ","); got != "show,post-parent-ready" {
t.Fatalf("ready sequence = %q, want show,post-parent-ready", got)
}
}
func newVisibilityTestChild() (*Bridge, *Control) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "window-1",
Kind: "workbench",
})
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return successfulVisibilityResponse(), nil
})
control := newControl(bridge)
bridge.setReadyHandler(control.markFrontendReady)
return bridge, control
}
func successfulVisibilityResponse() *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"window-1"}`)),
Header: make(http.Header),
}
}