mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件 - 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡 - 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点 - 拒绝复用终态子进程,并恢复失败的附加与关闭操作 - 串行展示、隐藏和终态退出,消除关闭门闩并发竞态 - 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
1822 lines
62 KiB
Go
1822 lines
62 KiB
Go
package nativewindow
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
aiservice "GoNavi-Wails/internal/ai/service"
|
|
appcore "GoNavi-Wails/internal/app"
|
|
)
|
|
|
|
type fakeProcessStarter struct {
|
|
mu sync.Mutex
|
|
nextPID int
|
|
specs []processSpec
|
|
processes []*fakeChildProcess
|
|
onStart func(processSpec, *fakeChildProcess)
|
|
}
|
|
|
|
func (s *fakeProcessStarter) Start(spec processSpec) (childProcess, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nextPID++
|
|
process := &fakeChildProcess{pid: s.nextPID, done: make(chan error, 1), killed: make(chan struct{})}
|
|
s.specs = append(s.specs, spec)
|
|
s.processes = append(s.processes, process)
|
|
onStart := s.onStart
|
|
if onStart != nil {
|
|
go onStart(spec, process)
|
|
}
|
|
return process, nil
|
|
}
|
|
|
|
type fakeChildProcess struct {
|
|
pid int
|
|
done chan error
|
|
killed chan struct{}
|
|
killOnce sync.Once
|
|
}
|
|
|
|
type readyBeforeReturnStarter struct {
|
|
manager *Manager
|
|
mu sync.Mutex
|
|
nextPID int
|
|
started []*fakeChildProcess
|
|
skipReadySignal bool
|
|
}
|
|
|
|
func (s *readyBeforeReturnStarter) Start(spec processSpec) (childProcess, error) {
|
|
s.mu.Lock()
|
|
s.nextPID++
|
|
process := &fakeChildProcess{
|
|
pid: s.nextPID,
|
|
done: make(chan error, 1),
|
|
killed: make(chan struct{}),
|
|
}
|
|
s.started = append(s.started, process)
|
|
s.mu.Unlock()
|
|
|
|
id := environmentValue(spec.Env, envWindowID)
|
|
s.manager.mu.Lock()
|
|
entry := s.manager.windows[id]
|
|
if entry != nil {
|
|
entry.info.Ready = true
|
|
if !s.skipReadySignal {
|
|
entry.readyOnce.Do(func() { close(entry.ready) })
|
|
}
|
|
}
|
|
s.manager.mu.Unlock()
|
|
return process, nil
|
|
}
|
|
|
|
func (p *fakeChildProcess) PID() int { return p.pid }
|
|
func (p *fakeChildProcess) Wait() error {
|
|
return <-p.done
|
|
}
|
|
func (p *fakeChildProcess) Kill() error {
|
|
p.killOnce.Do(func() {
|
|
close(p.killed)
|
|
p.done <- errors.New("killed")
|
|
})
|
|
return nil
|
|
}
|
|
func (p *fakeChildProcess) finish(err error) {
|
|
p.killOnce.Do(func() { p.done <- err })
|
|
}
|
|
|
|
func TestParseChildOptionsPreservesNegativeVirtualDesktopCoordinates(t *testing.T) {
|
|
t.Setenv(envParentURL, "")
|
|
t.Setenv(envToken, "")
|
|
t.Setenv(envWindowID, "")
|
|
|
|
options, err := ParseChildOptions([]string{
|
|
"--parent-url=http://127.0.0.1:43119",
|
|
"--token=test-token",
|
|
"--id=window-1",
|
|
"--x=-2560",
|
|
"--y=-180",
|
|
"--width=1400",
|
|
"--height=900",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ParseChildOptions returned error: %v", err)
|
|
}
|
|
if options.X != -2560 || options.Y != -180 {
|
|
t.Fatalf("virtual desktop coordinates were clamped: x=%d y=%d", options.X, options.Y)
|
|
}
|
|
if options.Width != 1400 || options.Height != 900 {
|
|
t.Fatalf("unexpected child size: %dx%d", options.Width, options.Height)
|
|
}
|
|
}
|
|
|
|
func TestDetachedWindowMinimumSizeMatchesFrontendPresets(t *testing.T) {
|
|
if width, height := detachedWindowMinimumSize("ai-chat"); width != 360 || height != 420 {
|
|
t.Fatalf("AI minimum size = %dx%d, want 360x420", width, height)
|
|
}
|
|
if width, height := detachedWindowMinimumSize("workbench"); width != 480 || height != 320 {
|
|
t.Fatalf("workbench minimum size = %dx%d, want 480x320", width, height)
|
|
}
|
|
}
|
|
|
|
func TestDefaultGracefulCloseTimeoutLeavesTerminalGuardHeadroom(t *testing.T) {
|
|
const terminalGuardTimeout = 3 * time.Second
|
|
if defaultGracefulCloseTimeout != 10*time.Second {
|
|
t.Fatalf("default graceful close timeout = %s, want 10s", defaultGracefulCloseTimeout)
|
|
}
|
|
if defaultGracefulCloseTimeout <= terminalGuardTimeout {
|
|
t.Fatalf(
|
|
"default graceful close timeout %s must exceed terminal guard timeout %s",
|
|
defaultGracefulCloseTimeout,
|
|
terminalGuardTimeout,
|
|
)
|
|
}
|
|
|
|
manager := newHTTPTestManager(t)
|
|
if manager.closeFallbackDelay != defaultGracefulCloseTimeout {
|
|
t.Fatalf("manager close fallback = %s, want %s", manager.closeFallbackDelay, defaultGracefulCloseTimeout)
|
|
}
|
|
if manager.shutdownGracePeriod != defaultGracefulCloseTimeout {
|
|
t.Fatalf("manager shutdown grace = %s, want %s", manager.shutdownGracePeriod, defaultGracefulCloseTimeout)
|
|
}
|
|
control := newControl(nil)
|
|
if control.closeFallbackDelay != defaultGracefulCloseTimeout {
|
|
t.Fatalf("child close fallback = %s, want %s", control.closeFallbackDelay, defaultGracefulCloseTimeout)
|
|
}
|
|
}
|
|
|
|
func TestValidateOpenRequestSupportsEveryDetachedWindowKind(t *testing.T) {
|
|
for _, kind := range []string{"workbench", "query-result", "ai-chat"} {
|
|
if err := validateOpenRequest(OpenRequest{ID: "window-1", Kind: kind}); err != nil {
|
|
t.Fatalf("validateOpenRequest(%q) returned error: %v", kind, err)
|
|
}
|
|
}
|
|
if err := validateOpenRequest(OpenRequest{ID: "window-1", Kind: "unsupported"}); err == nil {
|
|
t.Fatal("validateOpenRequest accepted an unsupported kind")
|
|
}
|
|
}
|
|
|
|
func TestManagerOpenGeneratesUniqueIDsAndRegistersMultipleWindows(t *testing.T) {
|
|
starter := &fakeProcessStarter{nextPID: 100}
|
|
manager := &Manager{
|
|
token: "test-token",
|
|
endpoint: "http://127.0.0.1:43119",
|
|
started: true,
|
|
windows: make(map[string]*windowEntry),
|
|
starter: starter,
|
|
executable: "/tmp/GoNavi",
|
|
openTimeout: time.Second,
|
|
}
|
|
starter.onStart = func(spec processSpec, _ *fakeChildProcess) {
|
|
id := environmentValue(spec.Env, envWindowID)
|
|
manager.mu.Lock()
|
|
entry := manager.windows[id]
|
|
if entry != nil {
|
|
entry.info.Ready = true
|
|
entry.readyOnce.Do(func() { close(entry.ready) })
|
|
}
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
first := manager.Open(OpenRequest{Kind: "workbench", X: -1920, Y: 40, Width: 1100, Height: 760})
|
|
second := manager.Open(OpenRequest{Kind: "query-result", X: 1720, Y: -20, Width: 900, Height: 680})
|
|
if !first.Success || !second.Success {
|
|
t.Fatalf("Open results = %#v %#v", first, second)
|
|
}
|
|
if first.ID == "" || second.ID == "" || first.ID == second.ID {
|
|
t.Fatalf("expected unique generated IDs, got %q and %q", first.ID, second.ID)
|
|
}
|
|
windows := manager.List()
|
|
if len(windows) != 2 {
|
|
t.Fatalf("registry size = %d, want 2", len(windows))
|
|
}
|
|
firstBounds := WindowBounds{X: -1920, Y: 40, Width: 1100, Height: 760}
|
|
if first.Bounds == nil || *first.Bounds != firstBounds {
|
|
t.Fatalf("first Open bounds = %#v, want %#v", first.Bounds, firstBounds)
|
|
}
|
|
var firstInfo *WindowInfo
|
|
for index := range windows {
|
|
if windows[index].ID == first.ID {
|
|
firstInfo = &windows[index]
|
|
break
|
|
}
|
|
}
|
|
if firstInfo == nil {
|
|
t.Fatalf("first window %q is missing from registry: %#v", first.ID, windows)
|
|
}
|
|
if got := *windowBoundsFromInfo(*firstInfo); got != firstBounds {
|
|
t.Fatalf("first registry bounds = %#v, want %#v", got, firstBounds)
|
|
}
|
|
|
|
starter.mu.Lock()
|
|
firstSpec := starter.specs[0]
|
|
processes := append([]*fakeChildProcess(nil), starter.processes...)
|
|
starter.mu.Unlock()
|
|
if value := environmentValue(firstSpec.Env, envX); value != "-1920" {
|
|
t.Fatalf("child x environment = %q, want -1920", value)
|
|
}
|
|
if value := environmentValue(firstSpec.Env, envY); value != "40" {
|
|
t.Fatalf("child y environment = %q, want 40", value)
|
|
}
|
|
for _, process := range processes {
|
|
process.finish(nil)
|
|
}
|
|
waitForRegistrySize(t, manager, 0)
|
|
}
|
|
|
|
func TestManagerOpenUsesResolvedBoundsForChildRegistryAndResponse(t *testing.T) {
|
|
starter := &fakeProcessStarter{nextPID: 300}
|
|
corrected := WindowBounds{X: 563, Y: 182, Width: 921, Height: 812}
|
|
manager := &Manager{
|
|
token: "test-token",
|
|
endpoint: "http://127.0.0.1:43119",
|
|
started: true,
|
|
windows: make(map[string]*windowEntry),
|
|
starter: starter,
|
|
executable: "/tmp/GoNavi",
|
|
openTimeout: time.Second,
|
|
resolveBounds: func(WindowBounds) WindowBounds { return corrected },
|
|
}
|
|
starter.onStart = func(spec processSpec, _ *fakeChildProcess) {
|
|
id := environmentValue(spec.Env, envWindowID)
|
|
manager.mu.Lock()
|
|
entry := manager.windows[id]
|
|
if entry != nil {
|
|
entry.info.Ready = true
|
|
entry.readyOnce.Do(func() { close(entry.ready) })
|
|
}
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
result := manager.Open(OpenRequest{
|
|
ID: "ai-chat", Kind: "ai-chat", X: 0, Y: 1152, Width: 921, Height: 812,
|
|
})
|
|
if !result.Success || result.Bounds == nil || *result.Bounds != corrected {
|
|
t.Fatalf("Open result = %#v, want corrected bounds %#v", result, corrected)
|
|
}
|
|
|
|
starter.mu.Lock()
|
|
spec := starter.specs[0]
|
|
process := starter.processes[0]
|
|
starter.mu.Unlock()
|
|
if value := environmentValue(spec.Env, envX); value != "563" {
|
|
t.Fatalf("child x environment = %q, want 563", value)
|
|
}
|
|
if value := environmentValue(spec.Env, envY); value != "182" {
|
|
t.Fatalf("child y environment = %q, want 182", value)
|
|
}
|
|
if value := environmentValue(spec.Env, envWidth); value != "921" {
|
|
t.Fatalf("child width environment = %q, want 921", value)
|
|
}
|
|
if value := environmentValue(spec.Env, envHeight); value != "812" {
|
|
t.Fatalf("child height environment = %q, want 812", value)
|
|
}
|
|
windows := manager.List()
|
|
if len(windows) != 1 || windowBoundsFromInfo(windows[0]) == nil || *windowBoundsFromInfo(windows[0]) != corrected {
|
|
t.Fatalf("registered windows = %#v, want corrected bounds %#v", windows, corrected)
|
|
}
|
|
|
|
process.finish(nil)
|
|
waitForRegistrySize(t, manager, 0)
|
|
}
|
|
|
|
func TestManagerOpenAcceptsReadyAtTimeoutBoundary(t *testing.T) {
|
|
corrected := WindowBounds{X: -1520, Y: 80, Width: 920, Height: 700}
|
|
manager := &Manager{
|
|
token: "test-token",
|
|
endpoint: "http://127.0.0.1:43119",
|
|
started: true,
|
|
windows: make(map[string]*windowEntry),
|
|
executable: "/tmp/GoNavi",
|
|
openTimeout: time.Nanosecond,
|
|
resolveBounds: func(WindowBounds) WindowBounds { return corrected },
|
|
}
|
|
starter := &readyBeforeReturnStarter{
|
|
manager: manager, nextPID: 400, skipReadySignal: true,
|
|
}
|
|
manager.starter = starter
|
|
|
|
for attempt := 0; attempt < 64; attempt++ {
|
|
id := fmt.Sprintf("ready-at-timeout-%d", attempt)
|
|
result := manager.Open(OpenRequest{ID: id, Kind: "workbench"})
|
|
if !result.Success {
|
|
t.Fatalf("Open attempt %d rejected an already-ready child: %#v", attempt, result)
|
|
}
|
|
if result.Bounds == nil || *result.Bounds != corrected {
|
|
t.Fatalf("Open attempt %d bounds = %#v, want %#v", attempt, result.Bounds, corrected)
|
|
}
|
|
|
|
starter.mu.Lock()
|
|
process := starter.started[len(starter.started)-1]
|
|
starter.mu.Unlock()
|
|
process.finish(nil)
|
|
waitForRegistrySize(t, manager, 0)
|
|
}
|
|
}
|
|
|
|
func TestManagerSyncHostStateRetainsNewestRevisionAndOnlyEmitsToChildren(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
|
|
}
|
|
type targetedCommand struct {
|
|
targetID string
|
|
command childCommand
|
|
}
|
|
commands := make(chan targetedCommand, 2)
|
|
manager.emitToChild = func(targetID string, name string, args ...any) {
|
|
if name != CommandEventName || len(args) != 1 {
|
|
t.Fatalf("unexpected child event: %q %#v", name, args)
|
|
}
|
|
commands <- targetedCommand{targetID: targetID, command: args[0].(childCommand)}
|
|
}
|
|
mainEvents := 0
|
|
manager.runtimeCtx = context.Background()
|
|
manager.emitToWails = func(context.Context, string, ...any) {
|
|
mainEvents++
|
|
}
|
|
|
|
newestState := map[string]any{
|
|
"activeTabId": "query-new",
|
|
"activeContext": map[string]any{
|
|
"connectionId": "conn-new",
|
|
"dbName": "analytics",
|
|
},
|
|
}
|
|
result := manager.SyncHostState(HostStateRequest{
|
|
ID: "ai-chat",
|
|
Revision: 2,
|
|
StoreState: newestState,
|
|
})
|
|
if !result.Success {
|
|
t.Fatalf("SyncHostState newest result = %#v", result)
|
|
}
|
|
newestState["activeTabId"] = "mutated-after-sync"
|
|
|
|
stale := manager.SyncHostState(HostStateRequest{
|
|
ID: "ai-chat",
|
|
Revision: 1,
|
|
StoreState: map[string]any{
|
|
"activeTabId": "query-stale",
|
|
},
|
|
})
|
|
if !stale.Success || !strings.Contains(stale.Message, "stale") {
|
|
t.Fatalf("SyncHostState stale result = %#v", stale)
|
|
}
|
|
|
|
targeted := <-commands
|
|
if targeted.targetID != "ai-chat" {
|
|
t.Fatalf("host-state target = %q, want ai-chat", targeted.targetID)
|
|
}
|
|
command := targeted.command
|
|
if command.ID != "ai-chat" || command.Action != "sync-host-state" {
|
|
t.Fatalf("unexpected host-state command: %#v", command)
|
|
}
|
|
payload, ok := command.Payload.(hostStateInvalidationPayload)
|
|
if !ok || payload.Revision != 2 {
|
|
t.Fatalf("unexpected host-state payload: %#v", command.Payload)
|
|
}
|
|
select {
|
|
case duplicate := <-commands:
|
|
t.Fatalf("stale revision was emitted: %#v", duplicate)
|
|
default:
|
|
}
|
|
if mainEvents != 0 {
|
|
t.Fatalf("host state echoed to main Wails window %d times", mainEvents)
|
|
}
|
|
|
|
manager.mu.RLock()
|
|
retained := manager.windows["ai-chat"].hostState
|
|
manager.mu.RUnlock()
|
|
if retained.Revision != 2 || retained.StoreState["activeTabId"] != "query-new" {
|
|
t.Fatalf("retained host state = %#v", retained)
|
|
}
|
|
}
|
|
|
|
func TestManagerRoutesCommandsAndAIStreamsToOnlyTheirTargetWindow(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["workbench:query-a"] = &windowEntry{
|
|
info: WindowInfo{ID: "workbench:query-a", Kind: "workbench"},
|
|
}
|
|
manager.runtimeCtx = context.Background()
|
|
|
|
type emittedEvent struct {
|
|
targetID string
|
|
name string
|
|
args []any
|
|
}
|
|
reliable := make(chan emittedEvent, 2)
|
|
bestEffort := make(chan emittedEvent, 2)
|
|
broadcast := make(chan emittedEvent, 2)
|
|
mainEvents := make(chan emittedEvent, 2)
|
|
manager.emitToChild = func(targetID string, name string, args ...any) {
|
|
reliable <- emittedEvent{targetID: targetID, name: name, args: args}
|
|
}
|
|
manager.emitToChildBestEffort = func(targetID string, name string, args ...any) {
|
|
bestEffort <- emittedEvent{targetID: targetID, name: name, args: args}
|
|
}
|
|
manager.emitToChildren = func(name string, args ...any) {
|
|
broadcast <- emittedEvent{name: name, args: args}
|
|
}
|
|
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
|
mainEvents <- emittedEvent{name: name, args: args}
|
|
}
|
|
|
|
if result := manager.Focus("workbench:query-a"); !result.Success {
|
|
t.Fatalf("Focus result = %#v", result)
|
|
}
|
|
focus := <-reliable
|
|
if focus.targetID != "workbench:query-a" || focus.name != CommandEventName {
|
|
t.Fatalf("focus event = %#v", focus)
|
|
}
|
|
if command := focus.args[0].(childCommand); command.Action != "focus" {
|
|
t.Fatalf("focus command = %#v", command)
|
|
}
|
|
|
|
if result := manager.Close("workbench:query-a"); !result.Success {
|
|
t.Fatalf("Close result = %#v", result)
|
|
}
|
|
closeEvent := <-reliable
|
|
if closeEvent.targetID != "workbench:query-a" || closeEvent.name != CommandEventName {
|
|
t.Fatalf("close event = %#v", closeEvent)
|
|
}
|
|
if command := closeEvent.args[0].(childCommand); command.Action != "close" {
|
|
t.Fatalf("close command = %#v", command)
|
|
}
|
|
|
|
manager.emit("ai:stream:session-1", map[string]any{"content": "chunk"})
|
|
stream := <-bestEffort
|
|
if stream.targetID != "ai-chat" || stream.name != "ai:stream:session-1" {
|
|
t.Fatalf("AI stream event = %#v", stream)
|
|
}
|
|
if main := <-mainEvents; main.name != "ai:stream:session-1" {
|
|
t.Fatalf("main AI stream event = %#v", main)
|
|
}
|
|
select {
|
|
case leaked := <-broadcast:
|
|
t.Fatalf("AI stream was broadcast to every child: %#v", leaked)
|
|
default:
|
|
}
|
|
|
|
manager.emit("sqlfile:progress", map[string]any{"current": 1})
|
|
if normal := <-broadcast; normal.name != "sqlfile:progress" {
|
|
t.Fatalf("normal backend event = %#v", normal)
|
|
}
|
|
}
|
|
|
|
func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{
|
|
ID: "ai-chat",
|
|
Kind: "ai-chat",
|
|
X: 10,
|
|
Y: 20,
|
|
Width: 440,
|
|
Height: 720,
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
commands <- args[0].(childCommand)
|
|
}
|
|
|
|
firstHide := manager.Hide("ai-chat")
|
|
if !firstHide.Success || firstHide.VisibilityRevision != 1 {
|
|
t.Fatalf("first Hide result = %#v", firstHide)
|
|
}
|
|
firstHideCommand := <-commands
|
|
if firstHideCommand.Action != "hide" ||
|
|
firstHideCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 1 {
|
|
t.Fatalf("first hide command = %#v", firstHideCommand)
|
|
}
|
|
|
|
secondHide := manager.Hide("ai-chat")
|
|
if !secondHide.Success || secondHide.VisibilityRevision != 1 {
|
|
t.Fatalf("second Hide result = %#v", secondHide)
|
|
}
|
|
secondHideCommand := <-commands
|
|
if secondHideCommand.Action != "hide" ||
|
|
secondHideCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 1 {
|
|
t.Fatalf("second hide command = %#v", secondHideCommand)
|
|
}
|
|
|
|
focus := manager.Focus("ai-chat")
|
|
if !focus.Success || focus.VisibilityRevision != 2 {
|
|
t.Fatalf("Focus result = %#v", focus)
|
|
}
|
|
focusCommand := <-commands
|
|
if focusCommand.Action != "focus" ||
|
|
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()
|
|
if hidden {
|
|
t.Fatal("focused window remained hidden in manager state")
|
|
}
|
|
}
|
|
|
|
func TestManagerReplaysLatestFocusUntilChildAcknowledgesIt(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{
|
|
ID: "ai-chat",
|
|
Kind: "ai-chat",
|
|
Hidden: true,
|
|
},
|
|
visibilityRevision: 1,
|
|
}
|
|
// Simulate a disconnected child: reliable SSE has no subscriber and the
|
|
// immediate delivery therefore disappears.
|
|
manager.emitToChild = func(string, string, ...any) {}
|
|
|
|
first := manager.Focus("ai-chat")
|
|
second := manager.Focus("ai-chat")
|
|
if !first.Success || first.VisibilityRevision != 2 ||
|
|
!second.Success || second.VisibilityRevision != 3 {
|
|
t.Fatalf("Focus results = %#v %#v", first, second)
|
|
}
|
|
|
|
replay := func() (*httptest.ResponseRecorder, childCommand) {
|
|
t.Helper()
|
|
request := authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
var command childCommand
|
|
if recorder.Code == http.StatusOK {
|
|
if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil {
|
|
t.Fatalf("decode command state: %v", err)
|
|
}
|
|
}
|
|
return recorder, command
|
|
}
|
|
|
|
recorder, command := replay()
|
|
if recorder.Code != http.StatusOK || command.Action != "focus" ||
|
|
positiveVisibilityRevision(command.Payload) != 3 {
|
|
t.Fatalf("pending focus replay = status %d command %#v", recorder.Code, command)
|
|
}
|
|
|
|
staleAck := strings.NewReader(`{"action":"ack-focus","visibilityRevision":2}`)
|
|
staleRequest := authenticatedRequest(manager, http.MethodPost, CommandStatePath, "ai-chat", staleAck)
|
|
staleRecorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(staleRecorder, staleRequest)
|
|
if staleRecorder.Code != http.StatusOK {
|
|
t.Fatalf("stale focus ack status = %d body=%s", staleRecorder.Code, staleRecorder.Body.String())
|
|
}
|
|
if recorder, command = replay(); recorder.Code != http.StatusOK ||
|
|
command.Action != "focus" || positiveVisibilityRevision(command.Payload) != 3 {
|
|
t.Fatalf("focus after stale ack = status %d command %#v", recorder.Code, command)
|
|
}
|
|
|
|
latestAck := strings.NewReader(`{"action":"ack-focus","visibilityRevision":3}`)
|
|
latestRequest := authenticatedRequest(manager, http.MethodPost, CommandStatePath, "ai-chat", latestAck)
|
|
latestRecorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(latestRecorder, latestRequest)
|
|
if latestRecorder.Code != http.StatusOK {
|
|
t.Fatalf("latest focus ack status = %d body=%s", latestRecorder.Code, latestRecorder.Body.String())
|
|
}
|
|
if recorder, _ = replay(); recorder.Code != http.StatusNoContent {
|
|
t.Fatalf("command state after focus ack = %d body=%s, want 204", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestManagerCommandStatePrioritizesHideAndCloseOverPendingFocus(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Hidden: true},
|
|
visibilityRevision: 1,
|
|
}
|
|
manager.emitToChild = func(string, string, ...any) {}
|
|
|
|
if result := manager.Focus("ai-chat"); !result.Success {
|
|
t.Fatalf("Focus result = %#v", result)
|
|
}
|
|
if result := manager.Hide("ai-chat"); !result.Success {
|
|
t.Fatalf("Hide result = %#v", result)
|
|
}
|
|
request := authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
var command childCommand
|
|
if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil {
|
|
t.Fatalf("decode hidden command state: %v", err)
|
|
}
|
|
if recorder.Code != http.StatusOK || command.Action != "hide" {
|
|
t.Fatalf("hidden command state = status %d command %#v", recorder.Code, command)
|
|
}
|
|
|
|
if result := manager.Close("ai-chat"); !result.Success {
|
|
t.Fatalf("Close result = %#v", result)
|
|
}
|
|
request = authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil)
|
|
recorder = httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil {
|
|
t.Fatalf("decode closing command state: %v", err)
|
|
}
|
|
if recorder.Code != http.StatusOK || command.Action != "close" {
|
|
t.Fatalf("closing command state = status %d command %#v", recorder.Code, command)
|
|
}
|
|
}
|
|
|
|
func TestManagerFocusAndOpenRejectClosingWindowForRetry(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
starter := &fakeProcessStarter{}
|
|
manager.started = true
|
|
manager.endpoint = "http://127.0.0.1:43119"
|
|
manager.starter = starter
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{
|
|
ID: "ai-chat",
|
|
Kind: "ai-chat",
|
|
Title: "Existing",
|
|
X: 10,
|
|
Y: 20,
|
|
Width: 440,
|
|
Height: 720,
|
|
Hidden: true,
|
|
CloseSent: true,
|
|
},
|
|
payload: map[string]any{"snapshot": "existing"},
|
|
visibilityRevision: 4,
|
|
}
|
|
|
|
for name, result := range map[string]OperationResult{
|
|
"focus": manager.Focus("ai-chat"),
|
|
"open": manager.Open(OpenRequest{
|
|
ID: "ai-chat",
|
|
Kind: "ai-chat",
|
|
Title: "Replacement",
|
|
Payload: map[string]any{"snapshot": "replacement"},
|
|
X: 30,
|
|
Y: 40,
|
|
Width: 500,
|
|
Height: 800,
|
|
}),
|
|
} {
|
|
if result.Success || !strings.Contains(result.Message, "closing") ||
|
|
!strings.Contains(result.Message, "retry") {
|
|
t.Fatalf("%s result = %#v, want explicit retry failure", name, result)
|
|
}
|
|
}
|
|
|
|
manager.mu.RLock()
|
|
entry := manager.windows["ai-chat"]
|
|
payload := entry.payload.(map[string]any)["snapshot"]
|
|
info := entry.info
|
|
revision := entry.visibilityRevision
|
|
manager.mu.RUnlock()
|
|
if payload != "existing" || info.Title != "Existing" || info.X != 10 || info.Y != 20 ||
|
|
!info.Hidden || revision != 4 {
|
|
t.Fatalf("closing entry was mutated: info=%#v payload=%#v revision=%d", info, payload, revision)
|
|
}
|
|
starter.mu.Lock()
|
|
starts := len(starter.specs)
|
|
starter.mu.Unlock()
|
|
if starts != 0 {
|
|
t.Fatalf("closing entry spawned %d replacement processes, want 0", starts)
|
|
}
|
|
}
|
|
|
|
func TestHideActionParksWithoutCommittingTerminalState(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true},
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
body := strings.NewReader(`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":1}}`)
|
|
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("hide 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 hide result: %v", err)
|
|
}
|
|
if !result.Success || result.VisibilityRevision != 1 {
|
|
t.Fatalf("hide result = %#v", result)
|
|
}
|
|
event := receiveEvent(t, events)
|
|
if event.Action != "hide" || event.ID != "ai-chat" {
|
|
t.Fatalf("hide event = %#v", event)
|
|
}
|
|
payload := event.Payload.(map[string]any)
|
|
if payload["visibilityRevision"] != uint64(1) {
|
|
t.Fatalf("hide payload = %#v", payload)
|
|
}
|
|
manager.mu.RLock()
|
|
entry := manager.windows["ai-chat"]
|
|
hidden := entry.info.Hidden
|
|
closeSent := entry.info.CloseSent
|
|
exitReason := entry.exitReason
|
|
manager.mu.RUnlock()
|
|
if !hidden || closeSent || exitReason != "" {
|
|
t.Fatalf(
|
|
"parked state = hidden %v closeSent %v reason %q",
|
|
hidden,
|
|
closeSent,
|
|
exitReason,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestStaleHideActionCannotOverrideNewerFocus(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true, Hidden: true},
|
|
visibilityRevision: 1,
|
|
actionRevision: 1,
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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}}`,
|
|
)
|
|
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 hide status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
event := receiveEvent(t, events)
|
|
if event.Action != "sync" {
|
|
t.Fatalf("stale hide event = %#v, want sync", event)
|
|
}
|
|
manager.mu.RLock()
|
|
entry := manager.windows["ai-chat"]
|
|
hidden := entry.info.Hidden
|
|
revision := entry.visibilityRevision
|
|
manager.mu.RUnlock()
|
|
if hidden || revision != 2 {
|
|
t.Fatalf("state after stale hide = hidden %v revision %d", hidden, revision)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
|
hostState: HostStateRequest{
|
|
ID: "ai-chat",
|
|
Revision: 7,
|
|
StoreState: map[string]any{"activeTabId": "query-7"},
|
|
},
|
|
}
|
|
|
|
request := authenticatedRequest(manager, http.MethodGet, HostStatePath, "ai-chat", nil)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("host-state status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var snapshot HostStateRequest
|
|
if err := json.NewDecoder(recorder.Body).Decode(&snapshot); err != nil {
|
|
t.Fatalf("decode host-state response: %v", err)
|
|
}
|
|
if snapshot.ID != "ai-chat" || snapshot.Revision != 7 || snapshot.StoreState["activeTabId"] != "query-7" {
|
|
t.Fatalf("unexpected host-state response: %#v", snapshot)
|
|
}
|
|
|
|
manager.windows["workbench:query-empty"] = &windowEntry{
|
|
info: WindowInfo{ID: "workbench:query-empty", Kind: "workbench"},
|
|
}
|
|
emptyRequest := authenticatedRequest(manager, http.MethodGet, HostStatePath, "workbench:query-empty", nil)
|
|
emptyRecorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(emptyRecorder, emptyRequest)
|
|
if emptyRecorder.Code != http.StatusNoContent {
|
|
t.Fatalf("empty host-state status = %d, want 204", emptyRecorder.Code)
|
|
}
|
|
}
|
|
|
|
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"},
|
|
actionRevision: 17,
|
|
ready: make(chan struct{}),
|
|
}
|
|
handler := manager.authenticatedHandler()
|
|
|
|
missingToken := httptest.NewRequest(http.MethodGet, BootstrapPath, nil)
|
|
missingToken.RemoteAddr = "127.0.0.1:51001"
|
|
missingToken.Header.Set(HeaderWindowID, "window-1")
|
|
missingRecorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(missingRecorder, missingToken)
|
|
if missingRecorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("missing-token status = %d, want 401", missingRecorder.Code)
|
|
}
|
|
|
|
remoteRequest := httptest.NewRequest(http.MethodGet, BootstrapPath, nil)
|
|
remoteRequest.RemoteAddr = "192.0.2.10:51002"
|
|
remoteRequest.Header.Set(HeaderToken, manager.token)
|
|
remoteRequest.Header.Set(HeaderWindowID, "window-1")
|
|
remoteRecorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(remoteRecorder, remoteRequest)
|
|
if remoteRecorder.Code != http.StatusForbidden {
|
|
t.Fatalf("non-loopback status = %d, want 403", remoteRecorder.Code)
|
|
}
|
|
|
|
unknownRequest := authenticatedRequest(manager, http.MethodGet, BootstrapPath, "window-2", nil)
|
|
unknownRecorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(unknownRecorder, unknownRequest)
|
|
if unknownRecorder.Code != http.StatusForbidden {
|
|
t.Fatalf("unknown-window status = %d, want 403", unknownRecorder.Code)
|
|
}
|
|
|
|
validRequest := authenticatedRequest(manager, http.MethodGet, BootstrapPath, "window-1", nil)
|
|
validRecorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(validRecorder, validRequest)
|
|
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"`) ||
|
|
!strings.Contains(validRecorder.Body.String(), `"actionRevision":17`) {
|
|
t.Fatalf("unexpected bootstrap body: %s", validRecorder.Body.String())
|
|
}
|
|
select {
|
|
case <-manager.windows["window-1"].ready:
|
|
t.Fatal("bootstrap read acknowledged the window before frontend mount")
|
|
default:
|
|
}
|
|
|
|
readyBody := strings.NewReader(`{"action":"ready","payload":{"id":"window-1","kind":"query-result"}}`)
|
|
readyRequest := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", readyBody)
|
|
readyRecorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(readyRecorder, readyRequest)
|
|
if readyRecorder.Code != http.StatusOK {
|
|
t.Fatalf("ready status = %d body=%s", readyRecorder.Code, readyRecorder.Body.String())
|
|
}
|
|
select {
|
|
case <-manager.windows["window-1"].ready:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ready action did not acknowledge the native window")
|
|
}
|
|
}
|
|
|
|
func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
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()
|
|
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
|
if name == MainEventName && len(args) == 1 {
|
|
events <- args[0].(Event)
|
|
}
|
|
}
|
|
|
|
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("open-ai-settings status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
event := receiveEvent(t, events)
|
|
if event.ID != "ai-chat" || event.Kind != "ai-chat" || event.Action != "open-ai-settings" {
|
|
t.Fatalf("unexpected open-ai-settings event: %#v", event)
|
|
}
|
|
manager.mu.RLock()
|
|
exitReason := manager.windows["ai-chat"].exitReason
|
|
manager.mu.RUnlock()
|
|
if exitReason != "" {
|
|
t.Fatalf("open-ai-settings marked child terminal: %q", exitReason)
|
|
}
|
|
}
|
|
|
|
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
|
|
manager.endpoint = "http://127.0.0.1:43119"
|
|
manager.executable = "/tmp/GoNavi"
|
|
manager.openTimeout = time.Second
|
|
requestedBounds := WindowBounds{X: 2100, Y: -120, Width: 900, Height: 620}
|
|
correctedBounds := WindowBounds{X: 96, Y: 80, Width: 800, Height: 500}
|
|
manager.resolveBounds = func(bounds WindowBounds) WindowBounds {
|
|
if bounds != requestedBounds {
|
|
t.Fatalf("bounds resolver input = %#v, want %#v", bounds, requestedBounds)
|
|
}
|
|
return correctedBounds
|
|
}
|
|
manager.windows["workbench:query-a"] = &windowEntry{
|
|
info: WindowInfo{ID: "workbench:query-a", Kind: "workbench", Title: "SQL"},
|
|
}
|
|
|
|
starter := &fakeProcessStarter{nextPID: 500}
|
|
manager.starter = starter
|
|
starter.onStart = func(spec processSpec, _ *fakeChildProcess) {
|
|
id := environmentValue(spec.Env, envWindowID)
|
|
manager.mu.Lock()
|
|
entry := manager.windows[id]
|
|
if entry != nil {
|
|
entry.info.Ready = true
|
|
entry.readyOnce.Do(func() { close(entry.ready) })
|
|
}
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
events := make(chan Event, 4)
|
|
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":"open",
|
|
"request":{
|
|
"id":"query-result:query-a:r1",
|
|
"kind":"query-result",
|
|
"title":"Result 1",
|
|
"x":2100,
|
|
"y":-120,
|
|
"width":900,
|
|
"height":620,
|
|
"payload":{
|
|
"storeState":{},
|
|
"resultWindow":{
|
|
"id":"query-result:query-a:r1",
|
|
"sourceQueryTabId":"query-a",
|
|
"x":2100,
|
|
"y":-120,
|
|
"width":900,
|
|
"height":620
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
request := authenticatedRequest(manager, http.MethodPost, ControlPath, "workbench:query-a", body)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("owned open status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
manager.mu.RLock()
|
|
ownedEntry := manager.windows["query-result:query-a:r1"]
|
|
manager.mu.RUnlock()
|
|
if ownedEntry == nil || ownedEntry.ownerID != "workbench:query-a" {
|
|
t.Fatalf("owned entry = %#v, want owner workbench:query-a", ownedEntry)
|
|
}
|
|
opened := receiveEvent(t, events)
|
|
if opened.ID != "query-result:query-a:r1" || opened.Action != "opened" {
|
|
t.Fatalf("unexpected opened event: %#v", opened)
|
|
}
|
|
payload, ok := opened.Payload.(map[string]any)
|
|
if !ok || payload["ownerWindowId"] != "workbench:query-a" {
|
|
t.Fatalf("opened event owner metadata = %#v", opened.Payload)
|
|
}
|
|
resultWindow, ok := payload["resultWindow"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("opened event result window = %#v, want structured payload", payload["resultWindow"])
|
|
}
|
|
if resultWindow["sourceQueryTabId"] != "query-a" {
|
|
t.Fatalf("opened event result window lost source metadata: %#v", resultWindow)
|
|
}
|
|
if resultWindow["x"] != correctedBounds.X ||
|
|
resultWindow["y"] != correctedBounds.Y ||
|
|
resultWindow["width"] != correctedBounds.Width ||
|
|
resultWindow["height"] != correctedBounds.Height {
|
|
t.Fatalf("opened event result bounds = %#v, want %#v", resultWindow, correctedBounds)
|
|
}
|
|
|
|
manager.windows["foreign-window"] = &windowEntry{
|
|
info: WindowInfo{ID: "foreign-window", Kind: "query-result"},
|
|
ownerID: "workbench:query-b",
|
|
}
|
|
foreignBody := strings.NewReader(`{"action":"close","id":"foreign-window"}`)
|
|
foreignRequest := authenticatedRequest(manager, http.MethodPost, ControlPath, "workbench:query-a", foreignBody)
|
|
foreignRecorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(foreignRecorder, foreignRequest)
|
|
if foreignRecorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("foreign close status = %d, want 400", foreignRecorder.Code)
|
|
}
|
|
|
|
starter.mu.Lock()
|
|
process := starter.processes[0]
|
|
starter.mu.Unlock()
|
|
process.finish(nil)
|
|
waitForRegistrySize(t, manager, 2)
|
|
}
|
|
|
|
func TestActionAndProcessExitEmitStableMainEventPayload(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
events := make(chan Event, 4)
|
|
manager.runtimeCtx = context.Background()
|
|
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
|
if name == MainEventName && len(args) == 1 {
|
|
if event, ok := args[0].(Event); ok {
|
|
events <- event
|
|
}
|
|
}
|
|
}
|
|
process := &fakeChildProcess{pid: 42, done: make(chan error, 1), killed: make(chan struct{})}
|
|
manager.windows["window-1"] = &windowEntry{
|
|
info: WindowInfo{ID: "window-1", Kind: "workbench", Title: "SQL"},
|
|
ownerID: "workbench:source",
|
|
process: process,
|
|
acknowledged: true,
|
|
}
|
|
|
|
actionBody := strings.NewReader(`{"action":"sync","payload":{"id":"window-1","storeState":{"activeTab":"sql"}}}`)
|
|
actionRequest := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", actionBody)
|
|
actionRecorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(actionRecorder, actionRequest)
|
|
if actionRecorder.Code != http.StatusOK {
|
|
t.Fatalf("action status = %d body=%s", actionRecorder.Code, actionRecorder.Body.String())
|
|
}
|
|
syncEvent := receiveEvent(t, events)
|
|
if syncEvent.ID != "window-1" || syncEvent.Kind != "workbench" || syncEvent.Action != "sync" {
|
|
t.Fatalf("unexpected sync event: %#v", syncEvent)
|
|
}
|
|
if payload, ok := syncEvent.Payload.(map[string]any); !ok || payload["ownerWindowId"] != "workbench:source" {
|
|
t.Fatalf("sync event is missing owner metadata: %#v", syncEvent.Payload)
|
|
}
|
|
|
|
go manager.watchProcess("window-1", process)
|
|
process.finish(errors.New("exit status 9"))
|
|
exitEvent := receiveEvent(t, events)
|
|
if exitEvent.ID != "window-1" || exitEvent.Action != "close" {
|
|
t.Fatalf("unexpected exit event: %#v", exitEvent)
|
|
}
|
|
payload, ok := exitEvent.Payload.(map[string]any)
|
|
if !ok || payload["reason"] != ExitReasonProcessError || payload["exited"] != true {
|
|
t.Fatalf("unexpected exit payload: %#v", exitEvent.Payload)
|
|
}
|
|
if payload["ownerWindowId"] != "workbench:source" {
|
|
t.Fatalf("exit event is missing owner metadata: %#v", exitEvent.Payload)
|
|
}
|
|
}
|
|
|
|
func TestRequestedCloseReasonSurvivesForcedProcessExit(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
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)
|
|
}
|
|
}
|
|
process := &fakeChildProcess{pid: 43, done: make(chan error, 1), killed: make(chan struct{})}
|
|
manager.windows["window-2"] = &windowEntry{
|
|
info: WindowInfo{ID: "window-2", Kind: "query-result"},
|
|
process: process,
|
|
exitReason: ExitReasonRequested,
|
|
acknowledged: true,
|
|
}
|
|
go manager.watchProcess("window-2", process)
|
|
process.finish(errors.New("signal: killed"))
|
|
event := receiveEvent(t, events)
|
|
payload := event.Payload.(map[string]any)
|
|
if payload["reason"] != ExitReasonRequested {
|
|
t.Fatalf("exit reason = %#v, want %q", payload["reason"], ExitReasonRequested)
|
|
}
|
|
}
|
|
|
|
func TestManagerCancelCloseInvalidatesForcedKill(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.closeFallbackDelay = 20 * time.Millisecond
|
|
process := &fakeChildProcess{pid: 44, done: make(chan error, 1), killed: make(chan struct{})}
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
|
process: process,
|
|
}
|
|
|
|
if result := manager.Close("ai-chat"); !result.Success {
|
|
t.Fatalf("Close result = %#v", result)
|
|
}
|
|
manager.mu.RLock()
|
|
entry := manager.windows["ai-chat"]
|
|
closeSent := entry.info.CloseSent
|
|
exitReason := entry.exitReason
|
|
manager.mu.RUnlock()
|
|
if !closeSent || exitReason != ExitReasonRequested {
|
|
t.Fatalf("pending close state = closeSent %v reason %q", closeSent, exitReason)
|
|
}
|
|
|
|
if result := manager.CancelClose("ai-chat"); !result.Success {
|
|
t.Fatalf("CancelClose result = %#v", result)
|
|
}
|
|
manager.mu.RLock()
|
|
closeSent = entry.info.CloseSent
|
|
exitReason = entry.exitReason
|
|
manager.mu.RUnlock()
|
|
if closeSent || exitReason != "" {
|
|
t.Fatalf("cancelled close state = closeSent %v reason %q", closeSent, exitReason)
|
|
}
|
|
select {
|
|
case <-process.killed:
|
|
t.Fatal("cancelled parent close still killed the child")
|
|
case <-time.After(60 * time.Millisecond):
|
|
}
|
|
if result := manager.Close("ai-chat"); !result.Success {
|
|
t.Fatalf("close retry result = %#v", result)
|
|
}
|
|
select {
|
|
case <-process.killed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("close retry did not schedule a fresh force-kill fallback")
|
|
}
|
|
}
|
|
|
|
func TestManagerCancelCloseRollsBackAcceptedTerminalReason(t *testing.T) {
|
|
for _, terminalReason := range []string{ExitReasonAttached, ExitReasonWindowClosed} {
|
|
t.Run(terminalReason, func(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
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)
|
|
}
|
|
}
|
|
process := &fakeChildProcess{
|
|
pid: 45,
|
|
done: make(chan error, 1),
|
|
killed: make(chan struct{}),
|
|
}
|
|
manager.windows["workbench:query-1"] = &windowEntry{
|
|
info: WindowInfo{
|
|
ID: "workbench:query-1",
|
|
Kind: "workbench",
|
|
CloseSent: true,
|
|
},
|
|
process: process,
|
|
exitReason: terminalReason,
|
|
acknowledged: true,
|
|
closeGeneration: 1,
|
|
}
|
|
|
|
if result := manager.CancelClose("workbench:query-1"); !result.Success {
|
|
t.Fatalf("CancelClose result = %#v", result)
|
|
}
|
|
manager.mu.RLock()
|
|
entry := manager.windows["workbench:query-1"]
|
|
closeSent := entry.info.CloseSent
|
|
exitReason := entry.exitReason
|
|
manager.mu.RUnlock()
|
|
if closeSent || exitReason != "" {
|
|
t.Fatalf("cancelled terminal state = closeSent %v reason %q", closeSent, exitReason)
|
|
}
|
|
|
|
go manager.watchProcess("workbench:query-1", process)
|
|
process.finish(errors.New("exit status 9"))
|
|
event := receiveEvent(t, events)
|
|
payload := event.Payload.(map[string]any)
|
|
if payload["reason"] != ExitReasonProcessError {
|
|
t.Fatalf("exit reason after rollback = %#v, want %q", payload["reason"], ExitReasonProcessError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCancelCloseActionClearsPendingStateAndNotifiesMainWindow(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", CloseSent: true},
|
|
exitReason: ExitReasonRequested,
|
|
}
|
|
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":"ai-chat","revision":9}}`)
|
|
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("cancel-close status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
manager.mu.RLock()
|
|
entry := manager.windows["ai-chat"]
|
|
closeSent := entry.info.CloseSent
|
|
exitReason := entry.exitReason
|
|
manager.mu.RUnlock()
|
|
if closeSent || exitReason != "" {
|
|
t.Fatalf("cancel-close state = closeSent %v reason %q", closeSent, exitReason)
|
|
}
|
|
event := receiveEvent(t, events)
|
|
if event.Action != "cancel-close" || event.ID != "ai-chat" {
|
|
t.Fatalf("unexpected cancel-close event: %#v", event)
|
|
}
|
|
}
|
|
|
|
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{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
|
}
|
|
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":"host-event","payload":{"name":"gonavi:insert-sql","detail":{"sql":"select 1"}}}`)
|
|
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
|
recorder := httptest.NewRecorder()
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("host-event status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
event := receiveEvent(t, events)
|
|
if event.ID != "ai-chat" || event.Action != "host-event" {
|
|
t.Fatalf("unexpected host-event: %#v", event)
|
|
}
|
|
manager.mu.RLock()
|
|
exitReason := manager.windows["ai-chat"].exitReason
|
|
manager.mu.RUnlock()
|
|
if exitReason != "" {
|
|
t.Fatalf("host-event marked child terminal: %q", exitReason)
|
|
}
|
|
}
|
|
|
|
func TestDetachedEventsOnlyBroadcastChildOwnedLifecycle(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.runtimeCtx = context.Background()
|
|
mainEvents := make(chan Event, 2)
|
|
type targetedEvent struct {
|
|
targetID string
|
|
event Event
|
|
}
|
|
childEvents := make(chan targetedEvent, 2)
|
|
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
|
if name == MainEventName && len(args) == 1 {
|
|
mainEvents <- args[0].(Event)
|
|
}
|
|
}
|
|
manager.emitToChild = func(targetID string, name string, args ...any) {
|
|
if name == MainEventName && len(args) == 1 {
|
|
childEvents <- targetedEvent{targetID: targetID, event: args[0].(Event)}
|
|
}
|
|
}
|
|
|
|
manager.emitDetached(Event{
|
|
ID: "ai-chat",
|
|
Kind: "ai-chat",
|
|
Action: "sync",
|
|
Payload: map[string]any{"storeState": map[string]any{"history": "large"}},
|
|
})
|
|
_ = receiveEvent(t, mainEvents)
|
|
select {
|
|
case event := <-childEvents:
|
|
t.Fatalf("top-level sync leaked to children: %#v", event)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
|
|
manager.emitDetached(Event{
|
|
ID: "query-result:query-1:r1",
|
|
Kind: "query-result",
|
|
Action: "opened",
|
|
Payload: map[string]any{
|
|
"ownerWindowId": "workbench:query-1",
|
|
},
|
|
})
|
|
_ = receiveEvent(t, mainEvents)
|
|
owned := <-childEvents
|
|
if owned.targetID != "workbench:query-1" || owned.event.ID != "query-result:query-1:r1" {
|
|
t.Fatalf("unexpected child-owned event: %#v", owned)
|
|
}
|
|
}
|
|
|
|
func TestActionRevisionRejectsOutOfOrderSyncWithoutEmitting(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["window-1"] = &windowEntry{
|
|
info: WindowInfo{ID: "window-1", Kind: "workbench"},
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
postAction := func(body string) *httptest.ResponseRecorder {
|
|
recorder := httptest.NewRecorder()
|
|
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", strings.NewReader(body))
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
return recorder
|
|
}
|
|
if recorder := postAction(`{"action":"sync","payload":{"revision":7,"storeState":{"value":"new"}}}`); recorder.Code != http.StatusOK {
|
|
t.Fatalf("new sync status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if recorder := postAction(`{"action":"sync","payload":{"revision":6,"storeState":{"value":"old"}}}`); recorder.Code != http.StatusOK {
|
|
t.Fatalf("stale sync status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
event := receiveEvent(t, events)
|
|
payload := event.Payload.(map[string]any)
|
|
if event.Action != "sync" || payload["revision"] != float64(7) {
|
|
t.Fatalf("unexpected newest sync event: %#v", event)
|
|
}
|
|
select {
|
|
case stale := <-events:
|
|
t.Fatalf("stale sync emitted an event: %#v", stale)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
manager.mu.RLock()
|
|
revision := manager.windows["window-1"].actionRevision
|
|
manager.mu.RUnlock()
|
|
if revision != 7 {
|
|
t.Fatalf("action revision = %d, want 7", revision)
|
|
}
|
|
}
|
|
|
|
func TestActionRevisionPreventsStaleTerminalTransition(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.windows["window-1"] = &windowEntry{
|
|
info: WindowInfo{ID: "window-1", Kind: "workbench"},
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
postAction := func(body string) {
|
|
recorder := httptest.NewRecorder()
|
|
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", strings.NewReader(body))
|
|
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("action status = %d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
postAction(`{"action":"attach","payload":{"revision":12}}`)
|
|
postAction(`{"action":"close","payload":{"revision":11}}`)
|
|
|
|
event := receiveEvent(t, events)
|
|
if event.Action != "attach" {
|
|
t.Fatalf("terminal event = %#v, want attach", event)
|
|
}
|
|
select {
|
|
case stale := <-events:
|
|
t.Fatalf("stale terminal action emitted an event: %#v", stale)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
manager.mu.RLock()
|
|
entry := manager.windows["window-1"]
|
|
exitReason := entry.exitReason
|
|
revision := entry.actionRevision
|
|
manager.mu.RUnlock()
|
|
if exitReason != ExitReasonAttached || revision != 12 {
|
|
t.Fatalf("terminal state = reason %q revision %d", exitReason, revision)
|
|
}
|
|
}
|
|
|
|
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
|
|
process := &fakeChildProcess{pid: 45, done: make(chan error, 1), killed: make(chan struct{})}
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
|
process: process,
|
|
acknowledged: true,
|
|
}
|
|
commands := make(chan childCommand, 1)
|
|
manager.emitToChild = func(targetID string, name string, args ...any) {
|
|
if targetID != "ai-chat" {
|
|
t.Fatalf("shutdown target = %q, want ai-chat", targetID)
|
|
}
|
|
if name == CommandEventName && len(args) == 1 {
|
|
commands <- args[0].(childCommand)
|
|
}
|
|
}
|
|
go manager.watchProcess("ai-chat", process)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
manager.shutdown()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case command := <-commands:
|
|
if command.ID != "ai-chat" || command.Action != "close" || command.Reason != ExitReasonParentShutdown {
|
|
t.Fatalf("unexpected shutdown command: %#v", command)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown did not request graceful child close")
|
|
}
|
|
select {
|
|
case <-process.killed:
|
|
t.Fatal("shutdown killed child before its grace period")
|
|
case <-time.After(30 * time.Millisecond):
|
|
}
|
|
process.finish(nil)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown did not finish after child exited")
|
|
}
|
|
select {
|
|
case <-process.killed:
|
|
t.Fatal("cooperative child was killed during shutdown")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestManagerShutdownKillsChildAfterGracePeriod(t *testing.T) {
|
|
manager := newHTTPTestManager(t)
|
|
manager.shutdownGracePeriod = 20 * time.Millisecond
|
|
process := &fakeChildProcess{pid: 46, done: make(chan error, 1), killed: make(chan struct{})}
|
|
manager.windows["ai-chat"] = &windowEntry{
|
|
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
|
process: process,
|
|
}
|
|
commands := make(chan childCommand, 1)
|
|
manager.emitToChild = func(targetID string, name string, args ...any) {
|
|
if targetID != "ai-chat" {
|
|
t.Fatalf("shutdown target = %q, want ai-chat", targetID)
|
|
}
|
|
if name == CommandEventName && len(args) == 1 {
|
|
commands <- args[0].(childCommand)
|
|
}
|
|
}
|
|
go manager.watchProcess("ai-chat", process)
|
|
|
|
manager.shutdown()
|
|
select {
|
|
case command := <-commands:
|
|
if command.Reason != ExitReasonParentShutdown {
|
|
t.Fatalf("shutdown reason = %q", command.Reason)
|
|
}
|
|
default:
|
|
t.Fatal("shutdown did not broadcast graceful close")
|
|
}
|
|
select {
|
|
case <-process.killed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("unresponsive child was not killed after grace period")
|
|
}
|
|
}
|
|
|
|
func TestManagerOpenFailureBeforeBootstrapKeepsWindowUnacknowledged(t *testing.T) {
|
|
starter := &fakeProcessStarter{nextPID: 200}
|
|
manager := &Manager{
|
|
token: "test-token",
|
|
endpoint: "http://127.0.0.1:43119",
|
|
started: true,
|
|
windows: make(map[string]*windowEntry),
|
|
starter: starter,
|
|
executable: "/tmp/GoNavi",
|
|
openTimeout: 25 * time.Millisecond,
|
|
}
|
|
|
|
result := manager.Open(OpenRequest{ID: "never-ready", Kind: "workbench"})
|
|
if result.Success || !strings.Contains(result.Message, "did not become ready") {
|
|
t.Fatalf("Open result = %#v, want readiness failure", result)
|
|
}
|
|
if len(manager.List()) != 0 {
|
|
t.Fatalf("timed-out child remained registered: %#v", manager.List())
|
|
}
|
|
starter.mu.Lock()
|
|
process := starter.processes[0]
|
|
starter.mu.Unlock()
|
|
select {
|
|
case <-process.killed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed-out child was not killed")
|
|
}
|
|
}
|
|
|
|
func TestManagerOpenReportsChildExitBeforeBootstrapWithoutClosingMainTab(t *testing.T) {
|
|
starter := &fakeProcessStarter{nextPID: 300}
|
|
manager := &Manager{
|
|
token: "test-token",
|
|
endpoint: "http://127.0.0.1:43119",
|
|
started: true,
|
|
windows: make(map[string]*windowEntry),
|
|
starter: starter,
|
|
executable: "/tmp/GoNavi",
|
|
openTimeout: time.Second,
|
|
}
|
|
events := make(chan Event, 1)
|
|
manager.runtimeCtx = context.Background()
|
|
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
|
if name == MainEventName {
|
|
events <- args[0].(Event)
|
|
}
|
|
}
|
|
starter.onStart = func(_ processSpec, process *fakeChildProcess) {
|
|
process.finish(errors.New("child startup failed"))
|
|
}
|
|
|
|
result := manager.Open(OpenRequest{ID: "startup-failure", Kind: "workbench"})
|
|
if result.Success || !strings.Contains(result.Message, "child startup failed") {
|
|
t.Fatalf("Open result = %#v, want child startup failure", result)
|
|
}
|
|
if len(manager.List()) != 0 {
|
|
t.Fatalf("failed child remained registered: %#v", manager.List())
|
|
}
|
|
select {
|
|
case event := <-events:
|
|
t.Fatalf("unacknowledged child emitted a main close event: %#v", event)
|
|
case <-time.After(25 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func newHTTPTestManager(t *testing.T) *Manager {
|
|
t.Helper()
|
|
assets := fstest.MapFS{
|
|
"frontend/dist/index.html": &fstest.MapFile{Data: []byte("<html><head></head><body></body></html>")},
|
|
}
|
|
manager, err := NewManager(fs.FS(assets), appcore.NewWebApp(), aiservice.NewService())
|
|
if err != nil {
|
|
t.Fatalf("NewManager returned error: %v", err)
|
|
}
|
|
return manager
|
|
}
|
|
|
|
func authenticatedRequest(manager *Manager, method string, target string, id string, body *strings.Reader) *http.Request {
|
|
var request *http.Request
|
|
if body == nil {
|
|
request = httptest.NewRequest(method, target, nil)
|
|
} else {
|
|
request = httptest.NewRequest(method, target, body)
|
|
}
|
|
request.RemoteAddr = "127.0.0.1:51003"
|
|
request.Header.Set(HeaderToken, manager.token)
|
|
request.Header.Set(HeaderWindowID, id)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
return request
|
|
}
|
|
|
|
func environmentValue(environment []string, name string) string {
|
|
prefix := name + "="
|
|
for _, item := range environment {
|
|
if strings.HasPrefix(item, prefix) {
|
|
return strings.TrimPrefix(item, prefix)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func TestDetachedChildProcessUsesPositionalModeAndFiltersWailsDevEnvironment(t *testing.T) {
|
|
if argument := detachedWindowProcessArgument(); argument != "detached-window" || strings.HasPrefix(argument, "-") {
|
|
t.Fatalf("detached child argument = %q, want positional detached-window", argument)
|
|
}
|
|
environment := filterDetachedChildEnvironment([]string{
|
|
"PATH=/usr/bin",
|
|
"assetdir=frontend/dist",
|
|
"devserver=127.0.0.1:34115",
|
|
"frontenddevserverurl=http://127.0.0.1:5173",
|
|
"loglevel=debug",
|
|
})
|
|
for _, name := range []string{"assetdir", "devserver", "frontenddevserverurl"} {
|
|
if value := environmentValue(environment, name); value != "" {
|
|
t.Fatalf("filtered environment retained %s=%q", name, value)
|
|
}
|
|
}
|
|
if value := environmentValue(environment, "PATH"); value != "/usr/bin" {
|
|
t.Fatalf("PATH = %q, want /usr/bin", value)
|
|
}
|
|
if value := environmentValue(environment, "loglevel"); value != "debug" {
|
|
t.Fatalf("loglevel = %q, want debug", value)
|
|
}
|
|
}
|
|
|
|
func waitForRegistrySize(t *testing.T, manager *Manager, size int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if len(manager.List()) == size {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("registry size = %d, want %d", len(manager.List()), size)
|
|
}
|
|
|
|
func receiveEvent(t *testing.T, events <-chan Event) Event {
|
|
t.Helper()
|
|
select {
|
|
case event := <-events:
|
|
return event
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for detached event")
|
|
return Event{}
|
|
}
|
|
}
|