| 1 | //go:build browser |
| 2 | |
| 3 | package harness |
| 4 | |
| 5 | import ( |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | |
| 9 | "github.com/chromedp/chromedp" |
| 10 | ) |
| 11 | |
| 12 | // junkValues is what a missing field or an unhandled shape looks like |
| 13 | // once it has been through String() — the bug class that renders |
| 14 | // perfectly and says nothing. Kass's full set. Substring "null" can |
| 15 | // false-positive on legitimate prose, which is why every hit carries |
| 16 | // its surrounding text and AllowText exists for the rare deliberate |
| 17 | // case. |
| 18 | var junkValues = []string{"undefined", "null", "[object Object]", "NaN"} |
| 19 | |
| 20 | // AllowText exempts one exact string from the junk scan — for the |
| 21 | // screen whose legitimate prose contains a junk substring ("null and |
| 22 | // void"). The allowance is the surrounding string, not the junk value: |
| 23 | // everything else on the screen is still scanned. |
| 24 | func (r *Rig) AllowText(s string) { |
| 25 | r.mu.Lock() |
| 26 | defer r.mu.Unlock() |
| 27 | r.allowedText = append(r.allowedText, s) |
| 28 | } |
| 29 | |
| 30 | // junkScanJS reads the screen the way a person would, plus the places |
| 31 | // a person cannot see: input and textarea values, and the labels a |
| 32 | // screen reader announces. The scan root is the screen's own selector |
| 33 | // ("body" is the whole-page case). Each hit carries its surrounding |
| 34 | // text so a prose false-positive is legible at a glance. |
| 35 | const junkScanJS = `((sel, junk, allowed) => { |
| 36 | const root = document.querySelector(sel); |
| 37 | if (!root) return ["the junk-scan root " + sel + " is not on the page"]; |
| 38 | const hay = [root.textContent ?? ""]; |
| 39 | for (const f of root.querySelectorAll("input, textarea")) hay.push(f.value ?? ""); |
| 40 | for (const n of root.querySelectorAll("[aria-label]")) hay.push(n.getAttribute("aria-label") ?? ""); |
| 41 | const hits = []; |
| 42 | for (let text of hay) { |
| 43 | for (const a of allowed) text = text.split(a).join(""); |
| 44 | for (const j of junk) { |
| 45 | const at = text.indexOf(j); |
| 46 | if (at < 0) continue; |
| 47 | const around = text.slice(Math.max(0, at - 40), at + j.length + 40).replace(/\s+/g, " ").trim(); |
| 48 | hits.push(j + ' in: "' + around + '"'); |
| 49 | } |
| 50 | } |
| 51 | return hits; |
| 52 | })` |
| 53 | |
| 54 | // junkHits runs the scan rooted at selector and returns what it found. |
| 55 | func (r *Rig) junkHits(selector string) []string { |
| 56 | r.t.Helper() |
| 57 | r.mu.Lock() |
| 58 | allowed := append([]string(nil), r.allowedText...) |
| 59 | r.mu.Unlock() |
| 60 | if allowed == nil { |
| 61 | allowed = []string{} // marshal as [], never null — JS iterates it |
| 62 | } |
| 63 | var args []string |
| 64 | for _, v := range []any{selector, junkValues, allowed} { |
| 65 | b, err := json.Marshal(v) |
| 66 | if err != nil { |
| 67 | r.t.Fatalf("harness: junk scan args: %v", err) |
| 68 | } |
| 69 | args = append(args, string(b)) |
| 70 | } |
| 71 | var hits []string |
| 72 | expr := fmt.Sprintf("%s(%s, %s, %s)", junkScanJS, args[0], args[1], args[2]) |
| 73 | if err := chromedp.Run(r.ctx, chromedp.Evaluate(expr, &hits)); err != nil { |
| 74 | r.t.Fatalf("harness: junk scan: %v", err) |
| 75 | } |
| 76 | return hits |
| 77 | } |
| 78 | |