mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复
- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件 - 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡 - 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点 - 拒绝复用终态子进程,并恢复失败的附加与关闭操作 - 串行展示、隐藏和终态退出,消除关闭门闩并发竞态 - 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
This commit is contained in:
@@ -180,13 +180,17 @@ func (b *Bridge) control(request controlRequest) OperationResult {
|
||||
// Action acknowledges child readiness or forwards sync, hide, attach, or close
|
||||
// state to the main window.
|
||||
func (b *Bridge) Action(action string, payload any) OperationResult {
|
||||
return b.action(action, payload, true)
|
||||
}
|
||||
|
||||
func (b *Bridge) action(action string, payload any, grantForeground bool) OperationResult {
|
||||
normalizedAction := strings.ToLower(strings.TrimSpace(action))
|
||||
if normalizedAction == "ready" {
|
||||
if result := b.presentFrontendReady(); !result.Success {
|
||||
return result
|
||||
}
|
||||
}
|
||||
if normalizedAction == "open-ai-settings" && b.allowParentForeground != nil {
|
||||
if grantForeground && normalizedAction == "open-ai-settings" && b.allowParentForeground != nil {
|
||||
// The detached child owns the current user interaction on Windows. Grant
|
||||
// the parent permission immediately before it attempts to take focus. This
|
||||
// is best-effort so an OS rejection never blocks the settings action itself.
|
||||
@@ -201,10 +205,13 @@ func (b *Bridge) Action(action string, payload any) OperationResult {
|
||||
if status != http.StatusOK {
|
||||
return operationFailure(fmt.Sprintf("detached action failed with status %d", status))
|
||||
}
|
||||
if result.Success {
|
||||
if result.Success && (result.Applied == nil || *result.Applied) {
|
||||
b.mu.Lock()
|
||||
if normalizedAction == "attach" || normalizedAction == "close" {
|
||||
switch normalizedAction {
|
||||
case "attach", "close":
|
||||
b.terminal = normalizedAction
|
||||
case "cancel-close":
|
||||
b.terminal = ""
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
@@ -520,6 +527,7 @@ func (b *Bridge) emitRuntimeEvent(name string, args ...any) {
|
||||
// Wails window.
|
||||
type Control struct {
|
||||
mu sync.RWMutex
|
||||
visibilityOpMu sync.Mutex
|
||||
ctx context.Context
|
||||
bridge *Bridge
|
||||
closeGate closeGate
|
||||
@@ -581,6 +589,7 @@ func (c *Control) markDOMReady(ctx context.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.visibilityOpMu.Lock()
|
||||
c.mu.Lock()
|
||||
if c.ctx == nil {
|
||||
c.ctx = ctx
|
||||
@@ -588,22 +597,29 @@ func (c *Control) markDOMReady(ctx context.Context) {
|
||||
c.domReady = true
|
||||
presentation := c.takeInitialPresentationLocked()
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
c.runVisibilityPresentationLocked(presentation)
|
||||
}
|
||||
|
||||
func (c *Control) markFrontendReady() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.visibilityOpMu.Lock()
|
||||
c.mu.Lock()
|
||||
if c.closeCommitted {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return operationFailure("native window close is already committed")
|
||||
}
|
||||
if !c.domReady || c.ctx == nil || c.showWindow == nil {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return operationFailure("native window DOM is not ready")
|
||||
}
|
||||
c.frontendReady = true
|
||||
presentation := c.takeInitialPresentationLocked()
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
c.runVisibilityPresentationLocked(presentation)
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
@@ -614,13 +630,21 @@ func (c *Control) Present() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.visibilityOpMu.Lock()
|
||||
c.mu.Lock()
|
||||
if c.closeCommitted {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return operationFailure("native window close is already committed")
|
||||
}
|
||||
if !c.domReady || c.ctx == nil || c.showWindow == nil {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return operationFailure("native window DOM is not ready")
|
||||
}
|
||||
if c.visible {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
c.visible = true
|
||||
@@ -633,7 +657,7 @@ func (c *Control) Present() OperationResult {
|
||||
presentation.bridge = c.bridge
|
||||
}
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
c.runVisibilityPresentationLocked(presentation)
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
@@ -645,23 +669,37 @@ type childWindowPresentation struct {
|
||||
visibilityRevision uint64
|
||||
}
|
||||
|
||||
func (p childWindowPresentation) run() {
|
||||
func (p childWindowPresentation) runNative() {
|
||||
if p.show != nil {
|
||||
p.show(p.ctx)
|
||||
}
|
||||
if p.focus != nil {
|
||||
p.focus(p.ctx)
|
||||
if p.bridge != nil && p.visibilityRevision > 0 {
|
||||
// The parent must retain its pending focus until the native focus
|
||||
// callback has actually run. A failed acknowledgement deliberately
|
||||
// leaves that pending command available for the next SSE reconnect.
|
||||
_ = p.bridge.acknowledgeFocus(p.ctx, p.visibilityRevision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p childWindowPresentation) acknowledgeFocus() {
|
||||
if p.focus == nil || p.bridge == nil || p.visibilityRevision == 0 {
|
||||
return
|
||||
}
|
||||
// The parent must retain its pending focus until the native focus callback
|
||||
// has actually run. A failed acknowledgement deliberately leaves that
|
||||
// pending command available for the next SSE reconnect.
|
||||
_ = p.bridge.acknowledgeFocus(p.ctx, p.visibilityRevision)
|
||||
}
|
||||
|
||||
// runVisibilityPresentationLocked keeps state selection and native effects in
|
||||
// one visibility operation, then releases the lock before parent RPC.
|
||||
func (c *Control) runVisibilityPresentationLocked(presentation childWindowPresentation) {
|
||||
func() {
|
||||
defer c.visibilityOpMu.Unlock()
|
||||
presentation.runNative()
|
||||
}()
|
||||
presentation.acknowledgeFocus()
|
||||
}
|
||||
|
||||
func (c *Control) takeInitialPresentationLocked() childWindowPresentation {
|
||||
if c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
|
||||
if c.closeCommitted || c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
|
||||
return childWindowPresentation{}
|
||||
}
|
||||
c.visible = true
|
||||
@@ -680,6 +718,8 @@ func (c *Control) Close() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.visibilityOpMu.Lock()
|
||||
defer c.visibilityOpMu.Unlock()
|
||||
c.mu.Lock()
|
||||
ctx := c.ctx
|
||||
quit := c.quit
|
||||
@@ -705,6 +745,8 @@ func (c *Control) Hide(visibilityRevision uint64) OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.visibilityOpMu.Lock()
|
||||
defer c.visibilityOpMu.Unlock()
|
||||
c.mu.Lock()
|
||||
if visibilityRevision < c.visibilityRevision {
|
||||
currentRevision := c.visibilityRevision
|
||||
@@ -736,6 +778,53 @@ func (c *Control) Hide(visibilityRevision uint64) OperationResult {
|
||||
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
|
||||
}
|
||||
|
||||
// HideForAISettings parks the child before asking the parent to render its
|
||||
// settings modal. The request runs in Go after WindowHide, so WebView suspension
|
||||
// cannot leave the modal behind the detached window.
|
||||
func (c *Control) HideForAISettings(visibilityRevision uint64) OperationResult {
|
||||
if c == nil || c.bridge == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
bridge := c.bridge
|
||||
if bridge.allowParentForeground != nil {
|
||||
// Windows requires the currently foreground child to grant activation to
|
||||
// its parent before the child is hidden.
|
||||
_ = bridge.allowParentForeground()
|
||||
}
|
||||
hideResult := c.Hide(visibilityRevision)
|
||||
if !hideResult.Success {
|
||||
return hideResult
|
||||
}
|
||||
if hideResult.VisibilityRevision != visibilityRevision {
|
||||
failure := operationFailure("open AI settings was superseded by a newer window focus")
|
||||
failure.VisibilityRevision = hideResult.VisibilityRevision
|
||||
return failure
|
||||
}
|
||||
actionResult := bridge.action("open-ai-settings", map[string]any{
|
||||
"id": bridge.windowID,
|
||||
"kind": bridge.kind,
|
||||
"visibilityRevision": visibilityRevision,
|
||||
}, false)
|
||||
if actionResult.Success && (actionResult.Applied == nil || *actionResult.Applied) {
|
||||
return OperationResult{
|
||||
Success: true,
|
||||
ID: bridge.windowID,
|
||||
VisibilityRevision: visibilityRevision,
|
||||
}
|
||||
}
|
||||
|
||||
// Do not strand the user in a hidden child when the parent request fails.
|
||||
restoreResult := bridge.FocusWindow(bridge.windowID)
|
||||
if restoreResult.Success {
|
||||
_ = c.FocusRevision(restoreResult.VisibilityRevision)
|
||||
} else {
|
||||
_ = c.FocusRevision(visibilityRevision)
|
||||
}
|
||||
failure := operationFailure(fmt.Sprintf("open AI settings failed: %s", actionResult.Message))
|
||||
failure.VisibilityRevision = visibilityRevision
|
||||
return failure
|
||||
}
|
||||
|
||||
// CancelClose keeps the child alive after a failed final frontend flush. It
|
||||
// also invalidates any native-close fallback so a retry starts a fresh grace
|
||||
// period instead of inheriting the old timeout.
|
||||
@@ -799,34 +888,42 @@ func (c *Control) scheduleCloseFallback(fallbackCtx context.Context) {
|
||||
c.closeFallbackGeneration++
|
||||
generation := c.closeFallbackGeneration
|
||||
c.closeFallback = time.AfterFunc(delay, func() {
|
||||
c.mu.Lock()
|
||||
if c.closeCommitted ||
|
||||
c.closeFallbackGeneration != generation ||
|
||||
c.closeGate.isAllowed() {
|
||||
c.closeFallback = nil
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closeFallback = nil
|
||||
c.closeCommitted = true
|
||||
ctx := c.ctx
|
||||
quit := c.quit
|
||||
c.mu.Unlock()
|
||||
|
||||
c.closeGate.allow()
|
||||
if c.bridge != nil {
|
||||
c.bridge.notifyClosing()
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = fallbackCtx
|
||||
}
|
||||
if ctx != nil && quit != nil {
|
||||
quit(ctx)
|
||||
}
|
||||
c.runCloseFallback(generation, fallbackCtx)
|
||||
})
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Control) runCloseFallback(generation uint64, fallbackCtx context.Context) {
|
||||
c.visibilityOpMu.Lock()
|
||||
defer c.visibilityOpMu.Unlock()
|
||||
c.mu.Lock()
|
||||
if c.closeFallbackGeneration != generation {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if c.closeCommitted || c.closeGate.isAllowed() {
|
||||
c.closeFallback = nil
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closeFallback = nil
|
||||
c.closeCommitted = true
|
||||
ctx := c.ctx
|
||||
quit := c.quit
|
||||
c.mu.Unlock()
|
||||
|
||||
c.closeGate.allow()
|
||||
if c.bridge != nil {
|
||||
c.bridge.notifyClosing()
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = fallbackCtx
|
||||
}
|
||||
if ctx != nil && quit != nil {
|
||||
quit(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Control) invalidateCloseFallbackLocked() {
|
||||
c.closeFallbackGeneration++
|
||||
if c.closeFallback != nil {
|
||||
@@ -851,10 +948,17 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.visibilityOpMu.Lock()
|
||||
c.mu.Lock()
|
||||
if c.closeCommitted {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return operationFailure("native window close is already committed")
|
||||
}
|
||||
if visibilityRevision < c.visibilityRevision {
|
||||
currentRevision := c.visibilityRevision
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return OperationResult{
|
||||
Success: true,
|
||||
Message: "stale native window focus ignored",
|
||||
@@ -866,6 +970,7 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
|
||||
focus := c.focusWindow
|
||||
if ctx == nil || focus == nil {
|
||||
c.mu.Unlock()
|
||||
c.visibilityOpMu.Unlock()
|
||||
return operationFailure("native window is not ready")
|
||||
}
|
||||
if !c.visible {
|
||||
@@ -873,7 +978,7 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
|
||||
c.focusPendingRevision = visibilityRevision
|
||||
presentation := c.takeInitialPresentationLocked()
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
c.runVisibilityPresentationLocked(presentation)
|
||||
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
|
||||
}
|
||||
c.focusPending = false
|
||||
@@ -885,6 +990,6 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
|
||||
visibilityRevision: visibilityRevision,
|
||||
}
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
c.runVisibilityPresentationLocked(presentation)
|
||||
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,96 @@ func TestControlCancelCloseInvalidatesFallbackAndAllowsRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeCancelCloseResetsTerminalFallback(t *testing.T) {
|
||||
requests := make(chan actionRequest, 3)
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
bridge.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
defer r.Body.Close()
|
||||
var request actionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode fallback action: %v", err)
|
||||
}
|
||||
requests <- request
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"ai-chat"}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
control := newControl(bridge)
|
||||
|
||||
if result := bridge.Action("attach", map[string]any{"revision": 1}); !result.Success {
|
||||
t.Fatalf("attach Action result = %#v", result)
|
||||
}
|
||||
if result := bridge.Action("cancel-close", map[string]any{"revision": 2}); !result.Success {
|
||||
t.Fatalf("cancel-close Action result = %#v", result)
|
||||
}
|
||||
if result := control.CancelClose(); !result.Success {
|
||||
t.Fatalf("CancelClose result = %#v", result)
|
||||
}
|
||||
bridge.notifyClosing()
|
||||
|
||||
for _, expectedAction := range []string{"attach", "cancel-close", "close"} {
|
||||
select {
|
||||
case request := <-requests:
|
||||
if request.Action != expectedAction {
|
||||
t.Fatalf("action = %q, want %q", request.Action, expectedAction)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing %q action after terminal rollback", expectedAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeIgnoredTerminalActionDoesNotSuppressCloseFallback(t *testing.T) {
|
||||
requests := make(chan actionRequest, 2)
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
bridge.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
defer r.Body.Close()
|
||||
var request actionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode action: %v", err)
|
||||
}
|
||||
requests <- request
|
||||
body := `{"success":true,"id":"ai-chat"}`
|
||||
if request.Action == "attach" {
|
||||
body = `{"success":true,"applied":false,"message":"stale detached action ignored","id":"ai-chat"}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := bridge.Action("attach", map[string]any{"revision": 1})
|
||||
if !result.Success || result.Applied == nil || *result.Applied {
|
||||
t.Fatalf("ignored attach result = %#v", result)
|
||||
}
|
||||
bridge.notifyClosing()
|
||||
|
||||
for _, expectedAction := range []string{"attach", "close"} {
|
||||
select {
|
||||
case request := <-requests:
|
||||
if request.Action != expectedAction {
|
||||
t.Fatalf("action = %q, want %q", request.Action, expectedAction)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing %q action after ignored terminal action", expectedAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyClosingStillSendsOneFallbackAction(t *testing.T) {
|
||||
requests := make(chan actionRequest, 2)
|
||||
bridge := newBridge(ChildOptions{
|
||||
|
||||
@@ -1,13 +1,201 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControlHidesBeforeOpeningAISettingsInParent(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
control := newControl(bridge)
|
||||
InitializeControl(control, context.Background())
|
||||
steps := make([]string, 0, 3)
|
||||
bridge.allowParentForeground = func() error {
|
||||
steps = append(steps, "allow-parent-foreground")
|
||||
return nil
|
||||
}
|
||||
control.hideWindow = func(context.Context) {
|
||||
steps = append(steps, "hide-window")
|
||||
}
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path != ActionPath {
|
||||
t.Fatalf("unexpected request path %q", request.URL.Path)
|
||||
}
|
||||
steps = append(steps, "post-action")
|
||||
return successfulForegroundActionResponse(), nil
|
||||
})
|
||||
|
||||
result := control.HideForAISettings(7)
|
||||
if !result.Success || result.VisibilityRevision != 7 {
|
||||
t.Fatalf("HideForAISettings result = %#v", result)
|
||||
}
|
||||
if got := strings.Join(steps, ","); got != "allow-parent-foreground,hide-window,post-action" {
|
||||
t.Fatalf("HideForAISettings sequence = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlDoesNotOpenAISettingsAfterHideIsSupersededByFocus(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
control := newControl(bridge)
|
||||
InitializeControl(control, context.Background())
|
||||
control.visibilityRevision = 8
|
||||
control.visible = true
|
||||
hides := 0
|
||||
posts := 0
|
||||
control.hideWindow = func(context.Context) { hides++ }
|
||||
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
posts++
|
||||
return successfulForegroundActionResponse(), nil
|
||||
})
|
||||
|
||||
result := control.HideForAISettings(7)
|
||||
if result.Success || !strings.Contains(result.Message, "superseded") {
|
||||
t.Fatalf("stale HideForAISettings result = %#v", result)
|
||||
}
|
||||
if hides != 0 || posts != 0 {
|
||||
t.Fatalf("stale settings action reached native/parent: hides=%d posts=%d", hides, posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRestoresAIWindowWhenOpeningSettingsFails(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
control := newControl(bridge)
|
||||
ctx := context.Background()
|
||||
InitializeControl(control, ctx)
|
||||
steps := make([]string, 0, 8)
|
||||
control.showWindow = func(context.Context) { steps = append(steps, "show-window") }
|
||||
control.hideWindow = func(context.Context) { steps = append(steps, "hide-window") }
|
||||
control.focusWindow = func(context.Context) { steps = append(steps, "focus-window") }
|
||||
control.markDOMReady(ctx)
|
||||
if result := control.markFrontendReady(); !result.Success {
|
||||
t.Fatalf("markFrontendReady result = %#v", result)
|
||||
}
|
||||
steps = steps[:0]
|
||||
bridge.allowParentForeground = func() error {
|
||||
steps = append(steps, "allow-parent-foreground")
|
||||
return nil
|
||||
}
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch request.URL.Path {
|
||||
case ActionPath:
|
||||
steps = append(steps, "post-action")
|
||||
return nil, errors.New("parent unavailable")
|
||||
case ControlPath:
|
||||
steps = append(steps, "focus-parent")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"success":true,"id":"ai-chat","visibilityRevision":8}`,
|
||||
)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
case CommandStatePath:
|
||||
steps = append(steps, "ack-focus")
|
||||
return successfulForegroundActionResponse(), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request path %q", request.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
result := control.HideForAISettings(7)
|
||||
if result.Success || !strings.Contains(result.Message, "parent unavailable") {
|
||||
t.Fatalf("failed HideForAISettings result = %#v", result)
|
||||
}
|
||||
if got := strings.Join(steps, ","); got != "allow-parent-foreground,hide-window,post-action,focus-parent,show-window,focus-window,ack-focus" {
|
||||
t.Fatalf("failed HideForAISettings recovery sequence = %q", got)
|
||||
}
|
||||
control.mu.RLock()
|
||||
visible := control.visible
|
||||
revision := control.visibilityRevision
|
||||
control.mu.RUnlock()
|
||||
if !visible || revision != 8 {
|
||||
t.Fatalf("restored visibility = visible %v revision %d, want true/8", visible, revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRestoresParentAndChildVisibilityThroughAuthenticatedSelfFocus(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
Title: "GoNavi AI",
|
||||
Hidden: true,
|
||||
},
|
||||
visibilityRevision: 8,
|
||||
}
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: manager.token,
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
request.RemoteAddr = "127.0.0.1:51003"
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
return recorder.Result(), nil
|
||||
})
|
||||
control := newControl(bridge)
|
||||
ctx := context.Background()
|
||||
InitializeControl(control, ctx)
|
||||
control.showWindow = func(context.Context) {}
|
||||
control.hideWindow = func(context.Context) {}
|
||||
control.focusWindow = func(context.Context) {}
|
||||
control.markDOMReady(ctx)
|
||||
if result := control.markFrontendReady(); !result.Success {
|
||||
t.Fatalf("markFrontendReady result = %#v", result)
|
||||
}
|
||||
|
||||
result := control.HideForAISettings(7)
|
||||
if result.Success || !strings.Contains(result.Message, "ignored after a newer visibility action") {
|
||||
t.Fatalf("superseded HideForAISettings result = %#v", result)
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
managerEntry := manager.windows["ai-chat"]
|
||||
managerHidden := managerEntry.info.Hidden
|
||||
managerRevision := managerEntry.visibilityRevision
|
||||
pendingFocusRevision := managerEntry.pendingFocusRevision
|
||||
manager.mu.RUnlock()
|
||||
if managerHidden || managerRevision != 9 || pendingFocusRevision != 0 {
|
||||
t.Fatalf(
|
||||
"restored manager visibility = hidden %v revision %d pending %d, want false/9/0",
|
||||
managerHidden,
|
||||
managerRevision,
|
||||
pendingFocusRevision,
|
||||
)
|
||||
}
|
||||
control.mu.RLock()
|
||||
childVisible := control.visible
|
||||
childRevision := control.visibilityRevision
|
||||
control.mu.RUnlock()
|
||||
if !childVisible || childRevision != 9 {
|
||||
t.Fatalf("restored child visibility = visible %v revision %d, want true/9", childVisible, childRevision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeAllowsParentForegroundImmediatelyBeforeOpeningAISettings(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
const (
|
||||
defaultGracefulCloseTimeout = 10 * time.Second
|
||||
defaultOpenReadyTimeout = 10 * time.Second
|
||||
staleDetachedActionMessage = "stale detached action ignored"
|
||||
)
|
||||
|
||||
type processExit struct {
|
||||
@@ -295,7 +296,7 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
|
||||
return operationFailure("native window manager is not running")
|
||||
}
|
||||
if existing, exists := m.windows[request.ID]; exists {
|
||||
if existing.info.CloseSent {
|
||||
if windowEntryIsTerminating(existing) {
|
||||
m.mu.Unlock()
|
||||
return closingWindowRetryFailure(request.ID)
|
||||
}
|
||||
@@ -422,7 +423,7 @@ func (m *Manager) Focus(id string) OperationResult {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
if entry.info.CloseSent {
|
||||
if windowEntryIsTerminating(entry) {
|
||||
m.mu.Unlock()
|
||||
return closingWindowRetryFailure(id)
|
||||
}
|
||||
@@ -434,6 +435,7 @@ func (m *Manager) Focus(id string) OperationResult {
|
||||
entry.info.Hidden = false
|
||||
entry.pendingFocusRevision = entry.visibilityRevision
|
||||
visibilityRevision := entry.visibilityRevision
|
||||
kind := entry.info.Kind
|
||||
bounds := windowBoundsFromInfo(entry.info)
|
||||
emitToChild := m.emitToChild
|
||||
shared := m.shared
|
||||
@@ -449,6 +451,14 @@ func (m *Manager) Focus(id string) OperationResult {
|
||||
shared.EmitTo(id, CommandEventName, command)
|
||||
}
|
||||
if wasHidden {
|
||||
m.emitDetached(Event{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Action: "focus",
|
||||
Payload: visibilityCommandPayload{
|
||||
VisibilityRevision: visibilityRevision,
|
||||
},
|
||||
})
|
||||
publishDetachedDockMenuSnapshot(m)
|
||||
}
|
||||
return OperationResult{
|
||||
@@ -591,6 +601,10 @@ func (m *Manager) CancelClose(id string) OperationResult {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
if m.closing || entry.exitReason == ExitReasonParentShutdown {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window manager shutdown cannot be cancelled")
|
||||
}
|
||||
m.cancelCloseLocked(entry)
|
||||
m.mu.Unlock()
|
||||
publishDetachedDockMenuSnapshot(m)
|
||||
@@ -601,11 +615,36 @@ func (m *Manager) cancelCloseLocked(entry *windowEntry) {
|
||||
entry.closeGeneration++
|
||||
entry.info.CloseSent = false
|
||||
switch entry.exitReason {
|
||||
case ExitReasonRequested, ExitReasonParentShutdown, ExitReasonAttached, ExitReasonWindowClosed:
|
||||
case ExitReasonRequested, ExitReasonAttached, ExitReasonWindowClosed:
|
||||
entry.exitReason = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) cancelCloseActionLocked(entry *windowEntry, rollbackAction string) bool {
|
||||
if m.closing || entry == nil || entry.exitReason == ExitReasonParentShutdown {
|
||||
return false
|
||||
}
|
||||
switch rollbackAction {
|
||||
case "attach":
|
||||
if entry.info.CloseSent || (entry.exitReason != "" && entry.exitReason != ExitReasonAttached) {
|
||||
return false
|
||||
}
|
||||
case "close":
|
||||
validRequestedClose := entry.info.CloseSent && entry.exitReason == ExitReasonRequested
|
||||
validWindowClose := !entry.info.CloseSent && entry.exitReason == ExitReasonWindowClosed
|
||||
alreadyOpen := !entry.info.CloseSent && entry.exitReason == ""
|
||||
if !validRequestedClose && !validWindowClose && !alreadyOpen {
|
||||
return false
|
||||
}
|
||||
case "":
|
||||
// Compatibility with children started before rollbackAction was added.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
m.cancelCloseLocked(entry)
|
||||
return true
|
||||
}
|
||||
|
||||
// CloseAll requests graceful shutdown of every detached child.
|
||||
func (m *Manager) CloseAll() OperationResult {
|
||||
if m == nil {
|
||||
@@ -831,6 +870,10 @@ func operationFailure(message string) OperationResult {
|
||||
return OperationResult{Success: false, Message: message}
|
||||
}
|
||||
|
||||
func windowEntryIsTerminating(entry *windowEntry) bool {
|
||||
return entry != nil && (entry.info.CloseSent || entry.exitReason != "")
|
||||
}
|
||||
|
||||
func closingWindowRetryFailure(id string) OperationResult {
|
||||
return OperationResult{
|
||||
Success: false,
|
||||
@@ -993,7 +1036,13 @@ func (m *Manager) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unknown detached window", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
bootstrap := Bootstrap{ID: entry.info.ID, Kind: entry.info.Kind, Title: entry.info.Title, Payload: entry.payload}
|
||||
bootstrap := Bootstrap{
|
||||
ID: entry.info.ID,
|
||||
Kind: entry.info.Kind,
|
||||
Title: entry.info.Title,
|
||||
Payload: entry.payload,
|
||||
ActionRevision: entry.actionRevision,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -1146,6 +1195,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
|
||||
revision := positiveActionRevision(request.Payload)
|
||||
requestedAISettingsVisibilityRevision := uint64(0)
|
||||
m.mu.Lock()
|
||||
entry, exists := m.windows[id]
|
||||
if !exists {
|
||||
@@ -1153,6 +1203,37 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unknown detached window", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if request.Action == "open-ai-settings" {
|
||||
requestedAISettingsVisibilityRevision = positiveVisibilityRevision(request.Payload)
|
||||
if requestedAISettingsVisibilityRevision == 0 ||
|
||||
requestedAISettingsVisibilityRevision != entry.visibilityRevision ||
|
||||
!entry.info.Hidden {
|
||||
visibilityRevision := entry.visibilityRevision
|
||||
m.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{
|
||||
Success: true,
|
||||
Applied: operationApplied(false),
|
||||
ID: id,
|
||||
Message: "open AI settings ignored after a newer visibility action",
|
||||
VisibilityRevision: visibilityRevision,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if request.Action == "cancel-close" && (m.closing || entry.exitReason == ExitReasonParentShutdown) {
|
||||
visibilityRevision := entry.visibilityRevision
|
||||
m.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{
|
||||
Success: true,
|
||||
Applied: operationApplied(false),
|
||||
ID: id,
|
||||
Message: "detached close cancellation ignored while parent is closing",
|
||||
VisibilityRevision: visibilityRevision,
|
||||
})
|
||||
return
|
||||
}
|
||||
if actionUsesRevision(request.Action) && revision > 0 {
|
||||
if revision <= entry.actionRevision {
|
||||
visibilityRevision := entry.visibilityRevision
|
||||
@@ -1160,8 +1241,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{
|
||||
Success: true,
|
||||
Applied: operationApplied(false),
|
||||
ID: id,
|
||||
Message: "stale detached action ignored",
|
||||
Message: staleDetachedActionMessage,
|
||||
VisibilityRevision: visibilityRevision,
|
||||
})
|
||||
return
|
||||
@@ -1169,7 +1251,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
entry.actionRevision = revision
|
||||
}
|
||||
eventAction := request.Action
|
||||
visibilityRevision := uint64(0)
|
||||
visibilityRevision := requestedAISettingsVisibilityRevision
|
||||
if request.Action == "ready" {
|
||||
entry.info.Ready = true
|
||||
entry.readyOnce.Do(func() {
|
||||
@@ -1178,7 +1260,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
})
|
||||
} else if request.Action == "attach" {
|
||||
entry.exitReason = ExitReasonAttached
|
||||
if entry.exitReason == "" {
|
||||
entry.exitReason = ExitReasonAttached
|
||||
}
|
||||
entry.pendingFocusRevision = 0
|
||||
} else if request.Action == "close" && entry.exitReason == "" {
|
||||
entry.exitReason = ExitReasonWindowClosed
|
||||
@@ -1206,7 +1290,19 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
request.Payload = withVisibilityRevision(request.Payload, visibilityRevision)
|
||||
} else if request.Action == "cancel-close" {
|
||||
m.cancelCloseLocked(entry)
|
||||
if !m.cancelCloseActionLocked(entry, rollbackActionFromPayload(request.Payload)) {
|
||||
visibilityRevision = entry.visibilityRevision
|
||||
m.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{
|
||||
Success: true,
|
||||
Applied: operationApplied(false),
|
||||
ID: id,
|
||||
Message: "detached close cancellation no longer matches the active close",
|
||||
VisibilityRevision: visibilityRevision,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
info := entry.info
|
||||
ownerID := entry.ownerID
|
||||
@@ -1224,15 +1320,40 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
applied := revisionedActionApplied(request.Action, revision)
|
||||
if request.Action == "open-ai-settings" {
|
||||
applied = operationApplied(true)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{
|
||||
Success: true,
|
||||
Applied: applied,
|
||||
ID: id,
|
||||
VisibilityRevision: visibilityRevision,
|
||||
})
|
||||
}
|
||||
|
||||
func actionUsesRevision(action string) bool {
|
||||
return action == "sync" || action == "attach" || action == "close" || action == "hide"
|
||||
return action == "sync" || action == "attach" || action == "close" || action == "hide" || action == "cancel-close"
|
||||
}
|
||||
|
||||
func operationApplied(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func revisionedActionApplied(action string, revision int64) *bool {
|
||||
if !actionUsesRevision(action) || revision <= 0 {
|
||||
return nil
|
||||
}
|
||||
return operationApplied(true)
|
||||
}
|
||||
|
||||
func rollbackActionFromPayload(payload any) string {
|
||||
record, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rollbackAction, _ := record["rollbackAction"].(string)
|
||||
return strings.ToLower(strings.TrimSpace(rollbackAction))
|
||||
}
|
||||
|
||||
func positiveVisibilityRevision(payload any) uint64 {
|
||||
@@ -1355,7 +1476,7 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
case "focus":
|
||||
if !m.ownsWindow(request.ID, ownerID) {
|
||||
if !m.canFocusWindow(request.ID, ownerID) {
|
||||
result = operationFailure("native window is not owned by this window")
|
||||
break
|
||||
}
|
||||
@@ -1429,6 +1550,18 @@ func (m *Manager) ownsWindow(id string, ownerID string) bool {
|
||||
return exists && entry.ownerID == strings.TrimSpace(ownerID)
|
||||
}
|
||||
|
||||
func (m *Manager) canFocusWindow(id string, ownerID string) bool {
|
||||
id = strings.TrimSpace(id)
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if id == "" || ownerID == "" {
|
||||
return false
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
entry, exists := m.windows[id]
|
||||
return exists && (id == ownerID || entry.ownerID == ownerID)
|
||||
}
|
||||
|
||||
func (m *Manager) closeOwned(ownerID string) OperationResult {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID == "" {
|
||||
|
||||
@@ -486,6 +486,13 @@ func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T)
|
||||
},
|
||||
}
|
||||
commands := make(chan childCommand, 3)
|
||||
events := make(chan Event, 2)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
events <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
manager.emitToChild = func(targetID string, name string, args ...any) {
|
||||
if targetID != "ai-chat" || name != CommandEventName {
|
||||
t.Fatalf("unexpected target event %q %q", targetID, name)
|
||||
@@ -522,6 +529,11 @@ func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T)
|
||||
focusCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 2 {
|
||||
t.Fatalf("focus command = %#v", focusCommand)
|
||||
}
|
||||
focusEvent := receiveEvent(t, events)
|
||||
if focusEvent.ID != "ai-chat" || focusEvent.Kind != "ai-chat" || focusEvent.Action != "focus" ||
|
||||
positiveVisibilityRevision(focusEvent.Payload) != 2 {
|
||||
t.Fatalf("focus lifecycle event = %#v", focusEvent)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
hidden := manager.windows["ai-chat"].info.Hidden
|
||||
manager.mu.RUnlock()
|
||||
@@ -762,6 +774,10 @@ func TestStaleHideActionCannotOverrideNewerFocus(t *testing.T) {
|
||||
if result := manager.Focus("ai-chat"); !result.Success || result.VisibilityRevision != 2 {
|
||||
t.Fatalf("Focus result = %#v", result)
|
||||
}
|
||||
focusEvent := receiveEvent(t, events)
|
||||
if focusEvent.Action != "focus" || positiveVisibilityRevision(focusEvent.Payload) != 2 {
|
||||
t.Fatalf("Focus event = %#v", focusEvent)
|
||||
}
|
||||
body := strings.NewReader(
|
||||
`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":2,"visibilityRevision":1}}`,
|
||||
)
|
||||
@@ -824,9 +840,10 @@ func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) {
|
||||
func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["window-1"] = &windowEntry{
|
||||
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
|
||||
payload: map[string]any{"value": "shared"},
|
||||
ready: make(chan struct{}),
|
||||
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
|
||||
payload: map[string]any{"value": "shared"},
|
||||
actionRevision: 17,
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
handler := manager.authenticatedHandler()
|
||||
|
||||
@@ -862,7 +879,9 @@ func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing
|
||||
if validRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("valid bootstrap status = %d body=%s", validRecorder.Code, validRecorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) || !strings.Contains(validRecorder.Body.String(), `"value":"shared"`) {
|
||||
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) ||
|
||||
!strings.Contains(validRecorder.Body.String(), `"value":"shared"`) ||
|
||||
!strings.Contains(validRecorder.Body.String(), `"actionRevision":17`) {
|
||||
t.Fatalf("unexpected bootstrap body: %s", validRecorder.Body.String())
|
||||
}
|
||||
select {
|
||||
@@ -888,8 +907,9 @@ func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing
|
||||
func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
|
||||
ready: make(chan struct{}),
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI", Hidden: true},
|
||||
ready: make(chan struct{}),
|
||||
visibilityRevision: 7,
|
||||
}
|
||||
events := make(chan Event, 1)
|
||||
manager.runtimeCtx = context.Background()
|
||||
@@ -899,7 +919,7 @@ func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat"}}`)
|
||||
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat","visibilityRevision":7}}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
@@ -918,6 +938,50 @@ func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAISettingsActionIsIgnoredAfterANewerFocus(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Hidden: true},
|
||||
ready: make(chan struct{}),
|
||||
visibilityRevision: 7,
|
||||
}
|
||||
events := make(chan Event, 1)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
events <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
|
||||
focusResult := manager.Focus("ai-chat")
|
||||
if !focusResult.Success || focusResult.VisibilityRevision != 8 {
|
||||
t.Fatalf("Focus result = %#v", focusResult)
|
||||
}
|
||||
focusEvent := receiveEvent(t, events)
|
||||
if focusEvent.Action != "focus" || positiveVisibilityRevision(focusEvent.Payload) != 8 {
|
||||
t.Fatalf("Focus event = %#v", focusEvent)
|
||||
}
|
||||
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat","visibilityRevision":7}}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("stale open-ai-settings status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var result OperationResult
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode stale open-ai-settings result: %v", err)
|
||||
}
|
||||
if !result.Success || result.Applied == nil || *result.Applied {
|
||||
t.Fatalf("stale open-ai-settings result = %#v", result)
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
t.Fatalf("stale open-ai-settings emitted event: %#v", event)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.started = true
|
||||
@@ -1240,6 +1304,90 @@ func TestCancelCloseActionClearsPendingStateAndNotifiesMainWindow(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCloseActionCannotRollbackNewerTerminalState(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["workbench:query-1"] = &windowEntry{
|
||||
info: WindowInfo{ID: "workbench:query-1", Kind: "workbench", CloseSent: true},
|
||||
exitReason: ExitReasonRequested,
|
||||
actionRevision: 12,
|
||||
closeGeneration: 4,
|
||||
}
|
||||
events := make(chan Event, 1)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
events <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"action":"cancel-close","payload":{"id":"workbench:query-1","revision":11,"rollbackAction":"attach"}}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "workbench:query-1", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("stale cancel-close status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var result OperationResult
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode stale cancel-close result: %v", err)
|
||||
}
|
||||
if !result.Success || result.Applied == nil || *result.Applied {
|
||||
t.Fatalf("stale cancel-close result = %#v, want ignored success", result)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
entry := manager.windows["workbench:query-1"]
|
||||
closeSent := entry.info.CloseSent
|
||||
exitReason := entry.exitReason
|
||||
revision := entry.actionRevision
|
||||
closeGeneration := entry.closeGeneration
|
||||
manager.mu.RUnlock()
|
||||
if !closeSent || exitReason != ExitReasonRequested || revision != 12 || closeGeneration != 4 {
|
||||
t.Fatalf(
|
||||
"stale cancel changed state: closeSent=%v reason=%q revision=%d generation=%d",
|
||||
closeSent,
|
||||
exitReason,
|
||||
revision,
|
||||
closeGeneration,
|
||||
)
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
t.Fatalf("stale cancel emitted event: %#v", event)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCloseActionCannotCancelParentShutdown(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.closing = true
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", CloseSent: true},
|
||||
exitReason: ExitReasonParentShutdown,
|
||||
actionRevision: 8,
|
||||
closeGeneration: 5,
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{"action":"cancel-close","payload":{"id":"ai-chat","revision":9,"rollbackAction":"close"}}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("shutdown cancel-close status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var result OperationResult
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode shutdown cancel-close result: %v", err)
|
||||
}
|
||||
if !result.Success || result.Applied == nil || *result.Applied {
|
||||
t.Fatalf("shutdown cancel-close result = %#v, want ignored success", result)
|
||||
}
|
||||
entry := manager.windows["ai-chat"]
|
||||
if !entry.info.CloseSent || entry.exitReason != ExitReasonParentShutdown ||
|
||||
entry.actionRevision != 8 || entry.closeGeneration != 5 {
|
||||
t.Fatalf("shutdown cancellation changed entry: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostEventActionIsForwardedWithoutChangingTerminalState(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
@@ -1407,6 +1555,37 @@ func TestActionRevisionPreventsStaleTerminalTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDoesNotReuseChildAfterTerminalActionIsAccepted(t *testing.T) {
|
||||
for _, action := range []string{"attach", "close"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.started = true
|
||||
manager.endpoint = "http://127.0.0.1:43119"
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
|
||||
}
|
||||
|
||||
body := strings.NewReader(fmt.Sprintf(
|
||||
`{"action":%q,"payload":{"revision":1}}`,
|
||||
action,
|
||||
))
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("%s action status = %d body=%s", action, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
if result := manager.Focus("ai-chat"); result.Success || !strings.Contains(result.Message, "retry") {
|
||||
t.Errorf("Focus after %s result = %#v, want retry failure", action, result)
|
||||
}
|
||||
if result := manager.Open(OpenRequest{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"}); result.Success || !strings.Contains(result.Message, "retry") {
|
||||
t.Errorf("Open after %s result = %#v, want retry failure", action, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerShutdownAllowsGracefulChildExitBeforeKilling(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.shutdownGracePeriod = 250 * time.Millisecond
|
||||
|
||||
@@ -89,10 +89,11 @@ type WindowInfo struct {
|
||||
// Bootstrap is fetched by the child after Wails has installed its native
|
||||
// runtime and bindings.
|
||||
type Bootstrap struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
ActionRevision int64 `json:"actionRevision,omitempty"`
|
||||
}
|
||||
|
||||
// OperationResult is returned by the Wails-bound Manager commands.
|
||||
@@ -102,6 +103,9 @@ type OperationResult struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Bounds *WindowBounds `json:"bounds,omitempty"`
|
||||
VisibilityRevision uint64 `json:"visibilityRevision,omitempty"`
|
||||
// Applied is set only for revisioned child actions. A nil value preserves
|
||||
// compatibility with ordinary manager/control results and older parents.
|
||||
Applied *bool `json:"applied,omitempty"`
|
||||
}
|
||||
|
||||
// HostStateRequest carries main-window state that an active detached child
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDetachedChildQueuesFocusUntilFrontendReadyHandshake(t *testing.T) {
|
||||
@@ -223,6 +224,339 @@ func TestDetachedChildIgnoresLateHideAfterNewerFocus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user