rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package harness
4
5import (
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/url"
10 "strings"
11
12 cdplog "github.com/chromedp/cdproto/log"
13 "github.com/chromedp/cdproto/network"
14 "github.com/chromedp/cdproto/runtime"
15 "github.com/chromedp/chromedp"
16 "github.com/go-json-experiment/json/jsontext"
17)
18
19// Allow registers an expected probe: responses with this method, path
20// and status stop being problems — and so do the console-error mirrors
21// Chromium logs for them, which arrive via the CDP log domain carrying
22// only a URL and so are matched by path alone.
23func (r *Rig) Allow(method, path string, status int) {
24 r.mu.Lock()
25 defer r.mu.Unlock()
26 r.allows = append(r.allows, allowance{method: method, path: path, status: status})
27}
28
29func (r *Rig) add(problem string) {
30 r.mu.Lock()
31 defer r.mu.Unlock()
32 r.problems = append(r.problems, problem)
33}
34
35// take drains the accumulated problems — Screen's flush.
36func (r *Rig) take() []string {
37 r.mu.Lock()
38 defer r.mu.Unlock()
39 out := r.problems
40 r.problems = nil
41 return out
42}
43
44// consoleArgText renders one console-call argument's CDP value for a
45// problem line. A JS string arrives as a JSON-quoted value — so
46// console.error("kaboom") would otherwise read `"kaboom"`, quotes and
47// all — so a string is unquoted first; anything else (number, object,
48// ...) is reported as its raw JSON text, which is already readable.
49func consoleArgText(v jsontext.Value) string {
50 var s string
51 if err := json.Unmarshal(v, &s); err == nil {
52 return s
53 }
54 return string(v)
55}
56
57func pathOf(rawURL string) string {
58 u, err := url.Parse(rawURL)
59 if err != nil {
60 return rawURL
61 }
62 return u.Path
63}
64
65func (r *Rig) responseAllowed(method, rawURL string, status int) bool {
66 p := pathOf(rawURL)
67 r.mu.Lock()
68 defer r.mu.Unlock()
69 for _, a := range r.allows {
70 if a.method == method && a.path == p && a.status == status {
71 return true
72 }
73 }
74 return false
75}
76
77// logEntryAllowed matches a CDP log entry by URL alone: the log
78// domain's mirror of an HTTP failure carries no method or status, so
79// an allowed path excuses its mirror too.
80func (r *Rig) logEntryAllowed(rawURL string) bool {
81 p := pathOf(rawURL)
82 r.mu.Lock()
83 defer r.mu.Unlock()
84 for _, a := range r.allows {
85 if a.path == p {
86 return true
87 }
88 }
89 return false
90}
91
92func (r *Rig) requestInfoFor(id network.RequestID) requestInfo {
93 r.mu.Lock()
94 defer r.mu.Unlock()
95 return r.requests[id]
96}
97
98// watch wires the loud-failure listeners: console error/assert, thrown
99// exceptions, failed requests, responses >= 400 — all accumulate into
100// the problem list Screen flushes. Three chromedp-specific facts this
101// port honors: a 4xx/5xx response also surfaces as a console-error
102// MIRROR, which in CDP arrives via log.EventEntryAdded, not
103// runtime.EventConsoleAPICalled; a response has no method of its own,
104// so it is correlated from network.EventRequestWillBeSent by
105// RequestID; and the browser probes /favicon.ico by itself, which New
106// pre-allows. chromedp enables the log and network domains on target
107// attach, so there is nothing to switch on here.
108func (r *Rig) watch() {
109 chromedp.ListenTarget(r.ctx, func(ev any) {
110 switch e := ev.(type) {
111 case *runtime.EventConsoleAPICalled:
112 if e.Type == "error" || e.Type == "assert" {
113 var parts []string
114 for _, a := range e.Args {
115 parts = append(parts, consoleArgText(a.Value))
116 }
117 r.add("console." + string(e.Type) + ": " + strings.Join(parts, " "))
118 }
119 case *runtime.EventExceptionThrown:
120 r.add("uncaught: " + e.ExceptionDetails.Error())
121 case *cdplog.EventEntryAdded:
122 if e.Entry.Level != cdplog.LevelError {
123 return
124 }
125 if r.logEntryAllowed(e.Entry.URL) {
126 return
127 }
128 r.add(fmt.Sprintf("log.error: %s (%s)", e.Entry.Text, e.Entry.URL))
129 case *network.EventRequestWillBeSent:
130 r.mu.Lock()
131 r.requests[e.RequestID] = requestInfo{method: e.Request.Method, url: e.Request.URL}
132 r.mu.Unlock()
133 case *network.EventResponseReceived:
134 status := int(e.Response.Status)
135 if status < http.StatusBadRequest {
136 return
137 }
138 method := r.requestInfoFor(e.RequestID).method
139 if r.responseAllowed(method, e.Response.URL, status) {
140 return
141 }
142 r.add(fmt.Sprintf("HTTP %d %s %s", status, method, e.Response.URL))
143 case *network.EventLoadingFailed:
144 if e.Canceled {
145 // Navigating away cancels in-flight loads; routine,
146 // not a failure.
147 return
148 }
149 req := r.requestInfoFor(e.RequestID)
150 r.add(fmt.Sprintf("request failed: %s %s — %s", req.method, req.url, e.ErrorText))
151 }
152 })
153}
154