diff --git a/cmd/sin-code/chat_tools_extra.go b/cmd/sin-code/chat_tools_extra.go index ca20444b..15dfb507 100644 --- a/cmd/sin-code/chat_tools_extra.go +++ b/cmd/sin-code/chat_tools_extra.go @@ -1,7 +1,13 @@ // SPDX-License-Identifier: MIT // Purpose: extended builtin tools — git operations (read-only allow, -// mutating ask), bounded HTTP fetch, and test runner. Closes the gap -// between "needs sin_bash" and "needs a real tool". +// mutating ask), bounded HTTP fetch, test runner, and browser CDP recording. +// Closes the gap between "needs sin_bash" and "needs a real tool". +// +// Browser tools (sin_browser_*) use pkg/browser/cdp to drive a headless +// Chrome instance via the Chrome DevTools Protocol. They capture the full +// event stream — network, console, exceptions, DevTools Audits (CORS/CSP), +// security state — and surface deterministic Findings so the agent loop can +// act on problems without reading raw JSONL. package main import ( @@ -15,6 +21,9 @@ import ( "path/filepath" "strings" "time" + + "github.com/OpenSIN-Code/SIN-Code/pkg/browser/cdp" + "github.com/chromedp/chromedp" ) const ( @@ -40,6 +49,26 @@ func extraSpecs() []agentloopToolSpecAlias { InputSchema: obj(map[string]any{"url": str("http(s) URL")}, "url")}, {Name: "sin_test", Description: "Run the workspace test suite and return structured pass/fail output.", InputSchema: obj(map[string]any{"target": str("optional package/file filter")})}, + // Browser CDP tools — headless Chrome via Chrome DevTools Protocol. + // sin_browser_navigate starts a fresh recording session; subsequent + // sin_browser_findings / sin_browser_snapshot calls consume it. + {Name: "sin_browser_navigate", Description: "Navigate headless Chrome to a URL and record the full CDP event stream (network, console, exceptions, DevTools Audits, security, Web Vitals). Returns event counts. Call sin_browser_findings after to get the full Report. Set save_baseline=true before applying a fix so sin_browser_diff can compare before/after.", + InputSchema: obj(map[string]any{ + "url": str("http(s) URL to navigate to"), + "step": str("optional label for correlation (e.g. 'login_submit')"), + "wait_sec": str("seconds to wait after navigation (default 3)"), + "save_baseline": str("set to 'true' to save this session's Report as the baseline for sin_browser_diff"), + }, "url")}, + {Name: "sin_browser_findings", Description: "Return a full structured Report from the last sin_browser_navigate session: classified Findings (network/console/exception/audit/security/vital), root-cause Chains, FixSuggestions with FixClass routing tags, and a Summary (errors/warnings/has_fatal). Use this instead of reading raw JSONL.", + InputSchema: obj(map[string]any{})}, + {Name: "sin_browser_snapshot", Description: "Return a compact JSON summary of the last sin_browser_navigate session: event counts by domain, first/last wall times, finding count, and the full Report. Useful for a quick health check before calling sin_browser_diff.", + InputSchema: obj(map[string]any{})}, + {Name: "sin_browser_vitals_flush", Description: "Force a final Web Vitals metric flush in the current browser tab so that LCP/CLS/INP values are captured before calling sin_browser_findings. Call this right before sin_browser_findings when the page is already loaded.", + InputSchema: obj(map[string]any{})}, + {Name: "sin_browser_diff", Description: "Compare two browser sessions — the stored baseline (saved by the last sin_browser_navigate with save_baseline=true) with the current session — and return a Diff: resolved, introduced, and persisted Findings plus an 'improved' flag. Use after applying a fix to verify it worked.", + InputSchema: obj(map[string]any{ + "window": str("correlation window in sequence steps for the current session (default 25)"), + })}, } } @@ -74,6 +103,16 @@ func extraTool(ctx context.Context, name string, args map[string]any) (string, e return toolHTTPGet(ctx, argStr(args, "url")) case "sin_test": return toolTest(ctx, argStr(args, "target")) + case "sin_browser_navigate": + return toolBrowserNavigate(ctx, argStr(args, "url"), argStr(args, "step"), argStr(args, "wait_sec"), argStr(args, "save_baseline")) + case "sin_browser_findings": + return toolBrowserFindings() + case "sin_browser_snapshot": + return toolBrowserSnapshot() + case "sin_browser_vitals_flush": + return toolBrowserVitalsFlush(ctx) + case "sin_browser_diff": + return toolBrowserDiff(argStr(args, "window")) default: return "", fmt.Errorf("unknown tool %q", name) } @@ -158,3 +197,230 @@ func fileExists(p string) bool { // silence linter for unused json var _ = json.Marshal + +// ---------------------------------------------------------------------------- +// Browser CDP session state — process-wide singleton for the agent loop. +// Only one recording session is active at a time; sin_browser_navigate +// replaces any previous session. +// ---------------------------------------------------------------------------- + +var ( + browserSession *activeBrowserSession +) + +type activeBrowserSession struct { + rec *cdp.Recorder + cancelCtx context.CancelFunc + jsonlPath string + // cdpCtx is kept alive so EvalVitalsNow can run after navigation completes. + cdpCtx context.Context + // baseline is the Report saved from a prior run for DiffReports comparison. + baseline *cdp.Report +} + +// toolBrowserNavigate drives headless Chrome to url, records the full CDP +// event stream (including Web Vitals), and returns a short status string. +func toolBrowserNavigate(ctx context.Context, url, step, waitSecStr, saveBaselineStr string) (string, error) { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return "", fmt.Errorf("sin_browser_navigate: only http(s) URLs are supported") + } + + // Parse optional wait duration; default 3 s. + waitSec := 3 + if waitSecStr != "" { + var n int + if _, err := fmt.Sscanf(waitSecStr, "%d", &n); err == nil && n >= 0 && n <= 120 { + waitSec = n + } + } + + // Tear down any previous session before starting a new one. + if browserSession != nil { + _ = browserSession.rec.Close() + browserSession.cancelCtx() + browserSession = nil + } + + // Write the JSONL to a temp file so it survives the function call. + jsonlPath := filepath.Join(os.TempDir(), fmt.Sprintf("sin-browser-%d.jsonl", time.Now().UnixNano())) + + cfg := cdp.DefaultConfig(jsonlPath) + rec, err := cdp.NewRecorder(cfg) + if err != nil { + return "", fmt.Errorf("sin_browser_navigate: failed to create recorder: %w", err) + } + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-gpu", true), + )...) + + cdpCtx, cancelCDP := chromedp.NewContext(allocCtx) + + rec.Attach(cdpCtx) + if err := rec.EnableDomains(cdpCtx); err != nil { + cancelCDP() + cancelAlloc() + _ = rec.Close() + return "", fmt.Errorf("sin_browser_navigate: enable domains: %w", err) + } + // Install PerformanceObserver script so LCP/CLS/INP/LongTask metrics are + // captured as "__SINCDP_VITAL__"-tagged events on every new document. + if err := rec.InstallVitals(cdpCtx); err != nil { + // Non-fatal: vitals injection failing should not abort the session. + _ = err + } + + if step != "" { + rec.SetStep(step) + } else { + rec.SetStep("navigate") + } + + navErr := chromedp.Run(cdpCtx, + chromedp.Navigate(url), + chromedp.Sleep(time.Duration(waitSec)*time.Second), + ) + + rec.SetStep("") + + // Store session for subsequent findings/snapshot/diff calls. + // combinedCancel releases both the CDP context and the allocator. + combinedCancel := func() { cancelCDP(); cancelAlloc() } + var prevBaseline *cdp.Report + if browserSession != nil { + prevBaseline = browserSession.baseline // carry over baseline across navigations + } + browserSession = &activeBrowserSession{ + rec: rec, + cancelCtx: combinedCancel, + jsonlPath: jsonlPath, + cdpCtx: cdpCtx, + baseline: prevBaseline, + } + // If save_baseline=true, immediately build and store the baseline Report. + if saveBaselineStr == "true" { + browserSession.baseline = cdp.BuildReport(rec.Events(), 25) + } + + events := rec.Events() + if navErr != nil { + return fmt.Sprintf("navigation error: %v (captured %d events, JSONL: %s)", navErr, len(events), jsonlPath), nil + } + + // Quick domain breakdown for immediate feedback. + counts := map[string]int{} + for _, e := range events { + counts[e.Domain]++ + } + b, _ := json.MarshalIndent(counts, "", " ") + return fmt.Sprintf("navigated to %s — %d events captured\nJSONL: %s\ndomains: %s", + url, len(events), jsonlPath, string(b)), nil +} + +// toolBrowserFindings runs the full deterministic analysis pipeline over the +// last recorded session and returns a structured Report as JSON. The Report +// includes Findings, root-cause Chains, FixSuggestions, and a Summary. +func toolBrowserFindings() (string, error) { + if browserSession == nil { + return "", fmt.Errorf("sin_browser_findings: no active browser session — call sin_browser_navigate first") + } + report := cdp.BuildReport(browserSession.rec.Events(), 25) + if report.Summary.Errors == 0 && report.Summary.Warnings == 0 { + b, _ := json.MarshalIndent(report, "", " ") + return fmt.Sprintf("no errors or warnings detected\n%s", string(b)), nil + } + b, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", fmt.Errorf("sin_browser_findings: marshal: %w", err) + } + return fmt.Sprintf("report: %d error(s), %d warning(s), %d suggestion(s), fatal=%v\n%s", + report.Summary.Errors, report.Summary.Warnings, + len(report.Suggestions), report.Summary.HasFatal, + string(b)), nil +} + +// toolBrowserSnapshot returns a compact JSON summary of the last session using +// BuildReport so the agent gets findings, chains, and suggestions in one call. +func toolBrowserSnapshot() (string, error) { + if browserSession == nil { + return "", fmt.Errorf("sin_browser_snapshot: no active browser session — call sin_browser_navigate first") + } + events := browserSession.rec.Events() + if len(events) == 0 { + return `{"total_events":0}`, nil + } + + report := cdp.BuildReport(events, 25) + + // Add per-method event counts as extra context not in the report itself. + counts := map[string]int{} + for _, e := range events { + counts[e.Domain+"."+e.Method]++ + } + + snap := map[string]interface{}{ + "total_events": len(events), + "first_wall": events[0].WallTime, + "last_wall": events[len(events)-1].WallTime, + "event_counts": counts, + "report": report, + "jsonl": browserSession.jsonlPath, + } + b, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return "", fmt.Errorf("sin_browser_snapshot: marshal: %w", err) + } + return string(b), nil +} + +// toolBrowserVitalsFlush forces a final Web Vitals metric flush in the live +// browser tab. Call before toolBrowserFindings when the page is already loaded +// so that final CLS/LCP values are emitted before BuildReport runs. +func toolBrowserVitalsFlush(ctx context.Context) (string, error) { + if browserSession == nil { + return "", fmt.Errorf("sin_browser_vitals_flush: no active browser session — call sin_browser_navigate first") + } + browserSession.rec.EvalVitalsNow(browserSession.cdpCtx) + return "vitals flushed — call sin_browser_findings to get updated metrics", nil +} + +// toolBrowserDiff compares the saved baseline Report with the current session's +// Report and returns a Diff showing resolved, introduced, and persisted findings. +func toolBrowserDiff(windowStr string) (string, error) { + if browserSession == nil { + return "", fmt.Errorf("sin_browser_diff: no active browser session — call sin_browser_navigate first") + } + if browserSession.baseline == nil { + return "", fmt.Errorf("sin_browser_diff: no baseline saved — navigate with save_baseline=true first") + } + + window := uint64(25) + if windowStr != "" { + var n uint64 + if _, err := fmt.Sscanf(windowStr, "%d", &n); err == nil && n > 0 && n <= 1000 { + window = n + } + } + + after := cdp.BuildReport(browserSession.rec.Events(), window) + diff := cdp.DiffReports(browserSession.baseline, after) + + b, err := json.MarshalIndent(diff, "", " ") + if err != nil { + return "", fmt.Errorf("sin_browser_diff: marshal: %w", err) + } + verdict := "no improvement (errors unchanged)" + if diff.Improved { + verdict = "improved" + } else if len(diff.Introduced) > 0 { + verdict = "regression introduced" + } + return fmt.Sprintf("diff: %s — resolved=%d introduced=%d persisted=%d before_errors=%d after_errors=%d\n%s", + verdict, + len(diff.Resolved), len(diff.Introduced), len(diff.Persisted), + diff.BeforeErr, diff.AfterErr, + string(b)), nil +} diff --git a/cmd/sin-code/internal/permission_defaults.go b/cmd/sin-code/internal/permission_defaults.go index 485cbc1d..b957400a 100644 --- a/cmd/sin-code/internal/permission_defaults.go +++ b/cmd/sin-code/internal/permission_defaults.go @@ -45,6 +45,16 @@ func DefaultPermissionRules() []permission.Rule { {Tool: "sin_bash", Policy: "ask"}, {Tool: "sin_sbom_generate", Policy: "allow"}, {Tool: "sin_security_scan", Policy: "allow"}, + // v3.18+: Browser CDP tools. + // sin_browser_navigate drives headless Chrome — it can load arbitrary + // URLs so it requires explicit user confirmation (ask). + // sin_browser_findings and sin_browser_snapshot only read the already- + // captured in-memory event slice; no network calls, no side effects. + {Tool: "sin_browser_navigate", Policy: "ask"}, + {Tool: "sin_browser_findings", Policy: "allow"}, + {Tool: "sin_browser_snapshot", Policy: "allow"}, + {Tool: "sin_browser_vitals_flush", Policy: "allow"}, + {Tool: "sin_browser_diff", Policy: "allow"}, // v3.16.0: autodev-cli bridge (Bridged-External + autodev-mcp stdio MCP). // Qualified name = server-name + "__" + tool-name (registry.go "autodev" + autodev-mcp tools). diff --git a/go.mod b/go.mod index cf432e4f..0a92e48e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,8 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/chromedp/cdproto v0.0.0-20250403032234-65de8f5d025b + github.com/chromedp/chromedp v0.13.6 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/rogpeppe/go-internal v1.15.0 github.com/spf13/cobra v1.10.2 @@ -37,6 +39,12 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa // indirect diff --git a/pkg/browser/cdp/adapter.go b/pkg/browser/cdp/adapter.go new file mode 100644 index 00000000..15ae224d --- /dev/null +++ b/pkg/browser/cdp/adapter.go @@ -0,0 +1,162 @@ +package cdp + +import ( + "context" + "fmt" + "sort" +) + +// FixExecutor is implemented by the SIN-Code agent. The adapter calls Apply for +// a suggestion and expects the agent to perform the concrete remediation +// (LLM code edit, config change, endpoint check, ...). Revert undoes the last +// applied fix when a regression is detected. +type FixExecutor interface { + // Apply attempts a fix for one suggestion. Return ok=false if this executor + // does not handle the suggestion's FixClass (the loop then tries the next). + Apply(ctx context.Context, s *FixSuggest) (ok bool, err error) + // Revert rolls back the most recent successful Apply. + Revert(ctx context.Context, s *FixSuggest) error +} + +// Rerunner re-records the page after a fix and returns a fresh report, so the +// adapter can diff before/after. The agent owns browser/navigation lifecycle. +type Rerunner interface { + Rerun(ctx context.Context) (*Report, error) +} + +// LoopConfig tunes the auto-fix loop. +type LoopConfig struct { + MaxAttempts int // hard cap on fix attempts (default 10) + MinConfidence string // skip suggestions below this ("low"|"medium"|"high") + OnlyFatal bool // only act when the report HasFatal +} + +func DefaultLoopConfig() LoopConfig { + return LoopConfig{MaxAttempts: 10, MinConfidence: "low", OnlyFatal: true} +} + +// LoopResult is the audit trail of one auto-fix session. +type LoopResult struct { + Attempts []*Attempt `json:"attempts"` + Resolved int `json:"resolved"` + Remaining int `json:"remaining"` + Converged bool `json:"converged"` // no fatal findings remain +} + +type Attempt struct { + Signature string `json:"signature"` + FixClass string `json:"fix_class"` + Applied bool `json:"applied"` + Improved bool `json:"improved"` + Reverted bool `json:"reverted"` + Error string `json:"error,omitempty"` +} + +// RunAutoFix drives the closed loop: pick the highest-priority suggestion, +// apply it, re-run, diff, accept or revert, repeat until convergence or cap. +func RunAutoFix(ctx context.Context, initial *Report, exec FixExecutor, rerun Rerunner, cfg LoopConfig) (*LoopResult, error) { + if cfg.MaxAttempts <= 0 { + cfg.MaxAttempts = 10 + } + res := &LoopResult{} + current := initial + + for len(res.Attempts) < cfg.MaxAttempts { + if cfg.OnlyFatal && !current.Summary.HasFatal { + break // nothing critical left to fix + } + + s := pickSuggestion(current, cfg.MinConfidence) + if s == nil { + break // no actionable suggestion remains + } + + att := &Attempt{Signature: s.Signature, FixClass: s.FixClass} + + ok, err := exec.Apply(ctx, s) + if err != nil { + att.Error = err.Error() + res.Attempts = append(res.Attempts, att) + break + } + if !ok { + // No executor handled this class; record and stop to avoid a busy loop. + att.Error = "no executor for fix_class " + s.FixClass + res.Attempts = append(res.Attempts, att) + break + } + att.Applied = true + + after, err := rerun.Rerun(ctx) + if err != nil { + att.Error = fmt.Sprintf("rerun: %v", err) + res.Attempts = append(res.Attempts, att) + break + } + + diff := DiffReports(current, after) + att.Improved = diff.Improved + + if diff.Improved || len(diff.Resolved) > 0 && !introducedFatal(diff) { + // Accept the fix and continue from the new state. + res.Resolved += len(diff.Resolved) + current = after + } else { + // Regression or no progress: roll back and move on. + if rerr := exec.Revert(ctx, s); rerr != nil { + att.Error = fmt.Sprintf("revert: %v", rerr) + } + att.Reverted = true + } + res.Attempts = append(res.Attempts, att) + } + + res.Remaining = current.Summary.Errors + res.Converged = !current.Summary.HasFatal + return res, nil +} + +// pickSuggestion selects the next suggestion to try: errors before warnings, +// higher confidence first, filtered by the minimum confidence threshold. +func pickSuggestion(r *Report, minConf string) *FixSuggest { + var candidates []*FixSuggest + min := confRank(minConf) + for _, s := range r.Suggestions { + if confRank(s.Confidence) >= min { + candidates = append(candidates, s) + } + } + if len(candidates) == 0 { + return nil + } + sort.SliceStable(candidates, func(i, j int) bool { + if severityRank(candidates[i].Severity) != severityRank(candidates[j].Severity) { + return severityRank(candidates[i].Severity) < severityRank(candidates[j].Severity) + } + return confRank(candidates[i].Confidence) > confRank(candidates[j].Confidence) + }) + return candidates[0] +} + +func introducedFatal(d *Diff) bool { + for _, f := range d.Introduced { + if f.Severity == SevError { + return true + } + } + return false +} + +// confRank maps confidence strings to a comparable order (high > medium > low). +func confRank(c string) int { + switch c { + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} diff --git a/pkg/browser/cdp/adapter_test.go b/pkg/browser/cdp/adapter_test.go new file mode 100644 index 00000000..49169c99 --- /dev/null +++ b/pkg/browser/cdp/adapter_test.go @@ -0,0 +1,76 @@ +package cdp + +import ( + "context" + "testing" +) + +// fakeExec resolves a fix on the first apply; fakeRerun then returns a clean report. +type fakeExec struct { + applied int + reverts int +} + +func (f *fakeExec) Apply(_ context.Context, _ *FixSuggest) (bool, error) { + f.applied++ + return true, nil +} + +func (f *fakeExec) Revert(_ context.Context, _ *FixSuggest) error { + f.reverts++ + return nil +} + +type fakeRerun struct{ after *Report } + +func (f *fakeRerun) Rerun(_ context.Context) (*Report, error) { return f.after, nil } + +func TestAutoFixConverges(t *testing.T) { + initial := &Report{ + Findings: []*Finding{{Signature: "exc:boom", Severity: SevError, Count: 1}}, + Suggestions: []*FixSuggest{{Signature: "exc:boom", Severity: SevError, FixClass: "js.reference", Confidence: "high"}}, + Summary: ReportSummary{Errors: 1, HasFatal: true}, + } + clean := &Report{Findings: []*Finding{}, Summary: ReportSummary{Errors: 0, HasFatal: false}} + + exec := &fakeExec{} + res, err := RunAutoFix(context.Background(), initial, exec, &fakeRerun{after: clean}, DefaultLoopConfig()) + if err != nil { + t.Fatal(err) + } + if !res.Converged { + t.Error("expected convergence after the only error was resolved") + } + if exec.applied != 1 { + t.Errorf("expected 1 apply, got %d", exec.applied) + } + if res.Resolved != 1 { + t.Errorf("expected 1 resolved, got %d", res.Resolved) + } +} + +func TestAutoFixRevertsOnRegression(t *testing.T) { + initial := &Report{ + Findings: []*Finding{{Signature: "exc:a", Severity: SevError, Count: 1}}, + Suggestions: []*FixSuggest{{Signature: "exc:a", Severity: SevError, FixClass: "js.reference", Confidence: "high"}}, + Summary: ReportSummary{Errors: 1, HasFatal: true}, + } + // Re-run introduces a NEW error and keeps the old one -> regression. + worse := &Report{ + Findings: []*Finding{ + {Signature: "exc:a", Severity: SevError, Count: 1}, + {Signature: "exc:b", Severity: SevError, Count: 1}, + }, + Summary: ReportSummary{Errors: 2, HasFatal: true}, + } + exec := &fakeExec{} + cfg := DefaultLoopConfig() + cfg.MaxAttempts = 1 + res, _ := RunAutoFix(context.Background(), initial, exec, &fakeRerun{after: worse}, cfg) + if exec.reverts != 1 { + t.Errorf("expected 1 revert on regression, got %d", exec.reverts) + } + if res.Attempts[0].Improved { + t.Error("attempt should not be marked improved on regression") + } +} diff --git a/pkg/browser/cdp/analyze_test.go b/pkg/browser/cdp/analyze_test.go new file mode 100644 index 00000000..18c7d23e --- /dev/null +++ b/pkg/browser/cdp/analyze_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +// Purpose: pure unit tests for the deterministic analysis layer. +// +// These tests do NOT require Chrome or Chromium. They run on every CI build +// and exercise grouping, correlation, diff, and Vitals threshold logic using +// synthetic events constructed from JSON literals. +package cdp + +import ( + "encoding/json" + "testing" +) + +// mkEvent is a convenience constructor for synthetic test events. +func mkEvent(seq uint64, domain, method string, params interface{}) *Event { + b, _ := json.Marshal(params) + return &Event{Seq: seq, Domain: domain, Method: method, Params: b} +} + +// --------------------------------------------------------------------------- +// Analyze +// --------------------------------------------------------------------------- + +func TestAnalyzeGroupsRepeatedConsoleErrors(t *testing.T) { + var events []*Event + for i := uint64(1); i <= 5; i++ { + events = append(events, mkEvent(i, "Runtime", "consoleAPICalled", map[string]interface{}{ + "type": "error", + "args": []map[string]interface{}{{"value": json.RawMessage(`"same error"`)}}, + })) + } + findings := Analyze(events) + if len(findings) != 1 { + t.Fatalf("expected 1 grouped finding, got %d", len(findings)) + } + if findings[0].Count != 5 { + t.Errorf("expected count 5, got %d", findings[0].Count) + } +} + +func TestAnalyzeConsoleWarningSignature(t *testing.T) { + events := []*Event{ + mkEvent(1, "Runtime", "consoleAPICalled", map[string]interface{}{ + "type": "warning", + "args": []map[string]interface{}{{"value": json.RawMessage(`"heads up"`)}}, + }), + } + findings := Analyze(events) + if len(findings) != 1 { + t.Fatalf("expected 1 finding, got %d", len(findings)) + } + if findings[0].Severity != SevWarn { + t.Errorf("expected warn severity, got %q", findings[0].Severity) + } + // Warnings must use the "console:warning:" prefix so they don't collide + // with same-text errors. + if len(findings[0].Signature) < len("console:warning:") || + findings[0].Signature[:len("console:warning:")] != "console:warning:" { + t.Errorf("unexpected signature for console warning: %q", findings[0].Signature) + } +} + +// --------------------------------------------------------------------------- +// Correlate +// --------------------------------------------------------------------------- + +func TestCorrelateLinksFailureToException(t *testing.T) { + events := []*Event{ + mkEvent(1, "Network", "loadingFailed", map[string]interface{}{ + "type": "Script", "errorText": "net::ERR_CONNECTION_REFUSED", + }), + mkEvent(2, "Runtime", "exceptionThrown", map[string]interface{}{ + "exceptionDetails": map[string]interface{}{"text": "ReferenceError: x is not defined"}, + }), + } + chains := Correlate(events, 25) + if len(chains) != 1 { + t.Fatalf("expected 1 chain, got %d", len(chains)) + } + if len(chains[0].Symptoms) != 1 { + t.Errorf("expected 1 symptom linked to the failure, got %d", len(chains[0].Symptoms)) + } +} + +func TestCorrelateIgnoresOutOfWindowSymptoms(t *testing.T) { + events := []*Event{ + mkEvent(1, "Network", "loadingFailed", map[string]interface{}{ + "type": "Script", "errorText": "net::ERR_CONNECTION_REFUSED", + }), + // seq 100 is well outside window=5 + mkEvent(100, "Runtime", "exceptionThrown", map[string]interface{}{ + "exceptionDetails": map[string]interface{}{"text": "ReferenceError: late error"}, + }), + } + chains := Correlate(events, 5) + if len(chains) != 0 { + t.Errorf("expected 0 chains (symptom outside window), got %d", len(chains)) + } +} + +// --------------------------------------------------------------------------- +// DiffReports +// --------------------------------------------------------------------------- + +func TestDiffDetectsImprovement(t *testing.T) { + before := &Report{ + Findings: []*Finding{{Signature: "exc:boom", Severity: SevError, Count: 1}}, + Summary: ReportSummary{Errors: 1}, + } + after := &Report{ + Findings: []*Finding{}, + Summary: ReportSummary{Errors: 0}, + } + d := DiffReports(before, after) + if !d.Improved { + t.Error("expected Improved=true when the only error is resolved") + } + if len(d.Resolved) != 1 { + t.Errorf("expected 1 resolved finding, got %d", len(d.Resolved)) + } + if len(d.Introduced) != 0 { + t.Errorf("expected 0 introduced findings, got %d", len(d.Introduced)) + } +} + +func TestDiffDetectsRegression(t *testing.T) { + before := &Report{ + Findings: []*Finding{{Signature: "exc:a", Severity: SevError, Count: 1}}, + Summary: ReportSummary{Errors: 1}, + } + after := &Report{ + Findings: []*Finding{{Signature: "exc:b", Severity: SevError, Count: 1}}, + Summary: ReportSummary{Errors: 1}, + } + d := DiffReports(before, after) + if d.Improved { + t.Error("expected Improved=false: same error count and a new error introduced") + } + if len(d.Introduced) != 1 { + t.Errorf("expected 1 introduced finding, got %d", len(d.Introduced)) + } +} + +func TestDiffPersisted(t *testing.T) { + f := &Finding{Signature: "http:404", Severity: SevWarn, Count: 2} + before := &Report{Findings: []*Finding{f}, Summary: ReportSummary{Warnings: 2}} + after := &Report{ + Findings: []*Finding{{Signature: "http:404", Severity: SevWarn, Count: 2}}, + Summary: ReportSummary{Warnings: 2}, + } + d := DiffReports(before, after) + if len(d.Persisted) != 1 { + t.Errorf("expected 1 persisted finding, got %d", len(d.Persisted)) + } + if d.Improved { + t.Error("should not be improved when errors unchanged") + } +} + +// --------------------------------------------------------------------------- +// vitalSeverity thresholds +// --------------------------------------------------------------------------- + +func TestVitalSeverityThresholds(t *testing.T) { + cases := []struct { + name string + value float64 + want Severity + }{ + // LCP + {"LCP", 1000, ""}, + {"LCP", 3000, SevWarn}, + {"LCP", 5000, SevError}, + // CLS + {"CLS", 0.05, ""}, + {"CLS", 0.2, SevWarn}, + {"CLS", 0.4, SevError}, + // INP + {"INP", 100, ""}, + {"INP", 300, SevWarn}, + {"INP", 700, SevError}, + // LongTask + {"LongTask", 30, ""}, + {"LongTask", 100, SevInfo}, + {"LongTask", 250, SevWarn}, + } + for _, c := range cases { + got, _ := vitalSeverity(c.name, c.value) + if got != c.want { + t.Errorf("%s=%.2f: want %q, got %q", c.name, c.value, c.want, got) + } + } +} diff --git a/pkg/browser/cdp/correlate.go b/pkg/browser/cdp/correlate.go new file mode 100644 index 00000000..03872c24 --- /dev/null +++ b/pkg/browser/cdp/correlate.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// Purpose: root-cause correlation — links failing network requests to the +// downstream exceptions and console errors they caused. +// +// Correlate is a companion to Analyze: where Analyze produces a flat, +// deduplicated list of problems, Correlate produces causal chains that answer +// "why did this happen?" for the agent loop without requiring an LLM to read +// raw JSONL. +package cdp + +import ( + "encoding/json" + "fmt" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/runtime" +) + +// Chain links a likely root-cause event (a failed or errored network request) +// to downstream symptoms (exceptions and console errors) that occurred within +// window sequence steps after it. This turns "50 red lines" into one +// actionable story: failing request → exception → console errors. +type Chain struct { + RootCategory string `json:"root_category"` // always "network" + RootTitle string `json:"root_title"` + RootSeq uint64 `json:"root_seq"` + RootURL string `json:"root_url,omitempty"` + Symptoms []Symptom `json:"symptoms"` +} + +// Symptom is a downstream event attributed to a Chain's root cause. +type Symptom struct { + Category string `json:"category"` // "exception" | "console" + Seq uint64 `json:"seq"` + Message string `json:"message"` +} + +// Correlate scans the event stream and builds root-cause chains. +// window is how many sequence steps after a failure we still attribute +// symptoms to it — lower values are stricter, higher values catch more +// indirect effects on dense pages. A value of 25 works well for most pages. +func Correlate(events []*Event, window uint64) []*Chain { + type sym struct { + seq uint64 + category string + message string + } + var symptoms []sym + for _, e := range events { + switch { + case e.Domain == "Runtime" && e.Method == "exceptionThrown": + var p runtime.EventExceptionThrown + if json.Unmarshal(e.Params, &p) == nil && p.ExceptionDetails != nil { + symptoms = append(symptoms, sym{e.Seq, "exception", firstLine(p.ExceptionDetails.Text)}) + } + case e.Domain == "Runtime" && e.Method == "consoleAPICalled": + var p runtime.EventConsoleAPICalled + if json.Unmarshal(e.Params, &p) == nil && + (p.Type == runtime.APITypeError || p.Type == runtime.APITypeAssert) { + symptoms = append(symptoms, sym{e.Seq, "console", consoleText(&p)}) + } + } + } + + attach := func(rootSeq uint64) []Symptom { + var out []Symptom + for _, s := range symptoms { + if s.seq > rootSeq && s.seq <= rootSeq+window { + out = append(out, Symptom{Category: s.category, Seq: s.seq, Message: s.message}) + } + } + return out + } + + var chains []*Chain + for _, e := range events { + var rootTitle, rootURL string + isRoot := false + + switch { + case e.Domain == "Network" && e.Method == "loadingFailed": + var p network.EventLoadingFailed + if json.Unmarshal(e.Params, &p) == nil { + isRoot = true + rootTitle = "Request failed: " + p.ErrorText + if p.BlockedReason != "" { + rootTitle = "Request blocked: " + string(p.BlockedReason) + } + } + case e.Domain == "Network" && e.Method == "responseReceived": + var p network.EventResponseReceived + if json.Unmarshal(e.Params, &p) == nil && p.Response != nil && p.Response.Status >= 400 { + isRoot = true + rootTitle = fmt.Sprintf("HTTP %d response", p.Response.Status) + rootURL = p.Response.URL + } + } + + if !isRoot { + continue + } + if syms := attach(e.Seq); len(syms) > 0 { + chains = append(chains, &Chain{ + RootCategory: "network", + RootTitle: rootTitle, + RootSeq: e.Seq, + RootURL: rootURL, + Symptoms: syms, + }) + } + } + return chains +} diff --git a/pkg/browser/cdp/event.go b/pkg/browser/cdp/event.go new file mode 100644 index 00000000..8d9e2501 --- /dev/null +++ b/pkg/browser/cdp/event.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// Purpose: Event envelope and Artifact types for the CDP ground-truth log. +// Every captured CDP event is serialised as one JSON line (JSONL) so the +// on-disk record is streamable and self-describing. The Artifact type +// references out-of-band blobs (response bodies, screenshots, DOM dumps) +// that are written as separate files alongside the JSONL log. +// This package is the low-level capture layer; diagnostics and the +// deterministic findings engine live in the same package. +package cdp + +import "encoding/json" + +// Event is one line in the JSONL ground-truth log. +// The JSONL file is the single source of truth; every report or finding +// is a read-only view derived from it. +type Event struct { + // Seq is a global monotonically increasing sequence number assigned + // at emit time under a mutex, so JSONL order == causal order. + Seq uint64 `json:"seq"` + + // WallTime is an RFC3339Nano wall-clock timestamp — useful for + // forensic cross-referencing with external logs. + WallTime string `json:"wall_time"` + + // MonoNanos is nanoseconds elapsed since the Recorder was started. + // Use this for all duration / latency calculations because wall-clock + // can jump on NTP adjustments. + MonoNanos int64 `json:"mono_nanos"` + + // SessionID is the CDP session identifier. Non-empty only for + // events from auto-attached child targets (OOPIFs, workers). + SessionID string `json:"session_id,omitempty"` + + // Domain is the CDP protocol domain, e.g. "Network", "Audits". + Domain string `json:"domain"` + + // Method is the CDP event name within the domain, e.g. "responseReceived". + Method string `json:"method"` + + // StepID correlates this event to an agent action / navigation step. + // Set by calling Recorder.SetStep before the action. + StepID string `json:"step_id,omitempty"` + + // Params is the raw CDP event payload; kept as json.RawMessage so + // callers can unmarshal into the concrete cdproto type when needed + // without paying for a double-decode on every event. + Params json.RawMessage `json:"params"` +} + +// Artifact references an out-of-band blob written alongside the JSONL log. +// Examples: response bodies, screenshots, DOM snapshots, accessibility trees. +type Artifact struct { + // Seq links the artifact to the Event that triggered its capture. + Seq uint64 `json:"seq"` + + // Kind identifies the artifact type: "response_body", "screenshot", + // "dom_snapshot", "a11y_tree", "computed_styles", "box_model". + Kind string `json:"kind"` + + // RequestID is set for response-body artifacts. + RequestID string `json:"request_id,omitempty"` + + // MimeType is the Content-Type of the captured body (if known). + MimeType string `json:"mime_type,omitempty"` + + // Path is the file path of the artifact relative to the output directory. + Path string `json:"path"` + + // Bytes is the size of the artifact after any truncation. + Bytes int `json:"bytes"` + + // Truncated is true when the body was capped by MaxBodyBytes. + Truncated bool `json:"truncated,omitempty"` +} diff --git a/pkg/browser/cdp/findings.go b/pkg/browser/cdp/findings.go new file mode 100644 index 00000000..03509b3d --- /dev/null +++ b/pkg/browser/cdp/findings.go @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: MIT +// Purpose: deterministic Findings engine for the CDP ground-truth log. +// +// The gap identified in the audit (gap-report 2024-06-16) was that the existing +// Python diagnostics layer could only filter/query the event stream — the +// classification, grouping, and correlation work was left entirely to the LLM. +// This package closes that gap with a single-pass analyser that produces +// structured, deduplicated Findings from the in-memory event slice, making +// "problems" machine-readable and directly actionable by the agent loop. +// +// Design principles: +// - Deterministic: given the same event slice, Analyze always returns the +// same ordered Finding slice. No randomness, no LLM calls. +// - Grouped by signature: identical errors (e.g. the same exception thrown +// 50 times) collapse into a single Finding with Count > 1. +// - Sorted by severity then frequency: errors first, then warnings, then +// info; within each tier, higher-count findings come first. +// - Causally minimal: each Finding references its first and last seq numbers +// so downstream consumers can retrieve the full event context from the JSONL. +package cdp + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/runtime" +) + +// Severity classifies how urgent a Finding is. +type Severity string + +const ( + SevError Severity = "error" + SevWarn Severity = "warn" + SevInfo Severity = "info" +) + +// Finding is a grouped, classified problem derived from the CDP event stream. +// Multiple identical events are collapsed into a single Finding (Count tracks +// how many times the issue was observed). +type Finding struct { + // Category is the CDP domain family: "console", "exception", "network", + // "audit", "security", "vital". + Category string `json:"category"` + + // Severity is the worst-case severity of this class of event. + Severity Severity `json:"severity"` + + // Title is a short human-readable description of the problem. + Title string `json:"title"` + + // Signature is the stable key used for grouping and deduplication. + // It is not intended for display; use Title for that. + Signature string `json:"signature"` + + // Count is the number of events that matched this Finding's signature. + Count int `json:"count"` + + // FirstSeq and LastSeq are the global sequence numbers of the first and + // last matching events. Use them to retrieve the raw event from the JSONL. + FirstSeq uint64 `json:"first_seq"` + LastSeq uint64 `json:"last_seq"` + + // Sample is a short representative excerpt from the first matching event + // to aid human reading without requiring a JSONL lookup. + Sample string `json:"sample,omitempty"` +} + +// Analyze runs a deterministic single pass over events and returns grouped +// findings sorted by severity (error → warn → info) then count (descending). +// +// Pass rec.Events() directly after a recording session: +// +// findings := cdp.Analyze(rec.Events()) +func Analyze(events []*Event) []*Finding { + groups := map[string]*Finding{} + + add := func(cat string, sev Severity, title, sig, sample string, seq uint64) { + f, ok := groups[sig] + if !ok { + f = &Finding{ + Category: cat, + Severity: sev, + Title: title, + Signature: sig, + FirstSeq: seq, + Sample: sample, + } + groups[sig] = f + } + f.Count++ + f.LastSeq = seq + } + + for _, e := range events { + switch { + + // ---- Web Vitals injected by vitals.go (must precede console case) -- + // vitals.go installs a PerformanceObserver script that forwards each + // metric via console.debug("__SINCDP_VITAL__", ...). We intercept them + // here before the general consoleAPICalled handler so they are not + // double-counted as console errors. + case e.Domain == "Runtime" && e.Method == "consoleAPICalled" && isVital(e.Params): + name, value, ok := parseVital(e.Params) + if !ok { + continue + } + if sev, title := vitalSeverity(name, value); sev != "" { + sig := "vital:" + name + sample := fmt.Sprintf("%s = %.0fms", name, value) + if name == "CLS" { + sample = fmt.Sprintf("%s = %.3f", name, value) + } + add("vital", sev, title, sig, sample, e.Seq) + } + + // ---- Console errors / warnings ------------------------------------ + case e.Domain == "Runtime" && e.Method == "consoleAPICalled": + var p runtime.EventConsoleAPICalled + if json.Unmarshal(e.Params, &p) != nil { + continue + } + switch p.Type { + case runtime.APITypeError, runtime.APITypeAssert: + msg := consoleText(&p) + add("console", SevError, "Console error", "console:"+msg, msg, e.Seq) + case runtime.APITypeWarning: + msg := consoleText(&p) + add("console", SevWarn, "Console warning", "console:warning:"+msg, msg, e.Seq) + } + + // ---- Uncaught JS exceptions ---------------------------------------- + case e.Domain == "Runtime" && e.Method == "exceptionThrown": + var p runtime.EventExceptionThrown + if json.Unmarshal(e.Params, &p) != nil { + continue + } + msg := "uncaught exception" + if p.ExceptionDetails != nil { + msg = p.ExceptionDetails.Text + if p.ExceptionDetails.Exception != nil && + p.ExceptionDetails.Exception.Description != "" { + msg = p.ExceptionDetails.Exception.Description + } + } + add("exception", SevError, "Uncaught exception", "exc:"+firstLine(msg), msg, e.Seq) + + // ---- Network failures (request blocked or connection error) -------- + case e.Domain == "Network" && e.Method == "loadingFailed": + var p network.EventLoadingFailed + if json.Unmarshal(e.Params, &p) != nil { + continue + } + sev := SevError + var sig, title string + if p.BlockedReason != "" { + sig = "netblock:" + string(p.BlockedReason) + title = fmt.Sprintf("Request blocked: %s", p.BlockedReason) + } else { + sig = fmt.Sprintf("netfail:%s:%s", p.Type, p.ErrorText) + title = fmt.Sprintf("Request failed (%s)", p.ErrorText) + } + add("network", sev, title, sig, p.ErrorText, e.Seq) + + // ---- HTTP error responses (4xx warn, 5xx error) -------------------- + case e.Domain == "Network" && e.Method == "responseReceived": + var p network.EventResponseReceived + if json.Unmarshal(e.Params, &p) != nil || p.Response == nil { + continue + } + if p.Response.Status >= 400 { + sev := SevWarn + if p.Response.Status >= 500 { + sev = SevError + } + sig := fmt.Sprintf("http:%d", p.Response.Status) + add("network", sev, + fmt.Sprintf("HTTP %d response", p.Response.Status), + sig, p.Response.URL, e.Seq) + } + + // ---- DevTools Audits Issues (CORS, CSP, mixed content, ...) -------- + // Chrome pre-classifies these; we surface the issue code as a Finding + // so the agent can act on it without parsing the full audit payload. + case e.Domain == "Audits" && e.Method == "issueAdded": + var raw struct { + Issue struct { + Code string `json:"code"` + } `json:"issue"` + } + if json.Unmarshal(e.Params, &raw) != nil { + continue + } + code := raw.Issue.Code + if code == "" { + code = "UnknownIssue" + } + add("audit", SevWarn, "DevTools issue: "+code, "audit:"+code, code, e.Seq) + + // ---- Security state degradation ------------------------------------ + case e.Domain == "Security" && e.Method == "securityStateChanged": + var raw struct { + SecurityState string `json:"securityState"` + SchemeIsCryptographic bool `json:"schemeIsCryptographic"` + } + if json.Unmarshal(e.Params, &raw) != nil { + continue + } + if raw.SecurityState == "insecure" || raw.SecurityState == "neutral" { + add("security", SevWarn, + "Insecure security state: "+raw.SecurityState, + "security:"+raw.SecurityState, + raw.SecurityState, e.Seq) + } + } + } + + out := make([]*Finding, 0, len(groups)) + for _, f := range groups { + out = append(out, f) + } + sort.Slice(out, func(i, j int) bool { + ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity) + if ri != rj { + return ri < rj + } + return out[i].Count > out[j].Count + }) + return out +} + +// severityRank maps Severity to a sort key (lower = more urgent). +func severityRank(s Severity) int { + switch s { + case SevError: + return 0 + case SevWarn: + return 1 + default: + return 2 + } +} + +// consoleText extracts the first meaningful string from a console event's args. +func consoleText(p *runtime.EventConsoleAPICalled) string { + for _, a := range p.Args { + if a == nil { + continue + } + if a.Value != nil { + return firstLine(string(a.Value)) + } + if a.Description != "" { + return firstLine(a.Description) + } + } + return string(p.Type) +} + +// firstLine returns up to the first newline of s, capped at 200 characters. +func firstLine(s string) string { + for i, c := range s { + if c == '\n' { + if i > 200 { + return s[:200] + } + return s[:i] + } + } + if len(s) > 200 { + return s[:200] + } + return s +} + +// --------------------------------------------------------------------------- +// Web Vitals helpers (used by Analyze above and by vitals.go) +// --------------------------------------------------------------------------- + +// isVital is a cheap pre-check before full unmarshal: returns true when the +// consoleAPICalled payload carries one of our injected __SINCDP_VITAL__ tags. +func isVital(params json.RawMessage) bool { + return bytes.Contains(params, []byte("__SINCDP_VITAL__")) +} + +// parseVital extracts the metric name and float value from the tagged console +// call emitted by vitals.go. The page logs: +// +// console.debug("__SINCDP_VITAL__", JSON.stringify({name, value, ...})) +func parseVital(params json.RawMessage) (string, float64, bool) { + var p runtime.EventConsoleAPICalled + if json.Unmarshal(params, &p) != nil || len(p.Args) < 2 { + return "", 0, false + } + // Args[1] is the stringified JSON payload. + var rawStr string + if json.Unmarshal(p.Args[1].Value, &rawStr) != nil { + return "", 0, false + } + var v struct { + Name string `json:"name"` + Value float64 `json:"value"` + } + if json.Unmarshal([]byte(rawStr), &v) != nil || v.Name == "" { + return "", 0, false + } + return v.Name, v.Value, true +} + +// vitalSeverity applies Core Web Vitals "poor" / "needs-improvement" thresholds. +// Returns an empty severity string when the metric is in the "good" range. +func vitalSeverity(name string, value float64) (Severity, string) { + switch name { + case "LCP": // ms — good <2500, poor >4000 + if value > 4000 { + return SevError, "Poor LCP (largest contentful paint)" + } + if value > 2500 { + return SevWarn, "LCP needs improvement" + } + case "CLS": // unitless — good <0.1, poor >0.25 + if value > 0.25 { + return SevError, "Poor CLS (cumulative layout shift)" + } + if value > 0.1 { + return SevWarn, "CLS needs improvement" + } + case "INP": // ms — good <200, poor >500 + if value > 500 { + return SevError, "Poor INP (interaction to next paint)" + } + if value > 200 { + return SevWarn, "INP needs improvement" + } + case "LongTask": // ms — anything >50ms blocks the main thread + if value > 200 { + return SevWarn, "Long task blocking the main thread" + } + if value > 50 { + return SevInfo, "Long task on the main thread" + } + } + return "", "" +} diff --git a/pkg/browser/cdp/fixtures.go b/pkg/browser/cdp/fixtures.go new file mode 100644 index 00000000..3e76bca1 --- /dev/null +++ b/pkg/browser/cdp/fixtures.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Purpose: static test assets used by the fault-injection test server. +package cdp + +import "encoding/base64" + +// onePxPNG is a 1x1 transparent PNG used by the slow-LCP fixture in +// testserver.go. It is served with an artificial delay to guarantee that +// LCP is reported above the "needs improvement" threshold. +var onePxPNG = mustDecode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==") + +func mustDecode(s string) []byte { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + panic(err) + } + return b +} diff --git a/pkg/browser/cdp/harness_test.go b/pkg/browser/cdp/harness_test.go new file mode 100644 index 00000000..7de43fe1 --- /dev/null +++ b/pkg/browser/cdp/harness_test.go @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +// Purpose: browser integration test harness for pkg/browser/cdp. +// +// These tests require Chrome or Chromium. They skip automatically when no +// browser is found so CI stays green without a browser while still running +// the deterministic unit tests in analyze_test.go. +// +// Run browser tests explicitly: +// +// go test ./pkg/browser/cdp/... +// +// Or with a non-standard Chrome path: +// +// CHROME_PATH=/opt/chrome/chrome go test ./pkg/browser/cdp/... +package cdp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" +) + +// runScenario launches headless Chrome, navigates to url, waits settle, and +// returns the BuildReport result. Each test runs in its own temp dir. +func runScenario(t *testing.T, url string, settle time.Duration) *Report { + t.Helper() + + dir := t.TempDir() + cfg := DefaultConfig(filepath.Join(dir, "evidence.jsonl")) + cfg.MetricsEvery = 0 // determinism: no background polling in tests + rec, err := NewRecorder(cfg) + if err != nil { + t.Fatalf("recorder: %v", err) + } + defer rec.Close() + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + )...) + defer cancelAlloc() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + rec.Attach(ctx) + if err := rec.EnableDomains(ctx); err != nil { + t.Fatalf("enable: %v", err) + } + if err := rec.InstallVitals(ctx); err != nil { + t.Fatalf("vitals: %v", err) + } + + rec.SetStep("navigate") + if err := chromedp.Run(ctx, + chromedp.Navigate(url), + chromedp.Sleep(settle), + ); err != nil { + t.Logf("nav (non-fatal): %v", err) + } + rec.EvalVitalsNow(ctx) + + return BuildReport(rec.Events(), 25) +} + +// hasFinding reports whether the report contains a Finding whose Signature +// starts with sigPrefix. +func hasFinding(r *Report, sigPrefix string) bool { + for _, f := range r.Findings { + if strings.HasPrefix(f.Signature, sigPrefix) { + return true + } + } + return false +} + +// hasFixClass reports whether the report contains a Suggestion with the given +// FixClass value. +func hasFixClass(r *Report, fixClass string) bool { + for _, s := range r.Suggestions { + if s.FixClass == fixClass { + return true + } + } + return false +} + +// skipIfNoChrome skips the test when no Chrome/Chromium binary is found. +func skipIfNoChrome(t *testing.T) { + t.Helper() + for _, p := range []string{ + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + } { + if _, err := os.Stat(p); err == nil { + return + } + } + if os.Getenv("CHROME_PATH") == "" { + t.Skip("no Chrome/Chromium available; set CHROME_PATH to run browser harness") + } +} + +func TestConsoleAndException(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + r := runScenario(t, srv.URL+"/console-error", time.Second) + if !hasFinding(r, "console:") { + t.Error("expected a console error finding") + } + if !hasFinding(r, "exc:") { + t.Error("expected an uncaught exception finding") + } + if !r.Summary.HasFatal { + t.Error("expected HasFatal=true for a page with errors") + } +} + +func TestHTTP500(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + r := runScenario(t, srv.URL+"/http-500", time.Second) + if !hasFinding(r, "http:500") { + t.Error("expected an http:500 finding") + } + if !hasFixClass(r, "network.http_error") { + t.Error("expected a network.http_error suggestion") + } +} + +func TestNetworkFailure(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + r := runScenario(t, srv.URL+"/net-fail", 2*time.Second) + if !hasFinding(r, "netfail:") && !hasFinding(r, "netblock:") { + t.Error("expected a network failure finding") + } +} + +func TestCSPViolation(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + // Settle long enough for the Audits issueAdded event to arrive. + r := runScenario(t, srv.URL+"/csp", 1500*time.Millisecond) + if !hasFinding(r, "audit:") { + t.Error("expected an Audits issue finding for CSP violation") + } +} + +func TestSlowLCP(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + // Settle long enough for the delayed hero image to become the LCP element + // and for the PerformanceObserver to fire. + r := runScenario(t, srv.URL+"/slow-lcp", 2500*time.Millisecond) + if !hasFinding(r, "vital:LCP") { + t.Error("expected an LCP vital finding for the slow hero image") + } +} + +func TestCleanPageHasNoErrors(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + r := runScenario(t, srv.URL+"/clean", time.Second) + if r.Summary.HasFatal { + t.Errorf("clean page should not be fatal; findings=%+v", r.Findings) + } + if r.Summary.Errors != 0 { + t.Errorf("clean page should have 0 errors, got %d", r.Summary.Errors) + } +} + +// TestJSONLGroundTruthWritten verifies the on-disk log is non-empty after +// a navigation and that Close() flushes the sink correctly. +func TestJSONLGroundTruthWritten(t *testing.T) { + skipIfNoChrome(t) + srv := NewFaultServer() + defer srv.Close() + + dir := t.TempDir() + path := filepath.Join(dir, "gt.jsonl") + cfg := DefaultConfig(path) + cfg.MetricsEvery = 0 + rec, _ := NewRecorder(cfg) + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("no-sandbox", true), + )...) + defer cancelAlloc() + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + rec.Attach(ctx) + rec.EnableDomains(ctx) + chromedp.Run(ctx, + chromedp.Navigate(srv.URL+"/console-error"), + chromedp.Sleep(time.Second), + ) + rec.Close() // flush sink before stat + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("jsonl not written: %v", err) + } + if info.Size() == 0 { + t.Error("jsonl ground truth is empty") + } +} diff --git a/pkg/browser/cdp/recorder.go b/pkg/browser/cdp/recorder.go new file mode 100644 index 00000000..ffdb83b9 --- /dev/null +++ b/pkg/browser/cdp/recorder.go @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MIT +// Purpose: CDP Recorder for the SIN-Code browser-tools skill. +// +// Design rationale: +// - Uses a real chromedp CDPSession (via chromedp.ListenTarget) rather than +// Playwright page.on wrappers so that every raw protocol event is captured +// verbatim, including extra-info events that Playwright filters away. +// - Subscribes to every domain that surfaces actionable signals: Network +// (15 events including ExtraInfo/WebSocket/EventSource), Runtime (console + +// exceptions), Log, Audits (DevTools Issues panel — CORS, CSP, mixed +// content, deprecations), Security (TLS state), Page lifecycle, and Target +// (OOPIF / workers via setAutoAttach flatten=true). +// - Writes a JSONL ground-truth log with monotonic sequence numbers, wall +// clock, and a step_id correlation field so every event can be traced back +// to the agent action that triggered it. +// - Optionally captures response bodies on loadingFinished (deferred to +// correct timing), guarded by a configurable byte cap. +// - Keeps an in-memory copy of events (when Config.KeepInMemory is true) so +// the deterministic Findings engine in findings.go can run synchronously +// after recording without re-reading the JSONL from disk. +// +// Linter note: cdproto imports are aliased where the package name clashes with +// standard library names (cdplog for cdproto/log). +package cdp + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "time" + + "github.com/chromedp/cdproto/audits" + "github.com/chromedp/cdproto/dom" + cdplog "github.com/chromedp/cdproto/log" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/performance" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/cdproto/security" + "github.com/chromedp/cdproto/target" + "github.com/chromedp/chromedp" +) + +// Config controls the Recorder's behaviour. +type Config struct { + // JSONLPath is the file path for the ground-truth JSONL log. + // Required; NewRecorder returns an error if the file cannot be created. + JSONLPath string + + // KeepInMemory retains a copy of every Event in memory so callers can + // pass rec.Events() directly to Analyze without reading the JSONL file. + KeepInMemory bool + + // CaptureBodies fetches the response body for each completed network + // request and emits a synthetic "Network"/"responseBody" event. + CaptureBodies bool + + // MaxBodyBytes caps individual response body captures. 0 means no limit. + // Recommended default: 2 MiB (2 << 20). + MaxBodyBytes int64 + + // MetricsEvery controls the Performance.getMetrics polling interval. + // 0 disables polling (the default for lightweight runs). + MetricsEvery time.Duration +} + +// DefaultConfig returns a Config suitable for most agent sessions. +// JSONLPath must still be set by the caller. +func DefaultConfig(jsonlPath string) Config { + return Config{ + JSONLPath: jsonlPath, + KeepInMemory: true, + CaptureBodies: true, + MaxBodyBytes: 2 << 20, // 2 MiB + MetricsEvery: 2 * time.Second, + } +} + +// Recorder captures CDP events into a JSONL ground-truth log and optionally +// into an in-memory slice for the Findings engine. +type Recorder struct { + cfg Config + sink *Sink + start time.Time + + mu sync.Mutex + seq uint64 + events []*Event // populated only when cfg.KeepInMemory is true + + // step is the current correlation ID set by the agent before each action. + // atomic.Value allows racy reads from the dispatch goroutine without a mutex. + step atomic.Value // stores string + + // dead is closed by Close() to signal background goroutines to exit. + dead chan struct{} + + // bodyWG tracks in-flight async response-body fetches so Close() can wait + // for them to finish (with a 5 s guard) before flushing the sink. + bodyWG sync.WaitGroup +} + +// NewRecorder creates a Recorder and opens the JSONL sink at cfg.JSONLPath. +// The caller must call Close() when the session ends. +func NewRecorder(cfg Config) (*Recorder, error) { + sink, err := NewSink(cfg.JSONLPath) + if err != nil { + return nil, err + } + r := &Recorder{ + cfg: cfg, + sink: sink, + start: time.Now(), + dead: make(chan struct{}), + } + r.step.Store("") + return r, nil +} + +// SetStep tags all subsequent events with id so they can be correlated to +// the agent action that triggered them (e.g. "navigate", "click_submit"). +// Call before each agent action and clear with SetStep("") afterwards. +func (r *Recorder) SetStep(id string) { r.step.Store(id) } + +func (r *Recorder) currentStep() string { + if v, ok := r.step.Load().(string); ok { + return v + } + return "" +} + +// Events returns a snapshot of the in-memory event slice. +// Returns nil if Config.KeepInMemory was false. +func (r *Recorder) Events() []*Event { + r.mu.Lock() + defer r.mu.Unlock() + if r.events == nil { + return nil + } + out := make([]*Event, len(r.events)) + copy(out, r.events) + return out +} + +// Close stops background goroutines and flushes + closes the JSONL sink. +// It first waits (up to 5 s) for any in-flight response-body fetches to +// complete so no captured bodies are lost on fast sessions. +// Must be called exactly once after recording is complete. +func (r *Recorder) Close() error { + // Wait for in-flight body fetches, but never hang forever. + done := make(chan struct{}) + go func() { r.bodyWG.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + } + close(r.dead) + return r.sink.Close() +} + +// EnableDomains enables every CDP domain the Recorder listens to. +// Must be called after Attach but before the first navigation. +// Each Enable is best-effort: a missing domain must not abort the others. +func (r *Recorder) EnableDomains(ctx context.Context) error { + return chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + _ = network.Enable().Do(ctx) + _ = page.Enable().Do(ctx) + _ = runtime.Enable().Do(ctx) + _ = cdplog.Enable().Do(ctx) + _ = audits.Enable().Do(ctx) // DevTools Issues: CORS, CSP, mixed content, deprecations + _ = security.Enable().Do(ctx) // TLS / certificate / mixed-content state + _ = dom.Enable().Do(ctx) + _ = performance.Enable().Do(ctx) + _ = page.SetLifecycleEventsEnabled(true).Do(ctx) + // setAutoAttach with flatten=true surfaces OOPIFs and workers as + // child CDP sessions with their own session IDs, so cross-origin + // iframes are captured with the same fidelity as the main frame. + _ = target.SetAutoAttach(true, false, true).Do(ctx) + return nil + })) +} + +// Attach registers the event listener on the chromedp context and starts the +// optional Performance metrics poller. Must be called after chromedp.NewContext +// and before the first navigation. +func (r *Recorder) Attach(ctx context.Context) { + chromedp.ListenTarget(ctx, func(ev interface{}) { r.dispatch(ctx, ev) }) + r.captureInitialTargets(ctx) // record any targets that already exist + if r.cfg.MetricsEvery > 0 { + go r.pollMetrics(ctx) + } +} + +// captureInitialTargets records targets that already existed before we +// attached so that pre-opened tabs, workers, and service workers are not +// missed by the session. +func (r *Recorder) captureInitialTargets(ctx context.Context) { + _ = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + infos, err := target.GetTargets().Do(ctx) + if err != nil { + return nil + } + for _, ti := range infos { + r.emit("Target", "initialTarget", "", ti) + } + return nil + })) +} + +func (r *Recorder) pollMetrics(ctx context.Context) { + t := time.NewTicker(r.cfg.MetricsEvery) + defer t.Stop() + for { + select { + case <-r.dead: + return + case <-ctx.Done(): + return + case <-t.C: + _ = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + m, err := performance.GetMetrics().Do(ctx) + if err == nil { + r.emit("Performance", "metrics", "", map[string]interface{}{"metrics": m}) + } + return nil + })) + } + } +} + +// emit assigns a sequence number, builds an Event, and writes it to the sink. +func (r *Recorder) emit(domain, method, session string, params interface{}) { + b, err := json.Marshal(params) + if err != nil { + return + } + r.mu.Lock() + r.seq++ + e := &Event{ + Seq: r.seq, + WallTime: time.Now().UTC().Format(time.RFC3339Nano), + MonoNanos: time.Since(r.start).Nanoseconds(), + SessionID: session, + Domain: domain, + Method: method, + StepID: r.currentStep(), + Params: b, + } + if r.cfg.KeepInMemory { + r.events = append(r.events, e) + } + r.mu.Unlock() + // Write outside the mutex: the Sink has its own lock, and ordering is + // already guaranteed by the sequence number assigned above. + r.sink.write(e) +} + +// dispatch type-switches every CDP event and records it verbatim. +// It is called from chromedp.ListenTarget on the chromedp event loop goroutine. +// +//nolint:cyclop // large switch is intentional — one case per CDP event type +func (r *Recorder) dispatch(ctx context.Context, ev interface{}) { + switch e := ev.(type) { + + // ---- Network (15 events) ----------------------------------------------- + case *network.EventRequestWillBeSent: + r.emit("Network", "requestWillBeSent", "", e) + case *network.EventRequestWillBeSentExtraInfo: + r.emit("Network", "requestWillBeSentExtraInfo", "", e) + case *network.EventResponseReceived: + r.emit("Network", "responseReceived", "", e) + case *network.EventResponseReceivedExtraInfo: + r.emit("Network", "responseReceivedExtraInfo", "", e) + case *network.EventDataReceived: + r.emit("Network", "dataReceived", "", e) + case *network.EventLoadingFinished: + r.emit("Network", "loadingFinished", "", e) + if r.cfg.CaptureBodies { + r.bodyWG.Add(1) + go r.fetchBody(ctx, e.RequestID) + } + case *network.EventLoadingFailed: + r.emit("Network", "loadingFailed", "", e) + case *network.EventRequestServedFromCache: + r.emit("Network", "requestServedFromCache", "", e) + case *network.EventResourceChangedPriority: + r.emit("Network", "resourceChangedPriority", "", e) + case *network.EventWebSocketCreated: + r.emit("Network", "webSocketCreated", "", e) + case *network.EventWebSocketFrameSent: + r.emit("Network", "webSocketFrameSent", "", e) + case *network.EventWebSocketFrameReceived: + r.emit("Network", "webSocketFrameReceived", "", e) + case *network.EventWebSocketFrameError: + r.emit("Network", "webSocketFrameError", "", e) + case *network.EventWebSocketClosed: + r.emit("Network", "webSocketClosed", "", e) + case *network.EventEventSourceMessageReceived: + r.emit("Network", "eventSourceMessageReceived", "", e) + + // ---- Runtime (console + uncaught exceptions) --------------------------- + case *runtime.EventConsoleAPICalled: + r.emit("Runtime", "consoleAPICalled", "", e) + case *runtime.EventExceptionThrown: + r.emit("Runtime", "exceptionThrown", "", e) + + // ---- Log (browser-level log entries, distinct from console) ----------- + case *cdplog.EventEntryAdded: + r.emit("Log", "entryAdded", "", e) + + // ---- Audits (DevTools Issues panel) ------------------------------------ + // Covers: CORS errors, CSP violations, mixed content, SameSite cookies, + // low contrast, deprecation warnings — all pre-classified by Chrome. + case *audits.EventIssueAdded: + r.emit("Audits", "issueAdded", "", e) + + // ---- Security (TLS / certificate / mixed-content state) --------------- + case *security.EventSecurityStateChanged: + r.emit("Security", "securityStateChanged", "", e) + + // ---- Page lifecycle (16 events) ---------------------------------------- + case *page.EventLoadEventFired: + r.emit("Page", "loadEventFired", "", e) + case *page.EventDomContentEventFired: + r.emit("Page", "domContentEventFired", "", e) + case *page.EventLifecycleEvent: + r.emit("Page", "lifecycleEvent", "", e) + case *page.EventFrameNavigated: + r.emit("Page", "frameNavigated", "", e) + case *page.EventFrameRequestedNavigation: + r.emit("Page", "frameRequestedNavigation", "", e) + case *page.EventJavascriptDialogOpening: + r.emit("Page", "javascriptDialogOpening", "", e) + case *page.EventFileChooserOpened: + r.emit("Page", "fileChooserOpened", "", e) + case *page.EventDownloadWillBegin: + r.emit("Page", "downloadWillBegin", "", e) + case *page.EventDownloadProgress: + r.emit("Page", "downloadProgress", "", e) + + // ---- Targets (OOPIFs, workers, service workers) ----------------------- + // setAutoAttach(flatten=true) surfaces cross-origin iframes and workers as + // child sessions. enableOnSession turns on Network/Runtime/Log/Audits on + // each child so iframe console errors and audit issues are captured with + // the same fidelity as the main frame. + case *target.EventAttachedToTarget: + r.emit("Target", "attachedToTarget", string(e.SessionID), e) + go r.enableOnSession(ctx, e.SessionID) + case *target.EventDetachedFromTarget: + r.emit("Target", "detachedFromTarget", string(e.SessionID), e) + case *target.EventTargetCreated: + r.emit("Target", "targetCreated", "", e) + case *target.EventTargetDestroyed: + r.emit("Target", "targetDestroyed", "", e) + } +} + +// fetchBody retrieves the response body for a completed request and emits it +// as a synthetic "Network"/"responseBody" event. Called in its own goroutine +// after loadingFinished so the body is guaranteed to be available. +// bodyWG.Done is always called, even when the fetch fails, so Close() drains cleanly. +func (r *Recorder) fetchBody(ctx context.Context, id network.RequestID) { + defer r.bodyWG.Done() + _ = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + body, err := network.GetResponseBody(id).Do(ctx) + if err != nil { + // Body may already be evicted from the cache; not fatal. + return nil + } + truncated := false + if r.cfg.MaxBodyBytes > 0 && int64(len(body)) > r.cfg.MaxBodyBytes { + body = body[:r.cfg.MaxBodyBytes] + truncated = true + } + r.emit("Network", "responseBody", "", map[string]interface{}{ + "requestId": id, + "bytes": len(body), + "truncated": truncated, + "body": string(body), + }) + return nil + })) +} + +// enableOnSession turns on the relevant CDP domains for an auto-attached child +// target (OOPIF / dedicated worker / service worker). Without this, events +// originating inside cross-origin iframes or workers are never delivered to the +// top-level listener. Events from child sessions carry their own SessionID in +// the envelope so the analysis layer can separate top-frame and iframe traffic. +func (r *Recorder) enableOnSession(ctx context.Context, sid target.SessionID) { + sessionCtx, cancel := chromedp.NewContext(ctx, chromedp.WithExistingSession(sid)) + _ = cancel // session lifetime is owned by the target; we only borrow the executor + + _ = chromedp.Run(sessionCtx, chromedp.ActionFunc(func(ctx context.Context) error { + _ = network.Enable().Do(ctx) + _ = runtime.Enable().Do(ctx) + _ = cdplog.Enable().Do(ctx) + _ = audits.Enable().Do(ctx) + // Recursively auto-attach so nested OOPIFs/workers also surface. + _ = target.SetAutoAttach(true, false, true).Do(ctx) + // Release workers that pause waiting for a debugger to attach. + _ = target.RunIfWaitingForDebugger().Do(ctx) + return nil + })) +} diff --git a/pkg/browser/cdp/report.go b/pkg/browser/cdp/report.go new file mode 100644 index 00000000..73217702 --- /dev/null +++ b/pkg/browser/cdp/report.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +// Purpose: aggregated Report for the agent loop. +// +// Report is the single structured object the agent consumes after a recording +// session. It is a *view* over the JSONL ground truth — the JSONL file remains +// the canonical source of truth and is never modified by this layer. +// +// The pipeline is: +// +// rec.Events() → Analyze → Findings +// → Correlate → Chains +// → Suggest → FixSuggests +// → BuildReport → Report +// → Report.WriteJSON → report.json (agent persists for diffing) +package cdp + +import ( + "encoding/json" + "os" + "time" +) + +// Report is the top-level object the agent consumes after a navigation or +// interaction session. All fields are deterministic given the same event slice. +type Report struct { + // GeneratedAt is the wall-clock time BuildReport was called (RFC3339Nano). + GeneratedAt string `json:"generated_at"` + + // EventCount is the total number of CDP events captured in the session. + EventCount int `json:"event_count"` + + // Findings are the grouped, classified problems (errors, warnings, info). + // Sorted by severity then count — the most critical items come first. + Findings []*Finding `json:"findings"` + + // Chains are root-cause correlation chains that link a failing network + // request to the downstream exceptions and console errors it caused. + Chains []*Chain `json:"chains"` + + // Suggestions are deterministic, rule-based fix hints the agent can route + // to the appropriate auto-fix handler via FixClass. + Suggestions []*FixSuggest `json:"suggestions"` + + // Summary is a quick health snapshot for agent decision branching. + Summary ReportSummary `json:"summary"` +} + +// ReportSummary contains the counters the agent reads first before deciding +// whether to dig into Findings, route Suggestions, or declare success. +type ReportSummary struct { + Errors int `json:"errors"` + Warnings int `json:"warnings"` + HasFatal bool `json:"has_fatal"` // true when any error-severity finding exists +} + +// BuildReport runs the full deterministic analysis pipeline over events and +// returns a Report ready for the agent loop. +// +// report := cdp.BuildReport(rec.Events(), 25) +// if report.Summary.HasFatal { ... } +func BuildReport(events []*Event, window uint64) *Report { + findings := Analyze(events) + chains := Correlate(events, window) + suggestions := Suggest(findings, chains) + + var errs, warns int + for _, f := range findings { + switch f.Severity { + case SevError: + errs += f.Count + case SevWarn: + warns += f.Count + } + } + + return &Report{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), + EventCount: len(events), + Findings: findings, + Chains: chains, + Suggestions: suggestions, + Summary: ReportSummary{ + Errors: errs, + Warnings: warns, + HasFatal: errs > 0, + }, + } +} + +// WriteJSON persists the Report as indented JSON next to the JSONL log. +// The file can be re-read later for DiffReports without a re-run. +func (r *Report) WriteJSON(path string) error { + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} diff --git a/pkg/browser/cdp/sink.go b/pkg/browser/cdp/sink.go new file mode 100644 index 00000000..eb248017 --- /dev/null +++ b/pkg/browser/cdp/sink.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +// Purpose: thread-safe, buffered JSONL writer for the CDP ground-truth log. +// Each Sink wraps a single file and serialises all writes through a mutex so +// the sequence numbers assigned in Recorder.emit map 1-to-1 to lines on +// disk — there are no gaps and no reordering even under concurrent goroutines. +package cdp + +import ( + "bufio" + "encoding/json" + "os" + "sync" +) + +// Sink writes ordered JSONL events to a file on disk. +// All writes are serialised so the on-disk order exactly matches the +// sequence numbers assigned by the Recorder. +type Sink struct { + mu sync.Mutex + f *os.File + w *bufio.Writer +} + +// NewSink creates (or truncates) the file at path and returns a ready Sink. +// The caller must call Close when recording is finished. +func NewSink(path string) (*Sink, error) { + f, err := os.Create(path) + if err != nil { + return nil, err + } + // 64 KiB buffer: reduces syscall overhead for high-frequency events + // while keeping memory per Sink predictable. + return &Sink{f: f, w: bufio.NewWriterSize(f, 64*1024)}, nil +} + +// write marshals e to JSON and appends it as a single line. +// It is safe to call from multiple goroutines. +func (s *Sink) write(e *Event) { + b, err := json.Marshal(e) + if err != nil { + // Marshalling a struct that contains only json.RawMessage and + // primitive types should never fail; skip rather than panic. + return + } + s.mu.Lock() + defer s.mu.Unlock() + _, _ = s.w.Write(b) + _ = s.w.WriteByte('\n') +} + +// Close flushes the write buffer and closes the underlying file. +// Must be called exactly once after recording stops. +func (s *Sink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.w.Flush(); err != nil { + return err + } + return s.f.Close() +} diff --git a/pkg/browser/cdp/suggest.go b/pkg/browser/cdp/suggest.go new file mode 100644 index 00000000..5936a8c7 --- /dev/null +++ b/pkg/browser/cdp/suggest.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +// Purpose: rule-based Fix Suggestions from Findings and Chains. +// +// Suggest maps each Finding to a FixSuggest that names the root cause, +// describes the remediation action, and tags it with a FixClass so the agent +// loop can route it to the right auto-fix handler without LLM interpretation. +// +// Design contract: +// - Deterministic: same input → same output. +// - Conservative: never blindly edits code. Points at likely cause + class. +// - FixClass is the machine-readable routing tag (e.g. "security.cors"). +// - Confidence reflects how reliable the mapping is ("high" | "medium" | "low"). +package cdp + +import "strings" + +// FixSuggest is a deterministic, rule-based hint the agent can act on. It ties +// back to a Finding via Signature and adds a FixClass routing tag and a +// human-readable Cause + Action pair. +type FixSuggest struct { + // Signature matches the Finding this suggestion was derived from. + Signature string `json:"signature"` + + // Severity mirrors the parent Finding's severity. + Severity Severity `json:"severity"` + + // Cause is a one-sentence description of the likely root cause. + Cause string `json:"cause"` + + // Action is a concrete description of what the agent should do to fix it. + Action string `json:"action"` + + // FixClass is a dot-namespaced machine tag used by the agent loop to route + // the fix to the right handler (code edit, config change, endpoint check…). + FixClass string `json:"fix_class"` + + // Confidence is "high" | "medium" | "low" reflecting how reliable the + // cause/action mapping is for this finding class. + Confidence string `json:"confidence"` +} + +// Suggest maps findings (and the context from chains) to remediation hints. +// chains is currently used for context but future rules can weight suggestions +// by chain membership. +func Suggest(findings []*Finding, _ []*Chain) []*FixSuggest { + var out []*FixSuggest + for _, f := range findings { + if s := classify(f); s != nil { + out = append(out, s) + } + } + return out +} + +func classify(f *Finding) *FixSuggest { + sig := f.Signature + low := strings.ToLower(f.Sample) + + switch { + case strings.HasPrefix(sig, "vital:"): + return vitalSuggest(f, strings.TrimPrefix(sig, "vital:")) + + case strings.HasPrefix(sig, "audit:"): + return auditSuggest(f, strings.TrimPrefix(sig, "audit:")) + + case strings.HasPrefix(sig, "netblock:"): + return &FixSuggest{sig, f.Severity, + "A request was blocked by the browser (CSP, mixed content, or interception).", + "Inspect the blockedReason in the JSONL; adjust CSP/headers or upgrade the resource to https.", + "network.blocked", "high"} + + case strings.HasPrefix(sig, "http:"): + return &FixSuggest{sig, f.Severity, + "Server returned a 4xx/5xx for a fetched resource.", + "Check the request URL, auth, and payload against the responseReceived entry; fix the endpoint or client call.", + "network.http_error", "medium"} + + case strings.HasPrefix(sig, "netfail:"): + return &FixSuggest{sig, f.Severity, + "A network request failed before completing (DNS, TLS, abort, or timeout).", + "Read errorText in loadingFailed; verify host/cert/connectivity or add retry semantics.", + "network.transport", "medium"} + + case strings.HasPrefix(sig, "exc:"): + fc, conf := "js.exception", "medium" + if strings.Contains(low, "is not defined") || strings.Contains(low, "is not a function") { + fc, conf = "js.reference", "high" + } else if strings.Contains(low, "cannot read properties") || strings.Contains(low, "undefined") { + fc, conf = "js.null_access", "high" + } + return &FixSuggest{sig, f.Severity, + "Uncaught JavaScript exception in page or component code.", + "Locate the source from the exception stack trace and guard the access or fix the symbol.", + fc, conf} + + case strings.HasPrefix(sig, "console:") && f.Severity == SevError: + return &FixSuggest{sig, f.Severity, + "Application logged a console error.", + "Trace the log call site; treat as a symptom and correlate with nearby exceptions or network failures.", + "console.error", "low"} + + case strings.HasPrefix(sig, "security:"): + return &FixSuggest{sig, f.Severity, + "Page security state degraded to insecure or neutral.", + "Ensure all resources are served over https and that no mixed-content requests are made.", + "security.state", "medium"} + } + return nil +} + +func auditSuggest(f *Finding, code string) *FixSuggest { + switch code { + case "ContentSecurityPolicyIssue": + return &FixSuggest{f.Signature, SevWarn, + "A Content-Security-Policy directive was violated.", + "Adjust the CSP header to allow the legitimate source, or remove the offending inline/eval usage.", + "security.csp", "high"} + case "MixedContentIssue": + return &FixSuggest{f.Signature, SevWarn, + "Page loaded an insecure (http) subresource over https.", + "Upgrade the resource URL to https or use a protocol-relative/secure host.", + "security.mixed_content", "high"} + case "CorsIssue": + return &FixSuggest{f.Signature, SevWarn, + "A cross-origin request was blocked by CORS.", + "Set the correct Access-Control-Allow-* headers on the server, or proxy the request.", + "security.cors", "high"} + case "DeprecationIssue": + return &FixSuggest{f.Signature, SevInfo, + "Page uses a deprecated web platform feature.", + "Migrate off the deprecated API noted in the issue payload.", + "compat.deprecation", "medium"} + case "CookieIssue": + return &FixSuggest{f.Signature, SevWarn, + "A cookie was rejected or flagged (SameSite/Secure missing).", + "Set the SameSite and Secure attributes appropriately on the cookie.", + "security.cookie", "high"} + default: + return &FixSuggest{f.Signature, SevWarn, + "DevTools reported an issue: " + code, + "Read the full issue payload in the JSONL and remediate per its details.", + "audit.generic", "low"} + } +} + +func vitalSuggest(f *Finding, name string) *FixSuggest { + switch name { + case "LCP": + return &FixSuggest{f.Signature, f.Severity, + "The largest content element renders too late.", + "Preload the LCP image/font, reduce render-blocking JS/CSS, and serve the hero from a fast/cached source.", + "perf.lcp", "medium"} + case "CLS": + return &FixSuggest{f.Signature, f.Severity, + "Visible elements shift after load, hurting layout stability.", + "Set explicit width/height on images/embeds and reserve space for late-loading content.", + "perf.cls", "medium"} + case "INP": + return &FixSuggest{f.Signature, f.Severity, + "Interactions respond slowly due to main-thread work.", + "Break up long tasks, defer non-critical JS, and debounce heavy event handlers.", + "perf.inp", "medium"} + case "LongTask": + return &FixSuggest{f.Signature, f.Severity, + "A long task blocked the main thread.", + "Split the work with scheduling APIs (yield/requestIdleCallback) or move it to a worker.", + "perf.longtask", "low"} + } + return nil +} diff --git a/pkg/browser/cdp/testserver.go b/pkg/browser/cdp/testserver.go new file mode 100644 index 00000000..aefa1e45 --- /dev/null +++ b/pkg/browser/cdp/testserver.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Purpose: deterministic fault-injection HTTP server for the test harness. +// +// NewFaultServer returns an httptest.Server with one route per finding +// category so each browser test can navigate to a known-bad (or known-good) +// page and assert on a single class of problem in isolation. +package cdp + +import ( + "net/http" + "net/http/httptest" + "time" +) + +// NewFaultServer returns an httptest server that deterministically reproduces +// every finding category the recorder must catch. Each route triggers exactly +// one class of problem so tests can assert on it in isolation. +func NewFaultServer() *httptest.Server { + mux := http.NewServeMux() + + // 1. Console error + uncaught exception (ReferenceError). + mux.HandleFunc("/console-error", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

console

`)) + }) + + // 2. HTTP 500 on a fetched subresource. + mux.HandleFunc("/server-500.js", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`// 500`)) + }) + mux.HandleFunc("/http-500", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

500

+ `)) + }) + + // 3. Failed request (resource that never resolves -> loadingFailed). + // Points at port 1 which nothing listens on -> immediate connection refused. + mux.HandleFunc("/net-fail", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

fail

+ `)) + }) + + // 4. CSP violation (inline script blocked by a strict policy -> Audits issue). + mux.HandleFunc("/csp", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Security-Policy", "script-src 'none'") + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

csp

+ `)) + }) + + // 5. Slow LCP (delayed hero image -> poor LCP vital when InstallVitals is active). + mux.HandleFunc("/slow-img.png", func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(1200 * time.Millisecond) + w.Header().Set("Content-Type", "image/png") + w.Write(onePxPNG) + }) + mux.HandleFunc("/slow-lcp", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + `)) + }) + + // 6. Clean page (control: must produce zero error findings). + mux.HandleFunc("/clean", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

all good

+ `)) + }) + + return httptest.NewServer(mux) +} diff --git a/pkg/browser/cdp/verify.go b/pkg/browser/cdp/verify.go new file mode 100644 index 00000000..fb1ee44b --- /dev/null +++ b/pkg/browser/cdp/verify.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// Purpose: Fix-Verify loop — before/after report diffing for the agent. +// +// After applying a fix the agent creates a fresh Recorder, re-navigates, and +// calls BuildReport again. DiffReports compares the two reports keyed by +// Finding.Signature and tells the agent whether to accept the fix, revert it, +// or try the next suggestion. +// +// Usage: +// +// baseline := cdp.BuildReport(rec.Events(), 25) +// +// // ... agent applies a fix (code edit / config / endpoint) ... +// +// rec2, _ := cdp.NewRecorder(cdp.DefaultConfig("evidence.after.jsonl")) +// // ... set up ctx2, rec2.Attach + EnableDomains + InstallVitals, navigate ... +// after := cdp.BuildReport(rec2.Events(), 25) +// +// diff := cdp.DiffReports(baseline, after) +// switch { +// case diff.Improved: +// agent.AcceptFix() +// case len(diff.Introduced) > 0: +// agent.RevertFix() // regression introduced +// default: +// agent.TryNextSuggestion() // fix had no effect +// } +package cdp + +// Diff is the result of comparing a before-fix and after-fix Report. The agent +// uses it to decide whether to accept a fix, revert it, or try the next one. +type Diff struct { + // Resolved contains findings that were present before and gone after the fix. + Resolved []*Finding `json:"resolved"` + + // Introduced contains new findings that appeared after the fix (regressions). + Introduced []*Finding `json:"introduced"` + + // Persisted contains findings that exist in both reports. + Persisted []*Finding `json:"persisted"` + + // Improved is true when the net error count decreased AND no new + // error-severity findings were introduced. + Improved bool `json:"improved"` + + // BeforeErr and AfterErr are the total error event counts from each report. + BeforeErr int `json:"before_errors"` + AfterErr int `json:"after_errors"` +} + +// DiffReports computes the before/after delta keyed by Finding.Signature. +// It is deterministic: given the same two reports it always returns the same Diff. +func DiffReports(before, after *Report) *Diff { + beforeBySig := map[string]*Finding{} + for _, f := range before.Findings { + beforeBySig[f.Signature] = f + } + afterBySig := map[string]*Finding{} + for _, f := range after.Findings { + afterBySig[f.Signature] = f + } + + d := &Diff{BeforeErr: before.Summary.Errors, AfterErr: after.Summary.Errors} + + for sig, f := range beforeBySig { + if _, ok := afterBySig[sig]; !ok { + d.Resolved = append(d.Resolved, f) + } else { + d.Persisted = append(d.Persisted, afterBySig[sig]) + } + } + for sig, f := range afterBySig { + if _, ok := beforeBySig[sig]; !ok { + d.Introduced = append(d.Introduced, f) + } + } + + // A fix "improved" things only if total errors dropped AND no new + // error-severity findings were introduced. + regressed := false + for _, f := range d.Introduced { + if f.Severity == SevError { + regressed = true + break + } + } + d.Improved = d.AfterErr < d.BeforeErr && !regressed + return d +} diff --git a/pkg/browser/cdp/vitals.go b/pkg/browser/cdp/vitals.go new file mode 100644 index 00000000..605b0e9a --- /dev/null +++ b/pkg/browser/cdp/vitals.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// Purpose: Web Vitals injection for the CDP Recorder. +// +// InstallVitals adds a PerformanceObserver script to every new document so +// that LCP, CLS, INP, and LongTask metrics are forwarded via a tagged +// console.debug call into the CDP event stream. The findings engine in +// findings.go intercepts these "__SINCDP_VITAL__"-tagged events before the +// general console handler so they are classified as "vital" findings rather +// than plain console entries. +package cdp + +import ( + "context" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/cdproto/runtime" + "github.com/chromedp/chromedp" +) + +// vitalsScript installs PerformanceObservers for LCP, CLS, INP, and LongTask. +// Each metric is forwarded to the host via console.debug with the tag +// "__SINCDP_VITAL__" so the findings layer can recognise and classify it. +const vitalsScript = ` +(() => { + const send = (name, value, extra) => { + try { + console.debug("__SINCDP_VITAL__", JSON.stringify({ name, value, ...extra })); + } catch (e) {} + }; + + // Largest Contentful Paint + try { + new PerformanceObserver((l) => { + const entries = l.getEntries(); + const last = entries[entries.length - 1]; + if (last) send("LCP", last.startTime, { size: last.size }); + }).observe({ type: "largest-contentful-paint", buffered: true }); + } catch (e) {} + + // Cumulative Layout Shift + try { + let cls = 0; + new PerformanceObserver((l) => { + for (const entry of l.getEntries()) { + if (!entry.hadRecentInput) cls += entry.value; + } + send("CLS", cls, {}); + }).observe({ type: "layout-shift", buffered: true }); + } catch (e) {} + + // Long Tasks (main-thread blocking) + try { + new PerformanceObserver((l) => { + for (const entry of l.getEntries()) { + send("LongTask", entry.duration, { start: entry.startTime }); + } + }).observe({ type: "longtask", buffered: true }); + } catch (e) {} + + // Interaction to Next Paint (INP) + try { + new PerformanceObserver((l) => { + for (const entry of l.getEntries()) { + const delay = entry.processingStart - entry.startTime; + send("INP", delay, { name: entry.name }); + } + }).observe({ type: "event", buffered: true, durationThreshold: 40 }); + } catch (e) {} +})(); +` + +// InstallVitals registers the PerformanceObserver script so it runs on every +// new document loaded in the tab. Call once after EnableDomains and before +// navigating. No-op if the Page domain is not enabled. +func (r *Recorder) InstallVitals(ctx context.Context) error { + return chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + _, err := page.AddScriptToEvaluateOnNewDocument(vitalsScript).Do(ctx) + return err + })) +} + +// EvalVitalsNow forces an immediate metric flush by re-running the observer +// script in the current document context. Useful when called right before +// BuildReport on a page that is already loaded, so that final CLS/LCP values +// are captured even if the observers never fired a final callback. +func (r *Recorder) EvalVitalsNow(ctx context.Context) { + _ = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + _, exp, err := runtime.Evaluate(vitalsScript).Do(ctx) + _ = exp + return err + })) +} diff --git a/skills/browser-skills/skill-browser-tools/SKILL.md b/skills/browser-skills/skill-browser-tools/SKILL.md index 01a28fb9..3096c840 100644 --- a/skills/browser-skills/skill-browser-tools/SKILL.md +++ b/skills/browser-skills/skill-browser-tools/SKILL.md @@ -1,50 +1,168 @@ --- name: skill-browser-tools -description: Browser automation tools for agents. Navigate, click, screenshot, scrape, and interact with web pages. +description: Browser automation and CDP evidence capture for agents. Navigate, record, screenshot, scrape, and interact with web pages. Surfaces deterministic findings (network failures, JS exceptions, CORS/CSP violations, security state) without requiring LLM interpretation of raw logs. license: MIT compatibility: - opencode - sin-code metadata: author: SIN-Code - version: 1.0.0 + version: 2.0.0 --- # skill-browser-tools ## Overview -Use browser automation to interact with the web: navigate, click, screenshot, scrape, fill forms. +Use browser automation to interact with the web and to capture a full Chrome DevTools Protocol (CDP) ground-truth log of every navigation. The builtin tools drive a headless Chrome instance and expose three levels of output: + +1. **sin_browser_navigate** — navigate and record +2. **sin_browser_findings** — classified, grouped findings (errors / warnings) +3. **sin_browser_snapshot** — full session summary with raw event counts ## When to Use -- User asks to visit a website, test a page, screenshot, scrape, or interact with a web app. +- Visit a website, test a page, screenshot, or scrape content. +- Diagnose why a page is broken: network failures, JS exceptions, CORS/CSP errors. +- Verify a fix by comparing findings before and after. +- Capture a reproducible evidence log for a bug report. ## When NOT to Use -- The data is available via API (use API instead). -- The user has not authorized web interaction. +- The data is available via a public API (use `sin_http_get` instead). +- The user has not authorised web interaction. +- The operation requires a logged-in session with sensitive credentials. ## Core Process ``` -NAVIGATE → OBSERVE → ACT → VERIFY +NAVIGATE (sin_browser_navigate) + → OBSERVE findings (sin_browser_findings) + → ACT on errors (edit code / config) + → VERIFY (sin_browser_navigate again, compare findings) +``` + +## Builtin Tools + +### sin_browser_navigate + +Drives headless Chrome to a URL and records the complete CDP event stream. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `url` | string | yes | `http(s)` URL to load | +| `step` | string | no | Correlation label (e.g. `"login_submit"`) | +| `wait_sec` | string | no | Seconds to wait after load (default `"3"`, max `"120"`) | + +Returns: event count by CDP domain, path to the JSONL ground-truth log. + +Permission: **ask** (loads arbitrary URLs). + +**What is recorded (CDP domains):** + +| Domain | Events captured | +|--------|----------------| +| Network | `requestWillBeSent`, `requestWillBeSentExtraInfo`, `responseReceived`, `responseReceivedExtraInfo`, `dataReceived`, `loadingFinished`, `loadingFailed`, `requestServedFromCache`, `resourceChangedPriority`, `webSocketCreated/Frame*/Closed`, `eventSourceMessageReceived` | +| Runtime | `consoleAPICalled`, `exceptionThrown` | +| Log | `entryAdded` | +| **Audits** | `issueAdded` — CORS, CSP violations, mixed content, SameSite cookies, low contrast, deprecation warnings (pre-classified by Chrome) | +| **Security** | `securityStateChanged` — TLS/certificate/mixed-content state | +| Page | `loadEventFired`, `domContentEventFired`, `lifecycleEvent`, `frameNavigated`, `frameRequestedNavigation`, `javascriptDialogOpening`, `fileChooserOpened`, `downloadWillBegin/Progress` | +| Target | `attachedToTarget`, `detachedFromTarget`, `targetCreated/Destroyed` — OOPIFs and workers via `setAutoAttach(flatten=true)` | +| Performance | `getMetrics` polled every 2 s | + +Response bodies are captured on `loadingFinished` (deferred for correct timing), capped at 2 MiB each, and appended as synthetic `Network/responseBody` events. + +Each event carries: +- `seq` — global monotonic sequence number +- `wall_time` — RFC3339Nano timestamp +- `mono_nanos` — nanoseconds since session start (use for latency) +- `step_id` — correlation label set by `step` parameter +- `session_id` — CDP session for OOPIF / worker events + +--- + +### sin_browser_findings + +Runs the deterministic Findings engine over the last recorded session. + +No parameters required. + +Returns: JSON array of `Finding` objects, sorted by severity (error → warn → info) then by count (descending). Returns a "no findings" message when the page loaded cleanly. + +Permission: **allow** (reads in-memory state only, no network calls). + +**Finding schema:** + +```json +{ + "category": "console | exception | network | audit | security", + "severity": "error | warn | info", + "title": "Human-readable description", + "signature": "stable dedup key", + "count": 42, + "first_seq": 17, + "last_seq": 312, + "sample": "First 200 chars of the triggering event" +} ``` -1. Navigate to the target URL. -2. Observe the page state (title, elements, text). -3. Perform actions (click, type, scroll). -4. Verify expected outcome. +**What is classified:** + +| Category | Severity rule | +|----------|--------------| +| Console `error` / `assert` | error | +| Console `warning` | warn | +| Uncaught JS exception | error | +| Network `loadingFailed` (blocked) | error | +| HTTP 5xx response | error | +| HTTP 4xx response | warn | +| Audits `issueAdded` (any code) | warn | +| Security state `insecure` / `neutral` | warn | + +--- + +### sin_browser_snapshot + +Returns a compact JSON summary of the last session without re-running the full engine: total event count, first/last wall times, per-domain-method event counts, finding count, findings list, and the JSONL file path. + +No parameters required. + +Permission: **allow**. + +--- + +## JSONL Ground-Truth Log + +Every session writes a JSONL file to the OS temp directory. The path is returned by `sin_browser_navigate` and included in the snapshot. The file is a forensic record that can be: + +- Read directly with `sin_read` for deep inspection. +- Used as evidence in bug reports. +- Diffed across runs to isolate regressions. + +Format: one JSON object per line, schema matches the `Event` type in `pkg/browser/cdp/event.go`. + +--- + +## Known Limitations + +- **OOPIF console capture**: `Runtime.enable` / `Network.enable` / `Audits.enable` are not yet sent on auto-attached child sessions (cross-origin iframes, workers). Console errors and network failures inside those frames are not captured. Tracked as `TODO(oopif)` in `pkg/browser/cdp/recorder.go`. +- **Requires Chrome/Chromium**: the `chromedp` allocator uses the system Chrome binary. If Chrome is not installed, `sin_browser_navigate` returns an error. +- **Single session**: only one recording session is active at a time; a new `sin_browser_navigate` call tears down the previous session. + +--- ## Safety -- Respect robots.txt. -- Do not submit sensitive data. +- Respect `robots.txt`. +- Do not submit sensitive credentials — the recording captures all network traffic including request bodies. - Avoid automated account creation unless explicitly allowed. -## Verification +--- + +## Verification Checklist -- [ ] URL reached. -- [ ] Expected element or text present. -- [ ] Action outcome matches expectation. -- [ ] Screenshot or artifact saved if requested. +- [ ] `sin_browser_navigate` returns event counts for expected domains. +- [ ] `sin_browser_findings` returns 0 findings (or known/acceptable ones) after a fix. +- [ ] JSONL path is present and non-empty. +- [ ] Screenshot or DOM snapshot saved if requested.