返回 DeepSeek-Reasonix
browser_executor_test.go
根目录 / desktop / browser_executor_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "sync"
8 "testing"
9
10 "reasonix/desktop/internal/browserops"
11 "reasonix/internal/browser"
12 "reasonix/internal/extension/rpcwire"
13 )
14
15 type fakeBrowserHost struct {
16 mu sync.Mutex
17 calls []string
18 replies map[string]any
19 errs map[string]error
20 }
21
22 func (f *fakeBrowserHost) Request(_ context.Context, method string, params any, result any) error {
23 f.mu.Lock()
24 f.calls = append(f.calls, method)
25 reply, hasReply := f.replies[method]
26 err := f.errs[method]
27 f.mu.Unlock()
28 if err != nil {
29 return err
30 }
31 if result == nil || !hasReply {
32 return nil
33 }
34 raw, _ := json.Marshal(reply)
35 return json.Unmarshal(raw, result)
36 }
37
38 func (f *fakeBrowserHost) methods() []string {
39 f.mu.Lock()
40 defer f.mu.Unlock()
41 return append([]string(nil), f.calls...)
42 }
43
44 func newBrowserExecutorForTest(t *testing.T, host *fakeBrowserHost) (*App, *hostBrowserExecutor) {
45 t.Helper()
46 isolateDesktopUserDirs(t)
47 a := NewApp()
48 a.hostShell = &hostShellBridge{app: a}
49 exec := &hostBrowserExecutor{app: a, host: host, tabID: "tab-1", grantID: "grant-tab-1"}
50 return a, exec
51 }
52
53 func TestBrowserExecutorGrantsOnceAndCarriesGrantID(t *testing.T) {
54 host := &fakeBrowserHost{replies: map[string]any{"host/browser.tabs.list": map[string]any{"tabs": []map[string]any{{"id": "b1", "url": "https://example.test", "title": "Example"}}}}}
55 _, exec := newBrowserExecutorForTest(t, host)
56 for range 2 {
57 tabs, err := exec.Tabs(context.Background())
58 if err != nil || len(tabs) != 1 || tabs[0].ID != "b1" {
59 t.Fatalf("tabs: %+v err=%v", tabs, err)
60 }
61 }
62 got := host.methods()
63 want := []string{"host/browser.grant", "host/browser.tabs.list", "host/browser.tabs.list"}
64 if len(got) != len(want) {
65 t.Fatalf("calls %v, want %v", got, want)
66 }
67 for i := range want {
68 if got[i] != want[i] {
69 t.Fatalf("calls %v, want %v", got, want)
70 }
71 }
72 }
73
74 func TestBrowserExecutorActSettlesFromReceipt(t *testing.T) {
75 host := &fakeBrowserHost{replies: map[string]any{"host/browser.act": map[string]any{"executed": true, "documentToken": "doc-2"}}}
76 a, exec := newBrowserExecutorForTest(t, host)
77 req := browser.ActRequest{OperationID: "op-1", TabID: "b1", DocumentToken: "doc-1", Action: browser.ActionClick, Ref: "e3"}
78 res, err := exec.Act(context.Background(), req)
79 if err != nil || !res.Executed || res.Outcome != browser.OutcomeExecuted || res.DocumentToken != "doc-2" {
80 t.Fatalf("act: %+v err=%v", res, err)
81 }
82 ledger, err := a.browserLedger()
83 if err != nil {
84 t.Fatal(err)
85 }
86 op, ok := ledger.Lookup("op-1")
87 if !ok || op.State != browserops.StateExecuted || op.TabID != "b1" || op.Digest == "" {
88 t.Fatalf("ledger after executed act: %+v ok=%v", op, ok)
89 }
90
91 res, err = exec.Act(context.Background(), req)
92 if !errors.Is(err, browser.ErrUnknownOutcome) || res.Executed || res.Outcome != browser.OutcomeUnknown {
93 t.Fatalf("duplicate operation must not reach the shell: %+v err=%v", res, err)
94 }
95 if calls := host.methods(); len(calls) != 2 {
96 t.Fatalf("shell calls %v, want grant + one act", calls)
97 }
98 }
99
100 func TestBrowserExecutorActNotExecutedAndStale(t *testing.T) {
101 host := &fakeBrowserHost{replies: map[string]any{"host/browser.act": map[string]any{"executed": false, "reason": "element missing"}}}
102 a, exec := newBrowserExecutorForTest(t, host)
103 res, err := exec.Act(context.Background(), browser.ActRequest{OperationID: "op-a", TabID: "b1", DocumentToken: "d", Action: browser.ActionClick, Ref: "e1"})
104 if err != nil || res.Executed || res.Reason != "element missing" {
105 t.Fatalf("not executed: %+v err=%v", res, err)
106 }
107 host.errs = map[string]error{"host/browser.act": &rpcwire.ResponseError{Code: hostBrowserErrStaleReference, Message: "stale"}}
108 _, err = exec.Act(context.Background(), browser.ActRequest{OperationID: "op-b", TabID: "b1", DocumentToken: "d", Action: browser.ActionClick, Ref: "e1"})
109 if !errors.Is(err, browser.ErrStaleReference) {
110 t.Fatalf("stale reference error: %v", err)
111 }
112 ledger, _ := a.browserLedger()
113 for _, id := range []string{"op-a", "op-b"} {
114 if op, _ := ledger.Lookup(id); op.State != browserops.StateNotExecuted {
115 t.Fatalf("%s state %q, want not_executed", id, op.State)
116 }
117 }
118 }
119
120 func TestBrowserExecutorLostReceiptIsUnknown(t *testing.T) {
121 host := &fakeBrowserHost{errs: map[string]error{"host/browser.act": errors.New("connection closed")}}
122 a, exec := newBrowserExecutorForTest(t, host)
123 res, err := exec.Act(context.Background(), browser.ActRequest{OperationID: "op-x", TabID: "b1", DocumentToken: "d", Action: browser.ActionType, Ref: "e1", Text: "hi"})
124 if !errors.Is(err, browser.ErrUnknownOutcome) || res.Outcome != browser.OutcomeUnknown {
125 t.Fatalf("lost receipt: %+v err=%v", res, err)
126 }
127 ledger, _ := a.browserLedger()
128 unknown := ledger.Unsettled()
129 if len(unknown) != 1 || unknown[0].ID != "op-x" {
130 t.Fatalf("unsettled: %+v", unknown)
131 }
132 if _, err := exec.Act(context.Background(), browser.ActRequest{OperationID: "op-x", TabID: "b1", DocumentToken: "d", Action: browser.ActionType, Ref: "e1", Text: "hi"}); !errors.Is(err, browser.ErrUnknownOutcome) {
133 t.Fatalf("a repeated unknown id must preserve the no-retry outcome: %v", err)
134 }
135 if calls := host.methods(); len(calls) != 2 {
136 t.Fatalf("an unknown operation must never be replayed: %v", calls)
137 }
138 }
139
140 func TestBrowserExecutorPartialReceiptPersistsUnknown(t *testing.T) {
141 host := &fakeBrowserHost{replies: map[string]any{"host/browser.act": map[string]any{"executed": false, "outcome": "unknown", "reason": "takeover after focus click"}}}
142 a, exec := newBrowserExecutorForTest(t, host)
143 res, err := exec.Act(context.Background(), browser.ActRequest{OperationID: "partial", TabID: "b1", DocumentToken: "d", Action: browser.ActionType})
144 if !errors.Is(err, browser.ErrUnknownOutcome) || res.Outcome != browser.OutcomeUnknown {
145 t.Fatalf("partial receipt: %+v %v", res, err)
146 }
147 ledger, _ := a.browserLedger()
148 if op, _ := ledger.Lookup("partial"); op.State != browserops.StateUnknown {
149 t.Fatalf("partial action settled as %s", op.State)
150 }
151 }
152
153 func TestBrowserExecutorMissingReceiptCannotMeanNotExecuted(t *testing.T) {
154 host := &fakeBrowserHost{replies: map[string]any{"host/browser.act": map[string]any{}}}
155 a, exec := newBrowserExecutorForTest(t, host)
156 _, err := exec.Act(context.Background(), browser.ActRequest{OperationID: "missing", TabID: "b1", Action: browser.ActionClick})
157 if !errors.Is(err, browser.ErrUnknownOutcome) {
158 t.Fatalf("missing receipt: %v", err)
159 }
160 ledger, _ := a.browserLedger()
161 if op, _ := ledger.Lookup("missing"); op.State != browserops.StateUnknown {
162 t.Fatalf("missing receipt settled as %s", op.State)
163 }
164 }
165
166 func TestBrowserExecutorEveryTabWriteReservesBeforeDispatch(t *testing.T) {
167 for _, action := range []string{"open", "navigate", "close"} {
168 t.Run(action, func(t *testing.T) {
169 host := &fakeBrowserHost{errs: map[string]error{"host/browser.tabs." + action: errors.New("receipt lost")}}
170 a, exec := newBrowserExecutorForTest(t, host)
171 call := func() error {
172 switch action {
173 case "open":
174 _, err := exec.Open(context.Background(), browser.OpenRequest{OperationID: "op", URL: "https://example.test"})
175 return err
176 case "navigate":
177 _, err := exec.Navigate(context.Background(), browser.NavigateRequest{OperationID: "op", TabID: "b1", Action: browser.NavigateBack})
178 return err
179 default:
180 return exec.Close(context.Background(), browser.CloseRequest{OperationID: "op", TabID: "b1"})
181 }
182 }
183 for range 2 {
184 if err := call(); !errors.Is(err, browser.ErrUnknownOutcome) {
185 t.Fatalf("lost/duplicate receipt: %v", err)
186 }
187 }
188 if len(host.methods()) != 2 {
189 t.Fatalf("write replayed: %v", host.methods())
190 }
191 ledger, _ := a.browserLedger()
192 if op, _ := ledger.Lookup("op"); op.State != browserops.StateUnknown || op.Action != action {
193 t.Fatalf("ledger: %+v", op)
194 }
195 })
196 }
197 }
198
199 func TestBrowserExecutorRevokedFailsClosed(t *testing.T) {
200 host := &fakeBrowserHost{}
201 a, exec := newBrowserExecutorForTest(t, host)
202 a.browserExecutors = map[string]*hostBrowserExecutor{"tab-1": exec}
203 a.forgetBrowserExecutorLocked("tab-1")
204 if exec.Available(context.Background()) {
205 t.Fatal("revoked executor must report unavailable")
206 }
207 if _, err := exec.Tabs(context.Background()); !errors.Is(err, browser.ErrNoGrant) {
208 t.Fatalf("revoked executor call: %v", err)
209 }
210 if a.browserExecutorForTab(&WorkspaceTab{ID: "tab-2"}) == nil {
211 t.Fatal("host mode must hand out executors")
212 }
213 a.hostShell = nil
214 if a.browserExecutorForTab(&WorkspaceTab{ID: "tab-3"}) != nil {
215 t.Fatal("without the shell no executor may be registered")
216 }
217 }
218
218 lines GO