| 1 | package cdp |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "reasonix/internal/browser" |
| 13 | ) |
| 14 | |
| 15 | // livePage exercises the snapshot walker and every input path the executor |
| 16 | // dispatches: a labelled text box, a submit button that reports what it got, |
| 17 | // a select, and a link the walker must name. |
| 18 | const livePage = `<!doctype html> |
| 19 | <html><body> |
| 20 | <h1>Live fixture</h1> |
| 21 | <form id="f" onsubmit="event.preventDefault(); document.getElementById('out').textContent = 'submitted:' + q.value + ':' + pick.value;"> |
| 22 | <label for="q">Query</label> |
| 23 | <input id="q" name="q" type="text" placeholder="Search"> |
| 24 | <select id="pick" name="pick"><option value="one">One</option><option value="two">Two</option></select> |
| 25 | <button type="submit">Run search</button> |
| 26 | </form> |
| 27 | <input id="secret" type="password" value="hunter2"> |
| 28 | <a href="https://example.test/docs">Docs</a> |
| 29 | <div id="out"></div> |
| 30 | <div id="hidden" style="display:none"><button>Never</button></div> |
| 31 | </body></html>` |
| 32 | |
| 33 | // TestLiveChrome drives a real browser. It stays skipped in normal CI because |
| 34 | // it needs a Chrome install; it is the only test that runs the injected |
| 35 | // isolated-world helper against a real DOM. |
| 36 | // |
| 37 | // Run with: |
| 38 | // |
| 39 | // REASONIX_LIVE_CHROME=1 go test ./internal/browser/cdp \ |
| 40 | // -run '^TestLiveChrome$' -v -count=1 -timeout=3m |
| 41 | func TestLiveChrome(t *testing.T) { |
| 42 | if os.Getenv("REASONIX_LIVE_CHROME") != "1" { |
| 43 | t.Skip("set REASONIX_LIVE_CHROME=1 to run the real Chrome end-to-end test") |
| 44 | } |
| 45 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 46 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 47 | _, _ = w.Write([]byte(livePage)) |
| 48 | })) |
| 49 | defer srv.Close() |
| 50 | |
| 51 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) |
| 52 | defer cancel() |
| 53 | exec, err := New(ctx, Options{Headless: true, ArtifactDir: t.TempDir(), NavigateTimeout: 30 * time.Second}) |
| 54 | if err != nil { |
| 55 | t.Fatalf("launch chrome: %v", err) |
| 56 | } |
| 57 | defer exec.Shutdown() |
| 58 | |
| 59 | tab, err := exec.Open(ctx, browser.OpenRequest{OperationID: "live-open", URL: srv.URL}) |
| 60 | if err != nil { |
| 61 | t.Fatalf("open: %v", err) |
| 62 | } |
| 63 | snap, err := exec.Snapshot(ctx, browser.SnapshotRequest{TabID: tab.ID}) |
| 64 | if err != nil { |
| 65 | t.Fatalf("snapshot: %v", err) |
| 66 | } |
| 67 | t.Logf("snapshot refs=%d\n%s", snap.Refs, snap.Tree) |
| 68 | for _, want := range []string{`textbox "Query"`, `button "Run search"`, `link "Docs"`, "combobox"} { |
| 69 | if !strings.Contains(snap.Tree, want) { |
| 70 | t.Errorf("snapshot tree is missing %s:\n%s", want, snap.Tree) |
| 71 | } |
| 72 | } |
| 73 | if strings.Contains(snap.Tree, "hunter2") { |
| 74 | t.Errorf("the snapshot leaked a password field's value:\n%s", snap.Tree) |
| 75 | } |
| 76 | if strings.Contains(snap.Tree, `"Never"`) { |
| 77 | t.Errorf("the snapshot walked a display:none subtree:\n%s", snap.Tree) |
| 78 | } |
| 79 | |
| 80 | queryRef := refFor(t, snap.Tree, `textbox "Query"`) |
| 81 | pickRef := refFor(t, snap.Tree, "combobox") |
| 82 | buttonRef := refFor(t, snap.Tree, `button "Run search"`) |
| 83 | |
| 84 | act(t, ctx, exec, browser.ActRequest{ |
| 85 | OperationID: "live-type", TabID: tab.ID, DocumentToken: snap.DocumentToken, |
| 86 | Action: browser.ActionType, Ref: queryRef, Text: "reasonix", |
| 87 | }) |
| 88 | act(t, ctx, exec, browser.ActRequest{ |
| 89 | OperationID: "live-select", TabID: tab.ID, DocumentToken: snap.DocumentToken, |
| 90 | Action: browser.ActionSelect, Ref: pickRef, Options: []string{"Two"}, |
| 91 | }) |
| 92 | act(t, ctx, exec, browser.ActRequest{ |
| 93 | OperationID: "live-click", TabID: tab.ID, DocumentToken: snap.DocumentToken, |
| 94 | Action: browser.ActionClick, Ref: buttonRef, |
| 95 | }) |
| 96 | |
| 97 | after, err := exec.Snapshot(ctx, browser.SnapshotRequest{TabID: tab.ID, Selector: "#out"}) |
| 98 | if err != nil { |
| 99 | t.Fatalf("snapshot after acting: %v", err) |
| 100 | } |
| 101 | if !strings.Contains(after.Tree, "submitted:reasonix:two") { |
| 102 | t.Fatalf("the page did not observe the typed text, the selection, and the click:\n%s", after.Tree) |
| 103 | } |
| 104 | if after.DocumentToken == snap.DocumentToken { |
| 105 | t.Fatal("a second snapshot reused the first token") |
| 106 | } |
| 107 | // The retired token must not be usable even though the document never left. |
| 108 | if _, err := exec.Act(ctx, browser.ActRequest{ |
| 109 | OperationID: "live-stale", TabID: tab.ID, DocumentToken: snap.DocumentToken, |
| 110 | Action: browser.ActionClick, Ref: buttonRef, |
| 111 | }); err == nil { |
| 112 | t.Fatal("a write against the retired token was accepted") |
| 113 | } |
| 114 | |
| 115 | shot, err := exec.Screenshot(ctx, browser.ScreenshotRequest{TabID: tab.ID, FullPage: true}) |
| 116 | if err != nil { |
| 117 | t.Fatalf("screenshot: %v", err) |
| 118 | } |
| 119 | if shot.Width == 0 || shot.Height == 0 { |
| 120 | t.Fatalf("screenshot has no size: %+v", shot) |
| 121 | } |
| 122 | if err := exec.Close(ctx, browser.CloseRequest{OperationID: "live-close", TabID: tab.ID}); err != nil { |
| 123 | t.Fatalf("close: %v", err) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func act(t *testing.T, ctx context.Context, exec *Executor, req browser.ActRequest) { |
| 128 | t.Helper() |
| 129 | res, err := exec.Act(ctx, req) |
| 130 | if err != nil { |
| 131 | t.Fatalf("%s %s: %v", req.Action, req.Ref, err) |
| 132 | } |
| 133 | if !res.Executed { |
| 134 | t.Fatalf("%s %s was not executed: %s", req.Action, req.Ref, res.Reason) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // refFor returns the ref of the first snapshot line containing want. |
| 139 | func refFor(t *testing.T, tree, want string) string { |
| 140 | t.Helper() |
| 141 | for line := range strings.SplitSeq(tree, "\n") { |
| 142 | if !strings.Contains(line, want) { |
| 143 | continue |
| 144 | } |
| 145 | _, rest, ok := strings.Cut(line, "[ref=") |
| 146 | if !ok { |
| 147 | continue |
| 148 | } |
| 149 | if ref, _, ok := strings.Cut(rest, "]"); ok { |
| 150 | return ref |
| 151 | } |
| 152 | } |
| 153 | t.Fatalf("no ref for %q in:\n%s", want, tree) |
| 154 | return "" |
| 155 | } |
| 156 |