| 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 |