From a0cad39fbbff238e5eb3bc9bdeecad4c97342cd0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 13:29:46 +0000 Subject: [PATCH] feat(gui): add webview diagnostics and tray fallback Co-Authored-By: Virgil --- pkg/display/display.go | 67 ++++++++++++ pkg/mcp/tools_webview.go | 196 +++++++++++++++++++++++++++++++++++ pkg/systray/mock_platform.go | 13 ++- pkg/systray/wails.go | 2 - pkg/webview/diagnostics.go | 157 ++++++++++++++++++++++++++++ pkg/webview/messages.go | 89 ++++++++++++++++ pkg/webview/service.go | 169 ++++++++++++++++++++++++++++++ pkg/webview/service_test.go | 120 +++++++++++++++++++-- 8 files changed, 795 insertions(+), 18 deletions(-) create mode 100644 pkg/webview/diagnostics.go diff --git a/pkg/display/display.go b/pkg/display/display.go index 4785725..8eabd36 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -416,6 +416,73 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { return nil, false, e } result, handled, err = s.Core().PERFORM(webview.TaskClearConsole{Window: w}) + case "webview:highlight": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } + colour, _ := msg.Data["colour"].(string) + result, handled, err = s.Core().PERFORM(webview.TaskHighlight{Window: w, Selector: sel, Colour: colour}) + case "webview:computed-style": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(webview.QueryComputedStyle{Window: w, Selector: sel}) + case "webview:performance": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(webview.QueryPerformance{Window: w}) + case "webview:resources": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().QUERY(webview.QueryResources{Window: w}) + case "webview:network": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + limit := 0 + if l, ok := msg.Data["limit"].(float64); ok { + limit = int(l) + } + result, handled, err = s.Core().QUERY(webview.QueryNetwork{Window: w, Limit: limit}) + case "webview:network-inject": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(webview.TaskInjectNetworkLogging{Window: w}) + case "webview:network-clear": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(webview.TaskClearNetworkLog{Window: w}) + case "webview:print": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(webview.TaskPrint{Window: w}) + case "webview:pdf": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(webview.TaskExportPDF{Window: w}) case "webview:console": w, e := wsRequire(msg.Data, "window") if e != nil { diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index 1011f4b..3f4c368 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -374,6 +374,193 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in return nil, WebviewDOMTreeOutput{HTML: html}, nil } +// --- webview_computed_style --- + +type WebviewComputedStyleInput struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +type WebviewComputedStyleOutput struct { + Style map[string]string `json:"style"` +} + +func (s *Subsystem) webviewComputedStyle(_ context.Context, _ *mcp.CallToolRequest, input WebviewComputedStyleInput) (*mcp.CallToolResult, WebviewComputedStyleOutput, error) { + result, _, err := s.core.QUERY(webview.QueryComputedStyle{Window: input.Window, Selector: input.Selector}) + if err != nil { + return nil, WebviewComputedStyleOutput{}, err + } + style, ok := result.(map[string]string) + if !ok { + return nil, WebviewComputedStyleOutput{}, fmt.Errorf("unexpected result type from webview computed style query") + } + return nil, WebviewComputedStyleOutput{Style: style}, nil +} + +// --- webview_performance --- + +type WebviewPerformanceInput struct { + Window string `json:"window"` +} + +type WebviewPerformanceOutput struct { + Metrics webview.PerformanceMetrics `json:"metrics"` +} + +func (s *Subsystem) webviewPerformance(_ context.Context, _ *mcp.CallToolRequest, input WebviewPerformanceInput) (*mcp.CallToolResult, WebviewPerformanceOutput, error) { + result, _, err := s.core.QUERY(webview.QueryPerformance{Window: input.Window}) + if err != nil { + return nil, WebviewPerformanceOutput{}, err + } + metrics, ok := result.(webview.PerformanceMetrics) + if !ok { + return nil, WebviewPerformanceOutput{}, fmt.Errorf("unexpected result type from webview performance query") + } + return nil, WebviewPerformanceOutput{Metrics: metrics}, nil +} + +// --- webview_resources --- + +type WebviewResourcesInput struct { + Window string `json:"window"` +} + +type WebviewResourcesOutput struct { + Resources []webview.ResourceEntry `json:"resources"` +} + +func (s *Subsystem) webviewResources(_ context.Context, _ *mcp.CallToolRequest, input WebviewResourcesInput) (*mcp.CallToolResult, WebviewResourcesOutput, error) { + result, _, err := s.core.QUERY(webview.QueryResources{Window: input.Window}) + if err != nil { + return nil, WebviewResourcesOutput{}, err + } + resources, ok := result.([]webview.ResourceEntry) + if !ok { + return nil, WebviewResourcesOutput{}, fmt.Errorf("unexpected result type from webview resources query") + } + return nil, WebviewResourcesOutput{Resources: resources}, nil +} + +// --- webview_network --- + +type WebviewNetworkInput struct { + Window string `json:"window"` + Limit int `json:"limit,omitempty"` +} + +type WebviewNetworkOutput struct { + Requests []webview.NetworkEntry `json:"requests"` +} + +func (s *Subsystem) webviewNetwork(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInput) (*mcp.CallToolResult, WebviewNetworkOutput, error) { + result, _, err := s.core.QUERY(webview.QueryNetwork{Window: input.Window, Limit: input.Limit}) + if err != nil { + return nil, WebviewNetworkOutput{}, err + } + requests, ok := result.([]webview.NetworkEntry) + if !ok { + return nil, WebviewNetworkOutput{}, fmt.Errorf("unexpected result type from webview network query") + } + return nil, WebviewNetworkOutput{Requests: requests}, nil +} + +// --- webview_network_inject --- + +type WebviewNetworkInjectInput struct { + Window string `json:"window"` +} + +type WebviewNetworkInjectOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewNetworkInject(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInjectInput) (*mcp.CallToolResult, WebviewNetworkInjectOutput, error) { + _, _, err := s.core.PERFORM(webview.TaskInjectNetworkLogging{Window: input.Window}) + if err != nil { + return nil, WebviewNetworkInjectOutput{}, err + } + return nil, WebviewNetworkInjectOutput{Success: true}, nil +} + +// --- webview_network_clear --- + +type WebviewNetworkClearInput struct { + Window string `json:"window"` +} + +type WebviewNetworkClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewNetworkClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkClearInput) (*mcp.CallToolResult, WebviewNetworkClearOutput, error) { + _, _, err := s.core.PERFORM(webview.TaskClearNetworkLog{Window: input.Window}) + if err != nil { + return nil, WebviewNetworkClearOutput{}, err + } + return nil, WebviewNetworkClearOutput{Success: true}, nil +} + +// --- webview_highlight --- + +type WebviewHighlightInput struct { + Window string `json:"window"` + Selector string `json:"selector"` + Colour string `json:"colour,omitempty"` +} + +type WebviewHighlightOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewHighlight(_ context.Context, _ *mcp.CallToolRequest, input WebviewHighlightInput) (*mcp.CallToolResult, WebviewHighlightOutput, error) { + _, _, err := s.core.PERFORM(webview.TaskHighlight{Window: input.Window, Selector: input.Selector, Colour: input.Colour}) + if err != nil { + return nil, WebviewHighlightOutput{}, err + } + return nil, WebviewHighlightOutput{Success: true}, nil +} + +// --- webview_print --- + +type WebviewPrintInput struct { + Window string `json:"window"` +} + +type WebviewPrintOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewPrint(_ context.Context, _ *mcp.CallToolRequest, input WebviewPrintInput) (*mcp.CallToolResult, WebviewPrintOutput, error) { + _, _, err := s.core.PERFORM(webview.TaskPrint{Window: input.Window}) + if err != nil { + return nil, WebviewPrintOutput{}, err + } + return nil, WebviewPrintOutput{Success: true}, nil +} + +// --- webview_pdf --- + +type WebviewPDFInput struct { + Window string `json:"window"` +} + +type WebviewPDFOutput struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` +} + +func (s *Subsystem) webviewPDF(_ context.Context, _ *mcp.CallToolRequest, input WebviewPDFInput) (*mcp.CallToolResult, WebviewPDFOutput, error) { + result, _, err := s.core.PERFORM(webview.TaskExportPDF{Window: input.Window}) + if err != nil { + return nil, WebviewPDFOutput{}, err + } + pdf, ok := result.(webview.PDFResult) + if !ok { + return nil, WebviewPDFOutput{}, fmt.Errorf("unexpected result type from webview pdf task") + } + return nil, WebviewPDFOutput{Base64: pdf.Base64, MimeType: pdf.MimeType}, nil +} + // --- webview_url --- type WebviewURLInput struct { @@ -437,6 +624,15 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) { 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_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll) mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree) + mcp.AddTool(server, &mcp.Tool{Name: "webview_computed_style", Description: "Get computed styles for an element"}, s.webviewComputedStyle) + mcp.AddTool(server, &mcp.Tool{Name: "webview_performance", Description: "Get page performance metrics"}, s.webviewPerformance) + mcp.AddTool(server, &mcp.Tool{Name: "webview_resources", Description: "List loaded page resources"}, s.webviewResources) + mcp.AddTool(server, &mcp.Tool{Name: "webview_network", Description: "Get captured network requests"}, s.webviewNetwork) + mcp.AddTool(server, &mcp.Tool{Name: "webview_network_inject", Description: "Inject fetch/XHR network logging"}, s.webviewNetworkInject) + mcp.AddTool(server, &mcp.Tool{Name: "webview_network_clear", Description: "Clear captured network requests"}, s.webviewNetworkClear) + mcp.AddTool(server, &mcp.Tool{Name: "webview_highlight", Description: "Visually highlight an element"}, s.webviewHighlight) + mcp.AddTool(server, &mcp.Tool{Name: "webview_print", Description: "Open the browser print dialog"}, s.webviewPrint) + mcp.AddTool(server, &mcp.Tool{Name: "webview_pdf", Description: "Export the current page as a PDF"}, s.webviewPDF) mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL) mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle) mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_open", Description: "Open devtools for a webview window"}, s.webviewDevToolsOpen) diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go index 238b3d0..7e41d04 100644 --- a/pkg/systray/mock_platform.go +++ b/pkg/systray/mock_platform.go @@ -13,13 +13,12 @@ type exportedMockTray struct { tooltip, label string } -func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } -func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } -func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } -func (t *exportedMockTray) SetLabel(text string) { t.label = text } -func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} -func (t *exportedMockTray) AttachWindow(w WindowHandle) {} -func (t *exportedMockTray) ShowMessage(title, message string) {} +func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } +func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } +func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } +func (t *exportedMockTray) SetLabel(text string) { t.label = text } +func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} +func (t *exportedMockTray) AttachWindow(w WindowHandle) {} type exportedMockMenu struct{ items []exportedMockMenuItem } diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go index 02eca42..f1bc8de 100644 --- a/pkg/systray/wails.go +++ b/pkg/systray/wails.go @@ -43,8 +43,6 @@ func (wt *wailsTray) AttachWindow(w WindowHandle) { // The caller must pass an appropriate wrapper. } -func (wt *wailsTray) ShowMessage(title, message string) {} - // wailsTrayMenu wraps *application.Menu for the PlatformMenu interface. type wailsTrayMenu struct { menu *application.Menu diff --git a/pkg/webview/diagnostics.go b/pkg/webview/diagnostics.go new file mode 100644 index 0000000..60afb7f --- /dev/null +++ b/pkg/webview/diagnostics.go @@ -0,0 +1,157 @@ +package webview + +import ( + "encoding/json" + "fmt" + "strings" +) + +func jsQuote(v string) string { + b, _ := json.Marshal(v) + return string(b) +} + +func computedStyleScript(selector string) string { + sel := jsQuote(selector) + return fmt.Sprintf(`(function(){ + const el = document.querySelector(%s); + if (!el) return null; + const style = window.getComputedStyle(el); + const out = {}; + for (let i = 0; i < style.length; i++) { + const key = style[i]; + out[key] = style.getPropertyValue(key); + } + return out; +})()`, sel) +} + +func highlightScript(selector, colour string) string { + sel := jsQuote(selector) + if colour == "" { + colour = "#ff9800" + } + col := jsQuote(colour) + return fmt.Sprintf(`(function(){ + const el = document.querySelector(%s); + if (!el) return false; + if (el.__coreHighlightOrigOutline === undefined) { + el.__coreHighlightOrigOutline = el.style.outline || ""; + } + el.style.outline = "3px solid " + %s; + el.style.outlineOffset = "2px"; + try { el.scrollIntoView({block: "center", inline: "center", behavior: "smooth"}); } catch (e) {} + return true; +})()`, sel, col) +} + +func performanceScript() string { + return `(function(){ + const nav = performance.getEntriesByType("navigation")[0] || {}; + const paints = performance.getEntriesByType("paint"); + const firstPaint = paints.find((entry) => entry.name === "first-paint"); + const firstContentfulPaint = paints.find((entry) => entry.name === "first-contentful-paint"); + const memory = performance.memory || {}; + return { + navigationStart: nav.startTime || 0, + domContentLoaded: nav.domContentLoadedEventEnd || 0, + loadEventEnd: nav.loadEventEnd || 0, + firstPaint: firstPaint ? firstPaint.startTime : 0, + firstContentfulPaint: firstContentfulPaint ? firstContentfulPaint.startTime : 0, + usedJSHeapSize: memory.usedJSHeapSize || 0, + totalJSHeapSize: memory.totalJSHeapSize || 0 + }; +})()` +} + +func resourcesScript() string { + return `(function(){ + return performance.getEntriesByType("resource").map((entry) => ({ + name: entry.name, + entryType: entry.entryType, + initiatorType: entry.initiatorType, + startTime: entry.startTime, + duration: entry.duration, + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0, + decodedBodySize: entry.decodedBodySize || 0 + })); +})()` +} + +func networkInitScript() string { + return `(function(){ + if (window.__coreNetworkLog) return true; + window.__coreNetworkLog = []; + const log = (entry) => { window.__coreNetworkLog.push(entry); }; + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const request = typeof input === "string" ? input : (input && input.url) ? input.url : ""; + const method = (init && init.method) || (input && input.method) || "GET"; + const started = Date.now(); + try { + const response = await originalFetch.call(this, input, init); + log({ + url: response.url || request, + method: method, + status: response.status, + ok: response.ok, + resource: "fetch", + timestamp: started + }); + return response; + } catch (error) { + log({ + url: request, + method: method, + error: String(error), + resource: "fetch", + timestamp: started + }); + throw error; + } + }; + } + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__coreMethod = method; + this.__coreUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function(body) { + const started = Date.now(); + this.addEventListener("loadend", () => { + log({ + url: this.__coreUrl || "", + method: this.__coreMethod || "GET", + status: this.status || 0, + ok: this.status >= 200 && this.status < 400, + resource: "xhr", + timestamp: started + }); + }); + return originalSend.apply(this, arguments); + }; + return true; +})()` +} + +func networkClearScript() string { + return `(function(){ + window.__coreNetworkLog = []; + return true; +})()` +} + +func networkLogScript(limit int) string { + if limit <= 0 { + return `(window.__coreNetworkLog || [])` + } + return fmt.Sprintf(`(window.__coreNetworkLog || []).slice(-%d)`, limit) +} + +func normalizeWhitespace(s string) string { + return strings.TrimSpace(s) +} diff --git a/pkg/webview/messages.go b/pkg/webview/messages.go index 1e4a426..a063438 100644 --- a/pkg/webview/messages.go +++ b/pkg/webview/messages.go @@ -40,6 +40,28 @@ type QueryDOMTree struct { Selector string `json:"selector,omitempty"` // empty = full document } +// QueryComputedStyle returns the computed CSS properties for an element. +type QueryComputedStyle struct { + Window string `json:"window"` + Selector string `json:"selector"` +} + +// QueryPerformance returns page performance metrics. +type QueryPerformance struct { + Window string `json:"window"` +} + +// QueryResources returns the page's loaded resource entries. +type QueryResources struct { + Window string `json:"window"` +} + +// QueryNetwork returns the captured network log. +type QueryNetwork struct { + Window string `json:"window"` + Limit int `json:"limit,omitempty"` +} + // --- Tasks (side-effects) --- // TaskEvaluate executes JavaScript. Result: any (JS return value) @@ -118,6 +140,13 @@ type TaskClearConsole struct { Window string `json:"window"` } +// TaskHighlight visually highlights an element. +type TaskHighlight struct { + Window string `json:"window"` + Selector string `json:"selector"` + Colour string `json:"colour,omitempty"` +} + // TaskOpenDevTools opens the browser devtools for the target window. Result: nil type TaskOpenDevTools struct { Window string `json:"window"` @@ -128,6 +157,26 @@ type TaskCloseDevTools struct { Window string `json:"window"` } +// TaskInjectNetworkLogging injects fetch/XHR interception into the page. +type TaskInjectNetworkLogging struct { + Window string `json:"window"` +} + +// TaskClearNetworkLog clears the captured network log. +type TaskClearNetworkLog struct { + Window string `json:"window"` +} + +// TaskPrint prints the current page using the browser's native print flow. +type TaskPrint struct { + Window string `json:"window"` +} + +// TaskExportPDF exports the page to a PDF document. +type TaskExportPDF struct { + Window string `json:"window"` +} + // --- Actions (broadcast) --- // ActionConsoleMessage is broadcast when a console message is captured. @@ -187,3 +236,43 @@ type ScreenshotResult struct { Base64 string `json:"base64"` MimeType string `json:"mimeType"` // always "image/png" } + +// PerformanceMetrics summarises browser performance timings. +type PerformanceMetrics struct { + NavigationStart float64 `json:"navigationStart"` + DOMContentLoaded float64 `json:"domContentLoaded"` + LoadEventEnd float64 `json:"loadEventEnd"` + FirstPaint float64 `json:"firstPaint,omitempty"` + FirstContentfulPaint float64 `json:"firstContentfulPaint,omitempty"` + UsedJSHeapSize float64 `json:"usedJSHeapSize,omitempty"` + TotalJSHeapSize float64 `json:"totalJSHeapSize,omitempty"` +} + +// ResourceEntry summarises a loaded resource. +type ResourceEntry struct { + Name string `json:"name"` + EntryType string `json:"entryType"` + InitiatorType string `json:"initiatorType,omitempty"` + StartTime float64 `json:"startTime"` + Duration float64 `json:"duration"` + TransferSize float64 `json:"transferSize,omitempty"` + EncodedBodySize float64 `json:"encodedBodySize,omitempty"` + DecodedBodySize float64 `json:"decodedBodySize,omitempty"` +} + +// NetworkEntry summarises a captured fetch/XHR request. +type NetworkEntry struct { + URL string `json:"url"` + Method string `json:"method"` + Status int `json:"status,omitempty"` + Resource string `json:"resource,omitempty"` + OK bool `json:"ok,omitempty"` + Error string `json:"error,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +// PDFResult contains exported PDF bytes encoded for transport. +type PDFResult struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` // always "application/pdf" +} diff --git a/pkg/webview/service.go b/pkg/webview/service.go index 313156e..44c4266 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -4,10 +4,14 @@ package webview import ( "context" "encoding/base64" + "encoding/json" + "fmt" + "reflect" "strconv" "strings" "sync" "time" + "unsafe" gowebview "forge.lthn.ai/core/go-webview" "forge.lthn.ai/core/go/pkg/core" @@ -34,6 +38,8 @@ type connector interface { ClearConsole() SetViewport(width, height int) error UploadFile(selector string, paths []string) error + Print() error + PrintToPDF() ([]byte, error) Close() error } @@ -274,6 +280,62 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) { } html, err := conn.GetHTML(selector) return html, true, err + case QueryComputedStyle: + conn, err := s.getConn(q.Window) + if err != nil { + return nil, true, err + } + result, err := conn.Evaluate(computedStyleScript(q.Selector)) + if err != nil { + return nil, true, err + } + style, err := coerceToMapStringString(result) + if err != nil { + return nil, true, err + } + return style, true, nil + case QueryPerformance: + conn, err := s.getConn(q.Window) + if err != nil { + return nil, true, err + } + result, err := conn.Evaluate(performanceScript()) + if err != nil { + return nil, true, err + } + metrics, err := coerceToPerformanceMetrics(result) + if err != nil { + return nil, true, err + } + return metrics, true, nil + case QueryResources: + conn, err := s.getConn(q.Window) + if err != nil { + return nil, true, err + } + result, err := conn.Evaluate(resourcesScript()) + if err != nil { + return nil, true, err + } + resources, err := coerceToResourceEntries(result) + if err != nil { + return nil, true, err + } + return resources, true, nil + case QueryNetwork: + conn, err := s.getConn(q.Window) + if err != nil { + return nil, true, err + } + result, err := conn.Evaluate(networkLogScript(q.Limit)) + if err != nil { + return nil, true, err + } + entries, err := coerceToNetworkEntries(result) + if err != nil { + return nil, true, err + } + return entries, true, nil default: return nil, false, nil } @@ -363,15 +425,83 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) { } conn.ClearConsole() return nil, true, nil + case TaskHighlight: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + _, err = conn.Evaluate(highlightScript(t.Selector, t.Colour)) + return nil, true, err case TaskOpenDevTools: return nil, true, nil case TaskCloseDevTools: return nil, true, nil + case TaskInjectNetworkLogging: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + _, err = conn.Evaluate(networkInitScript()) + return nil, true, err + case TaskClearNetworkLog: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + _, err = conn.Evaluate(networkClearScript()) + return nil, true, err + case TaskPrint: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + return nil, true, conn.Print() + case TaskExportPDF: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + pdf, err := conn.PrintToPDF() + if err != nil { + return nil, true, err + } + return PDFResult{ + Base64: base64.StdEncoding.EncodeToString(pdf), + MimeType: "application/pdf", + }, true, nil default: return nil, false, nil } } +func coerceJSON[T any](v any) (T, error) { + var out T + raw, err := json.Marshal(v) + if err != nil { + return out, err + } + if err := json.Unmarshal(raw, &out); err != nil { + return out, err + } + return out, nil +} + +func coerceToMapStringString(v any) (map[string]string, error) { + return coerceJSON[map[string]string](v) +} + +func coerceToPerformanceMetrics(v any) (PerformanceMetrics, error) { + return coerceJSON[PerformanceMetrics](v) +} + +func coerceToResourceEntries(v any) ([]ResourceEntry, error) { + return coerceJSON[[]ResourceEntry](v) +} + +func coerceToNetworkEntries(v any) ([]NetworkEntry, error) { + return coerceJSON[[]NetworkEntry](v) +} + // realConnector wraps *gowebview.Webview, converting types at the boundary. type realConnector struct { wv *gowebview.Webview @@ -386,9 +516,48 @@ func (r *realConnector) GetURL() (string, error) { return r.wv.G func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() } func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) } func (r *realConnector) ClearConsole() { r.wv.ClearConsole() } +func (r *realConnector) Print() error { _, err := r.wv.Evaluate("window.print()"); return err } func (r *realConnector) Close() error { return r.wv.Close() } func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) } func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) } +func (r *realConnector) PrintToPDF() ([]byte, error) { + client, err := r.cdpClient() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + result, err := client.Call(ctx, "Page.printToPDF", map[string]any{ + "printBackground": true, + "preferCSSPageSize": true, + }) + if err != nil { + return nil, err + } + data, ok := result["data"].(string) + if !ok || data == "" { + return nil, fmt.Errorf("webview: missing PDF data") + } + return base64.StdEncoding.DecodeString(data) +} + +func (r *realConnector) cdpClient() (*gowebview.CDPClient, error) { + rv := reflect.ValueOf(r.wv) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return nil, fmt.Errorf("webview: invalid connector") + } + elem := rv.Elem() + field := elem.FieldByName("client") + if !field.IsValid() || field.IsNil() { + return nil, fmt.Errorf("webview: CDP client not available") + } + ptr := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface() + client, ok := ptr.(*gowebview.CDPClient) + if !ok || client == nil { + return nil, fmt.Errorf("webview: unexpected CDP client type") + } + return client, nil +} func (r *realConnector) Hover(sel string) error { return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv) diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 6551369..cf605ae 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -3,6 +3,7 @@ package webview import ( "context" + "strings" "testing" "forge.lthn.ai/core/go/pkg/core" @@ -12,14 +13,17 @@ import ( ) type mockConnector struct { - url string - title string - html string - evalResult any - screenshot []byte - console []ConsoleMessage - elements []*ElementInfo - closed bool + url string + title string + html string + evalResult any + evalFn func(script string) (any, error) + screenshot []byte + console []ConsoleMessage + elements []*ElementInfo + closed bool + pdfBytes []byte + printCalled bool lastClickSel string lastTypeSel string @@ -35,6 +39,7 @@ type mockConnector struct { lastViewportW int lastViewportH int consoleClearCalled bool + lastEvalScript string } func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil } @@ -55,18 +60,31 @@ func (m *mockConnector) Check(sel string, c bool) error { m.lastCheckVal = c return nil } -func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil } +func (m *mockConnector) Evaluate(s string) (any, error) { + m.lastEvalScript = s + if m.evalFn != nil { + return m.evalFn(s) + } + return m.evalResult, nil +} func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil } func (m *mockConnector) GetURL() (string, error) { return m.url, nil } func (m *mockConnector) GetTitle() (string, error) { return m.title, nil } func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil } func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true } +func (m *mockConnector) Print() error { m.printCalled = true; return nil } func (m *mockConnector) Close() error { m.closed = true; return nil } func (m *mockConnector) SetViewport(w, h int) error { m.lastViewportW = w m.lastViewportH = h return nil } +func (m *mockConnector) PrintToPDF() ([]byte, error) { + if len(m.pdfBytes) == 0 { + return []byte("%PDF-1.4\n"), nil + } + return m.pdfBytes, nil +} func (m *mockConnector) UploadFile(sel string, p []string) error { m.lastUploadSel = sel m.lastUploadPaths = p @@ -204,6 +222,90 @@ func TestTaskDevTools_Good(t *testing.T) { assert.True(t, handled) } +func TestDiagnosticsQueries_Good(t *testing.T) { + mock := &mockConnector{ + evalFn: func(script string) (any, error) { + switch { + case strings.Contains(script, "getComputedStyle"): + return map[string]any{"color": "rgb(1, 2, 3)"}, nil + case strings.Contains(script, "performance.getEntriesByType(\"navigation\")"): + return map[string]any{ + "navigationStart": 1.0, + "domContentLoaded": 2.0, + "loadEventEnd": 3.0, + "firstPaint": 4.0, + "firstContentfulPaint": 5.0, + "usedJSHeapSize": 6.0, + "totalJSHeapSize": 7.0, + }, nil + case strings.Contains(script, "performance.getEntriesByType(\"resource\")"): + return []any{ + map[string]any{"name": "app.js", "entryType": "resource", "initiatorType": "script"}, + }, nil + case strings.Contains(script, "window.__coreNetworkLog"): + return []any{ + map[string]any{"url": "https://example.com", "method": "GET", "status": 200, "resource": "fetch"}, + }, nil + default: + return nil, nil + } + }, + } + _, c := newTestService(t, mock) + + style, handled, err := c.QUERY(QueryComputedStyle{Window: "main", Selector: "#app"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "rgb(1, 2, 3)", style.(map[string]string)["color"]) + + perf, handled, err := c.QUERY(QueryPerformance{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 1.0, perf.(PerformanceMetrics).NavigationStart) + + resources, handled, err := c.QUERY(QueryResources{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Len(t, resources.([]ResourceEntry), 1) + + network, handled, err := c.QUERY(QueryNetwork{Window: "main", Limit: 10}) + require.NoError(t, err) + assert.True(t, handled) + assert.Len(t, network.([]NetworkEntry), 1) +} + +func TestDiagnosticsTasks_Good(t *testing.T) { + mock := &mockConnector{pdfBytes: []byte("%PDF-1.7")} + _, c := newTestService(t, mock) + + _, handled, err := c.PERFORM(TaskHighlight{Window: "main", Selector: "#app", Colour: "#00ff00"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Contains(t, mock.lastEvalScript, "outline") + + _, handled, err = c.PERFORM(TaskInjectNetworkLogging{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Contains(t, mock.lastEvalScript, "__coreNetworkLog") + + _, handled, err = c.PERFORM(TaskClearNetworkLog{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + + _, handled, err = c.PERFORM(TaskPrint{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.printCalled) + + result, handled, err := c.PERFORM(TaskExportPDF{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + pdf, ok := result.(PDFResult) + require.True(t, ok) + assert.Equal(t, "application/pdf", pdf.MimeType) + assert.NotEmpty(t, pdf.Base64) +} + func TestConnectionCleanup_Good(t *testing.T) { mock := &mockConnector{} _, c := newTestService(t, mock)