feat(gui): add webview diagnostics and tray fallback
Some checks failed
Security Scan / security (push) Failing after 22s
Test / test (push) Successful in 1m25s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 13:29:46 +00:00
parent 3413b64f6c
commit a0cad39fbb
8 changed files with 795 additions and 18 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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 }

View file

@ -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

157
pkg/webview/diagnostics.go Normal file
View file

@ -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)
}

View file

@ -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"
}

View file

@ -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)

View file

@ -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)