返回 DeepSeek-Reasonix
executor_test.go
根目录 / internal / browser / cdp / executor_test.go
1 package cdp
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/browser"
13 )
14
15 // openTab opens one tab and returns it with a fresh snapshot's token.
16 func openTab(t *testing.T, exec *Executor, ctx context.Context) (browser.Tab, browser.Snapshot) {
17 t.Helper()
18 tab, err := exec.Open(ctx, browser.OpenRequest{OperationID: "op-open", URL: "https://example.test/"})
19 if err != nil {
20 t.Fatalf("open: %v", err)
21 }
22 snap, err := exec.Snapshot(ctx, browser.SnapshotRequest{TabID: tab.ID})
23 if err != nil {
24 t.Fatalf("snapshot: %v", err)
25 }
26 if snap.DocumentToken == "" {
27 t.Fatal("snapshot returned no documentToken")
28 }
29 return tab, snap
30 }
31
32 func clickRequest(tab browser.Tab, token, op, ref string) browser.ActRequest {
33 return browser.ActRequest{OperationID: op, TabID: tab.ID, DocumentToken: token, Action: browser.ActionClick, Ref: ref}
34 }
35
36 func TestReusedOperationIDIsRefusedAndNeverDispatched(t *testing.T) {
37 f := newFakeBrowser(t)
38 exec := newTestExecutor(t, f)
39 ctx := context.Background()
40 tab, snap := openTab(t, exec, ctx)
41
42 if _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1")); err != nil {
43 t.Fatalf("first click: %v", err)
44 }
45 dispatched := f.countCalls("Input.dispatchMouseEvent")
46 if dispatched == 0 {
47 t.Fatal("first click dispatched no mouse events")
48 }
49 _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1"))
50 if err == nil || !strings.Contains(err.Error(), "already used") {
51 t.Fatalf("replayed operationId: got %v, want an already-used refusal", err)
52 }
53 if got := f.countCalls("Input.dispatchMouseEvent"); got != dispatched {
54 t.Fatalf("replayed operationId dispatched %d more events", got-dispatched)
55 }
56 }
57
58 func TestWriteAgainstAnotherDocumentTokenIsStale(t *testing.T) {
59 f := newFakeBrowser(t)
60 exec := newTestExecutor(t, f)
61 ctx := context.Background()
62 tab, _ := openTab(t, exec, ctx)
63
64 _, err := exec.Act(ctx, clickRequest(tab, "d-somethingelse", "op-click", "e1"))
65 if !errors.Is(err, browser.ErrStaleReference) {
66 t.Fatalf("foreign token: got %v, want ErrStaleReference", err)
67 }
68 if f.countCalls("Input.dispatchMouseEvent") != 0 {
69 t.Fatal("a stale write reached the page")
70 }
71 }
72
73 func TestNavigationRetiresTheDocumentToken(t *testing.T) {
74 f := newFakeBrowser(t)
75 exec := newTestExecutor(t, f)
76 ctx := context.Background()
77 tab, snap := openTab(t, exec, ctx)
78
79 if _, err := exec.Navigate(ctx, browser.NavigateRequest{OperationID: "op-nav", TabID: tab.ID, Action: browser.NavigateURL, URL: "https://example.test/next"}); err != nil {
80 t.Fatalf("navigate: %v", err)
81 }
82 _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1"))
83 if !errors.Is(err, browser.ErrStaleReference) {
84 t.Fatalf("write after navigation: got %v, want ErrStaleReference", err)
85 }
86 }
87
88 func TestTakeOverBlocksWritesUntilTheNextSnapshot(t *testing.T) {
89 f := newFakeBrowser(t)
90 exec := newTestExecutor(t, f)
91 ctx := context.Background()
92 tab, snap := openTab(t, exec, ctx)
93
94 f.takeOver()
95 _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1"))
96 if !errors.Is(err, browser.ErrTakenOver) {
97 t.Fatalf("write after take-over: got %v, want ErrTakenOver", err)
98 }
99 if f.countCalls("Input.dispatchMouseEvent") != 0 {
100 t.Fatal("a write reached a page the user had taken over")
101 }
102 // The take-over is sticky: only re-reading the page clears it.
103 if _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click-2", "e1")); !errors.Is(err, browser.ErrTakenOver) {
104 t.Fatalf("second write after take-over: got %v, want ErrTakenOver", err)
105 }
106 fresh, err := exec.Snapshot(ctx, browser.SnapshotRequest{TabID: tab.ID})
107 if err != nil {
108 t.Fatalf("snapshot after take-over: %v", err)
109 }
110 if fresh.DocumentToken == snap.DocumentToken {
111 t.Fatal("snapshot after take-over reused the retired token")
112 }
113 if _, err := exec.Act(ctx, clickRequest(tab, fresh.DocumentToken, "op-click-3", "e1")); err != nil {
114 t.Fatalf("write after re-reading the page: %v", err)
115 }
116 }
117
118 func TestTabsAreInvisibleToAnotherSession(t *testing.T) {
119 f := newFakeBrowser(t)
120 exec := newTestExecutor(t, f)
121 owner := browser.WithSession(context.Background(), "session-a")
122 other := browser.WithSession(context.Background(), "session-b")
123 tab, snap := openTab(t, exec, owner)
124
125 tabs, err := exec.Tabs(other)
126 if err != nil {
127 t.Fatalf("tabs: %v", err)
128 }
129 if len(tabs) != 0 {
130 t.Fatalf("another session sees %d tab(s)", len(tabs))
131 }
132 if _, err := exec.Act(other, clickRequest(tab, snap.DocumentToken, "op-click", "e1")); !errors.Is(err, browser.ErrNoGrant) {
133 t.Fatalf("cross-session write: got %v, want ErrNoGrant", err)
134 }
135 tabs, err = exec.Tabs(owner)
136 if err != nil || len(tabs) != 1 {
137 t.Fatalf("owner tabs: %v %v", tabs, err)
138 }
139 }
140
141 func TestFailureAfterTheFirstEventIsUnknown(t *testing.T) {
142 f := newFakeBrowser(t)
143 exec := newTestExecutor(t, f)
144 ctx := context.Background()
145 tab, snap := openTab(t, exec, ctx)
146
147 seen := 0
148 f.setHandler("Input.dispatchMouseEvent", func(json.RawMessage) (any, *protocolError) {
149 seen++
150 if seen > 1 {
151 return nil, &protocolError{Code: -32000, Message: "target closed"}
152 }
153 return map[string]any{}, nil
154 })
155 res, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1"))
156 if !errors.Is(err, browser.ErrUnknownOutcome) {
157 t.Fatalf("failure after the first event: got %v, want ErrUnknownOutcome", err)
158 }
159 if res.Outcome != browser.OutcomeUnknown {
160 t.Fatalf("outcome = %q, want %q", res.Outcome, browser.OutcomeUnknown)
161 }
162 }
163
164 func TestRefusalOnTheFirstEventIsNotExecuted(t *testing.T) {
165 f := newFakeBrowser(t)
166 exec := newTestExecutor(t, f)
167 ctx := context.Background()
168 tab, snap := openTab(t, exec, ctx)
169
170 f.setHandler("Input.dispatchMouseEvent", func(json.RawMessage) (any, *protocolError) {
171 return nil, &protocolError{Code: -32000, Message: "Input events are disabled"}
172 })
173 res, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1"))
174 if err != nil {
175 t.Fatalf("refused click returned an error: %v", err)
176 }
177 if res.Executed || res.Outcome != browser.OutcomeNotExecuted {
178 t.Fatalf("outcome = %+v, want a not_executed refusal", res)
179 }
180 if !strings.Contains(res.Reason, "Input events are disabled") {
181 t.Fatalf("reason = %q, want the browser's refusal", res.Reason)
182 }
183 }
184
185 func TestRefThatLeftTheDocumentIsStale(t *testing.T) {
186 f := newFakeBrowser(t)
187 exec := newTestExecutor(t, f)
188 ctx := context.Background()
189 tab, snap := openTab(t, exec, ctx)
190
191 _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e404"))
192 if !errors.Is(err, browser.ErrStaleReference) {
193 t.Fatalf("unknown ref: got %v, want ErrStaleReference", err)
194 }
195 }
196
197 func TestTypeSendsOneKeyPairPerRuneAndSubmits(t *testing.T) {
198 f := newFakeBrowser(t)
199 exec := newTestExecutor(t, f)
200 ctx := context.Background()
201 tab, snap := openTab(t, exec, ctx)
202
203 req := browser.ActRequest{
204 OperationID: "op-type", TabID: tab.ID, DocumentToken: snap.DocumentToken,
205 Action: browser.ActionType, Ref: "e1", Text: "hi", Submit: true,
206 }
207 if _, err := exec.Act(ctx, req); err != nil {
208 t.Fatalf("type: %v", err)
209 }
210 // Two runes and one Enter, each a key-down and a key-up.
211 if got := f.countCalls("Input.dispatchKeyEvent"); got != 6 {
212 t.Fatalf("dispatched %d key events, want 6", got)
213 }
214 var last struct {
215 Key string `json:"key"`
216 }
217 if err := json.Unmarshal(f.lastArgs("Input.dispatchKeyEvent"), &last); err != nil {
218 t.Fatalf("decode last key event: %v", err)
219 }
220 if last.Key != "Enter" {
221 t.Fatalf("last key = %q, want Enter", last.Key)
222 }
223 }
224
225 func TestScreenshotWritesATaskOwnedFile(t *testing.T) {
226 f := newFakeBrowser(t)
227 exec := newTestExecutor(t, f)
228 ctx := context.Background()
229 tab, _ := openTab(t, exec, ctx)
230
231 shot, err := exec.Screenshot(ctx, browser.ScreenshotRequest{TabID: tab.ID})
232 if err != nil {
233 t.Fatalf("screenshot: %v", err)
234 }
235 if shot.Width != 4 || shot.Height != 3 {
236 t.Fatalf("screenshot is %dx%d, want 4x3", shot.Width, shot.Height)
237 }
238 if filepath.Dir(shot.Path) != filepath.Join(exec.artifacts, "screenshots") {
239 t.Fatalf("screenshot landed at %s, outside the task's artifact directory", shot.Path)
240 }
241 if _, err := os.Stat(shot.Path); err != nil {
242 t.Fatalf("screenshot file: %v", err)
243 }
244 }
245
246 func TestDownloadsAreNamedAndListedPerTab(t *testing.T) {
247 f := newFakeBrowser(t)
248 exec := newTestExecutor(t, f)
249 ctx := context.Background()
250 tab, _ := openTab(t, exec, ctx)
251
252 dir := filepath.Join(exec.artifacts, "downloads")
253 if err := os.WriteFile(filepath.Join(dir, "guid-1"), []byte("id,name\n"), 0o600); err != nil {
254 t.Fatalf("stage download: %v", err)
255 }
256 f.emit("", "Browser.downloadWillBegin", map[string]any{
257 "frameId": "frame-1", "guid": "guid-1", "url": "https://example.test/report.csv", "suggestedFilename": "report.csv",
258 })
259 f.emit("", "Browser.downloadProgress", map[string]any{
260 "guid": "guid-1", "state": "completed", "receivedBytes": 8, "totalBytes": 8,
261 })
262 // One more round trip on the same socket: its reply cannot arrive before
263 // the events above were read and dispatched.
264 if _, err := exec.Tabs(ctx); err != nil {
265 t.Fatalf("tabs: %v", err)
266 }
267 list, err := exec.Downloads(ctx, browser.DownloadsRequest{TabID: tab.ID})
268 if err != nil {
269 t.Fatalf("downloads: %v", err)
270 }
271 if len(list) != 1 {
272 t.Fatalf("listed %d downloads, want 1", len(list))
273 }
274 if got := filepath.Base(list[0].Path); got != "report.csv" {
275 t.Fatalf("download saved as %q, want report.csv", got)
276 }
277 if list[0].State != "completed" || list[0].Bytes != 8 {
278 t.Fatalf("download = %+v, want a completed 8-byte entry", list[0])
279 }
280 }
281
282 func TestClosedExecutorFailsClosed(t *testing.T) {
283 f := newFakeBrowser(t)
284 exec := newTestExecutor(t, f)
285 ctx := context.Background()
286 tab, snap := openTab(t, exec, ctx)
287
288 exec.Shutdown()
289 if exec.Available(ctx) {
290 t.Fatal("a closed executor still reports itself available")
291 }
292 if _, err := exec.Act(ctx, clickRequest(tab, snap.DocumentToken, "op-click", "e1")); !errors.Is(err, browser.ErrNoGrant) {
293 t.Fatalf("write after shutdown: got %v, want ErrNoGrant", err)
294 }
295 }
296
296 lines GO