feat(gui): add missing MCP aliases and webview errors
This commit is contained in:
parent
483c408497
commit
cad4e212c4
8 changed files with 272 additions and 0 deletions
|
|
@ -73,6 +73,7 @@ Response:
|
||||||
| `window_maximize` | Maximize window |
|
| `window_maximize` | Maximize window |
|
||||||
| `window_minimize` | Minimize window |
|
| `window_minimize` | Minimize window |
|
||||||
| `window_focus` | Bring window to front |
|
| `window_focus` | Bring window to front |
|
||||||
|
| `window_title_set` | Alias for `window_title` |
|
||||||
|
|
||||||
### WebView Interaction
|
### WebView Interaction
|
||||||
|
|
||||||
|
|
@ -84,6 +85,7 @@ Response:
|
||||||
| `webview_screenshot` | Capture page |
|
| `webview_screenshot` | Capture page |
|
||||||
| `webview_navigate` | Navigate to URL |
|
| `webview_navigate` | Navigate to URL |
|
||||||
| `webview_console` | Get console messages |
|
| `webview_console` | Get console messages |
|
||||||
|
| `webview_errors` | Get structured JavaScript errors |
|
||||||
|
|
||||||
### Screen Management
|
### Screen Management
|
||||||
|
|
||||||
|
|
@ -93,6 +95,8 @@ Response:
|
||||||
| `screen_primary` | Get primary screen |
|
| `screen_primary` | Get primary screen |
|
||||||
| `screen_at_point` | Get screen at coordinates |
|
| `screen_at_point` | Get screen at coordinates |
|
||||||
| `screen_work_areas` | Get usable screen space |
|
| `screen_work_areas` | Get usable screen space |
|
||||||
|
| `screen_work_area` | Alias for `screen_work_areas` |
|
||||||
|
| `screen_for_window` | Get the screen containing a window |
|
||||||
|
|
||||||
### Layout Management
|
### Layout Management
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import (
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/display"
|
||||||
"forge.lthn.ai/core/gui/pkg/notification"
|
"forge.lthn.ai/core/gui/pkg/notification"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/webview"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -50,6 +54,23 @@ func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) e
|
||||||
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil }
|
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil }
|
||||||
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil }
|
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil }
|
||||||
|
|
||||||
|
type mockScreenPlatform struct {
|
||||||
|
screens []screen.Screen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
|
||||||
|
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
|
||||||
|
for i := range m.screens {
|
||||||
|
if m.screens[i].IsPrimary {
|
||||||
|
return &m.screens[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.screens) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &m.screens[0]
|
||||||
|
}
|
||||||
|
|
||||||
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
||||||
c, err := core.New(
|
c, err := core.New(
|
||||||
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
|
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
|
||||||
|
|
@ -88,6 +109,133 @@ func TestMCP_Good_DialogMessage(t *testing.T) {
|
||||||
assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity)
|
assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_WindowTitleSetAlias(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||||
|
Window: &window.Window{Name: "alias-win", Title: "Original", URL: "/"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, result, err := sub.windowTitleSet(context.Background(), nil, WindowTitleInput{
|
||||||
|
Name: "alias-win",
|
||||||
|
Title: "Updated",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Success)
|
||||||
|
|
||||||
|
queried, handled, err := c.QUERY(window.QueryWindowByName{Name: "alias-win"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
info, ok := queried.(*window.WindowInfo)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, info)
|
||||||
|
assert.Equal(t, "Updated", info.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_ScreenWorkAreaAlias(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(screen.Register(&mockScreenPlatform{
|
||||||
|
screens: []screen.Screen{
|
||||||
|
{
|
||||||
|
ID: "1",
|
||||||
|
Name: "Primary",
|
||||||
|
IsPrimary: true,
|
||||||
|
WorkArea: screen.Rect{X: 0, Y: 24, Width: 1920, Height: 1056},
|
||||||
|
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
Size: screen.Size{Width: 1920, Height: 1080},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, plural, err := sub.screenWorkAreas(context.Background(), nil, ScreenWorkAreasInput{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, alias, err := sub.screenWorkArea(context.Background(), nil, ScreenWorkAreasInput{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, plural, alias)
|
||||||
|
assert.Len(t, alias.WorkAreas, 1)
|
||||||
|
assert.Equal(t, 24, alias.WorkAreas[0].Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_ScreenForWindow(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(display.Register(nil)),
|
||||||
|
core.WithService(screen.Register(&mockScreenPlatform{
|
||||||
|
screens: []screen.Screen{
|
||||||
|
{
|
||||||
|
ID: "1",
|
||||||
|
Name: "Primary",
|
||||||
|
IsPrimary: true,
|
||||||
|
WorkArea: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
Size: screen.Size{Width: 1920, Height: 1080},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "2",
|
||||||
|
Name: "Secondary",
|
||||||
|
WorkArea: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
|
||||||
|
Bounds: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
|
||||||
|
Size: screen.Size{Width: 1280, Height: 1024},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||||
|
Window: &window.Window{Name: "editor", Title: "Editor", X: 100, Y: 100, Width: 800, Height: 600},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, out, err := sub.screenForWindow(context.Background(), nil, ScreenForWindowInput{Window: "editor"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, out.Screen)
|
||||||
|
assert.Equal(t, "Primary", out.Screen.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_WebviewErrors(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(webview.Register()),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
require.NoError(t, c.ACTION(webview.ActionException{
|
||||||
|
Window: "main",
|
||||||
|
Exception: webview.ExceptionInfo{
|
||||||
|
Text: "boom",
|
||||||
|
URL: "https://example.com/app.js",
|
||||||
|
Line: 12,
|
||||||
|
Column: 4,
|
||||||
|
StackTrace: "Error: boom",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, out, err := sub.webviewErrors(context.Background(), nil, WebviewErrorsInput{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, out.Errors, 1)
|
||||||
|
assert.Equal(t, "boom", out.Errors[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMCP_Bad_NoServices(t *testing.T) {
|
func TestMCP_Bad_NoServices(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c, _ := core.New(core.WithServiceLock())
|
||||||
// Without any services, QUERY should return handled=false
|
// Without any services, QUERY should return handled=false
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/display"
|
||||||
"forge.lthn.ai/core/gui/pkg/screen"
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -109,6 +111,33 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
|
||||||
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- screen_work_area ---
|
||||||
|
|
||||||
|
func (s *Subsystem) screenWorkArea(ctx context.Context, req *mcp.CallToolRequest, input ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
|
||||||
|
return s.screenWorkAreas(ctx, req, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- screen_for_window ---
|
||||||
|
|
||||||
|
type ScreenForWindowInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
type ScreenForWindowOutput struct {
|
||||||
|
Screen *screen.Screen `json:"screen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
|
||||||
|
svc, err := core.ServiceFor[*display.Service](s.core, "display")
|
||||||
|
if err != nil {
|
||||||
|
return nil, ScreenForWindowOutput{}, err
|
||||||
|
}
|
||||||
|
scr, err := svc.GetScreenForWindow(input.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ScreenForWindowOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, ScreenForWindowOutput{Screen: scr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
|
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
|
||||||
|
|
@ -117,4 +146,6 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary)
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Alias for screen_work_areas"}, s.screenWorkArea)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,29 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques
|
||||||
return nil, WebviewConsoleClearOutput{Success: true}, nil
|
return nil, WebviewConsoleClearOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- webview_errors ---
|
||||||
|
|
||||||
|
type WebviewErrorsInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewErrorsOutput struct {
|
||||||
|
Errors []webview.ExceptionInfo `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewErrors(_ context.Context, _ *mcp.CallToolRequest, input WebviewErrorsInput) (*mcp.CallToolResult, WebviewErrorsOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(webview.QueryExceptions{Window: input.Window, Limit: input.Limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewErrorsOutput{}, err
|
||||||
|
}
|
||||||
|
errors, ok := result.([]webview.ExceptionInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewErrorsOutput{}, fmt.Errorf("unexpected result type from webview errors query")
|
||||||
|
}
|
||||||
|
return nil, WebviewErrorsOutput{Errors: errors}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- webview_devtools_open ---
|
// --- webview_devtools_open ---
|
||||||
|
|
||||||
type WebviewDevToolsOpenInput struct {
|
type WebviewDevToolsOpenInput struct {
|
||||||
|
|
@ -659,6 +682,7 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_clear_console", Description: "Alias for webview_console_clear"}, s.webviewConsoleClear)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_clear_console", Description: "Alias for webview_console_clear"}, s.webviewConsoleClear)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_errors", Description: "Get captured JavaScript exceptions from a webview"}, s.webviewErrors)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll)
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,12 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
|
||||||
return nil, WindowTitleOutput{Success: true}, nil
|
return nil, WindowTitleOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- window_title_set ---
|
||||||
|
|
||||||
|
func (s *Subsystem) windowTitleSet(ctx context.Context, req *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
|
||||||
|
return s.windowTitle(ctx, req, input)
|
||||||
|
}
|
||||||
|
|
||||||
// --- window_title_get ---
|
// --- window_title_get ---
|
||||||
|
|
||||||
type WindowTitleGetInput struct {
|
type WindowTitleGetInput struct {
|
||||||
|
|
@ -409,6 +415,7 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, s.windowFocus)
|
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, s.windowFocus)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_title_set", Description: "Alias for window_title"}, s.windowTitleSet)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ type QueryNetwork struct {
|
||||||
Limit int `json:"limit,omitempty"`
|
Limit int `json:"limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryExceptions returns captured JavaScript exceptions.
|
||||||
|
type QueryExceptions struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tasks (side-effects) ---
|
// --- Tasks (side-effects) ---
|
||||||
|
|
||||||
// TaskEvaluate executes JavaScript. Result: any (JS return value)
|
// TaskEvaluate executes JavaScript. Result: any (JS return value)
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
opts Options
|
opts Options
|
||||||
connections map[string]connector
|
connections map[string]connector
|
||||||
|
exceptions map[string][]ExceptionInfo
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
||||||
watcherSetup func(conn connector, windowName string) // called after connection creation
|
watcherSetup func(conn connector, windowName string) // called after connection creation
|
||||||
|
|
@ -80,6 +81,7 @@ func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
||||||
opts: o,
|
opts: o,
|
||||||
connections: make(map[string]connector),
|
connections: make(map[string]connector),
|
||||||
|
exceptions: make(map[string][]ExceptionInfo),
|
||||||
newConn: defaultNewConn(o),
|
newConn: defaultNewConn(o),
|
||||||
}
|
}
|
||||||
svc.watcherSetup = svc.defaultWatcherSetup
|
svc.watcherSetup = svc.defaultWatcherSetup
|
||||||
|
|
@ -192,7 +194,10 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
delete(s.connections, m.Name)
|
delete(s.connections, m.Name)
|
||||||
}
|
}
|
||||||
|
delete(s.exceptions, m.Name)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
case ActionException:
|
||||||
|
s.recordException(m.Window, m.Exception)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +346,8 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
|
||||||
return nil, true, err
|
return nil, true, err
|
||||||
}
|
}
|
||||||
return entries, true, nil
|
return entries, true, nil
|
||||||
|
case QueryExceptions:
|
||||||
|
return s.queryExceptions(q.Window, q.Limit), true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -510,6 +517,28 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordException(windowName string, exc ExceptionInfo) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
exceptions := append(s.exceptions[windowName], exc)
|
||||||
|
if limit := s.opts.ConsoleLimit; limit > 0 && len(exceptions) > limit {
|
||||||
|
exceptions = exceptions[len(exceptions)-limit:]
|
||||||
|
}
|
||||||
|
s.exceptions[windowName] = exceptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) queryExceptions(windowName string, limit int) []ExceptionInfo {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
exceptions := append([]ExceptionInfo(nil), s.exceptions[windowName]...)
|
||||||
|
if limit > 0 && len(exceptions) > limit {
|
||||||
|
exceptions = exceptions[len(exceptions)-limit:]
|
||||||
|
}
|
||||||
|
return exceptions
|
||||||
|
}
|
||||||
|
|
||||||
func coerceJSON[T any](v any) (T, error) {
|
func coerceJSON[T any](v any) (T, error) {
|
||||||
var out T
|
var out T
|
||||||
raw, err := json.Marshal(v)
|
raw, err := json.Marshal(v)
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,29 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
|
||||||
assert.Equal(t, "b", msgs[0].Text) // last 2
|
assert.Equal(t, "b", msgs[0].Text) // last 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryExceptions_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t, &mockConnector{})
|
||||||
|
|
||||||
|
require.NoError(t, c.ACTION(ActionException{
|
||||||
|
Window: "main",
|
||||||
|
Exception: ExceptionInfo{
|
||||||
|
Text: "boom",
|
||||||
|
URL: "https://example.com/app.js",
|
||||||
|
Line: 12,
|
||||||
|
Column: 4,
|
||||||
|
StackTrace: "Error: boom",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
result, handled, err := c.QUERY(QueryExceptions{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
exceptions, _ := result.([]ExceptionInfo)
|
||||||
|
require.Len(t, exceptions, 1)
|
||||||
|
assert.Equal(t, "boom", exceptions[0].Text)
|
||||||
|
assert.Equal(t, 12, exceptions[0].Line)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskEvaluate_Good(t *testing.T) {
|
func TestTaskEvaluate_Good(t *testing.T) {
|
||||||
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
||||||
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
|
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue