feat(gui): add webview diagnostics and tray fallback
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
3413b64f6c
commit
a0cad39fbb
8 changed files with 795 additions and 18 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
157
pkg/webview/diagnostics.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue