| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "io" |
| 7 | "net/http" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func TestRemoteResumeFailurePathsPublishOneRestoredSnapshot(t *testing.T) { |
| 13 | for _, kind := range []string{"http", "busy", "listing", "notfound", "transport"} { |
| 14 | t.Run(kind, func(t *testing.T) { |
| 15 | isolateDesktopUserDirs(t) |
| 16 | const oldPath, targetPath = "/sessions/old.jsonl", "/sessions/target.jsonl" |
| 17 | client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 18 | code, body := http.StatusConflict, "rejected" |
| 19 | if kind == "busy" { |
| 20 | body = "while a turn is running" |
| 21 | } |
| 22 | if kind == "listing" { |
| 23 | code = http.StatusInternalServerError |
| 24 | } |
| 25 | if kind == "notfound" { |
| 26 | code, body = http.StatusOK, `[]` |
| 27 | } |
| 28 | if kind == "transport" { |
| 29 | if req.URL.Path == "/resume" { |
| 30 | return nil, errors.New("response lost") |
| 31 | } |
| 32 | code, body = http.StatusOK, `[{"name":"old","path":"/sessions/old.jsonl","title":"Old","current":true}]` |
| 33 | } |
| 34 | return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil |
| 35 | })} |
| 36 | tab := &remoteTab{id: "remote-1", state: "ready", client: client, base: "http://fixture.invalid", gen: 7, selectionRevision: 9, |
| 37 | session: remoteTabSessionState{name: "target", path: targetPath}, topicTitle: "Target", |
| 38 | routing: remoteTabSessionRouting{currentPath: targetPath, pathRevision: 11, running: map[string]bool{}}, |
| 39 | } |
| 40 | oldPending := json.RawMessage(`{"kind":"approval_request","callId":"old"}`) |
| 41 | previous := &remoteTabOpenSelection{session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old", currentPath: oldPath, revision: 9, |
| 42 | pending: map[string]json.RawMessage{"old": oldPending}, runtime: remoteTabRuntimeState{running: true, cancellable: true, revision: 3}, |
| 43 | } |
| 44 | a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} |
| 45 | failures := 0 |
| 46 | a.remoteEventHook = func(_ string, payload any) { |
| 47 | state, ok := payload.(RemoteTabStateView) |
| 48 | if !ok || state.Error == "" { |
| 49 | return |
| 50 | } |
| 51 | failures++ |
| 52 | a.remoteTabMu.Lock() |
| 53 | defer a.remoteTabMu.Unlock() |
| 54 | if tab.session.name != "old" || tab.session.path != oldPath || tab.routing.currentPath != oldPath || tab.topicTitle != "Old" || |
| 55 | !tab.runtime.running || !tab.runtime.cancellable || string(tab.pendingEvents["old"]) != string(oldPending) || tab.err != state.Error { |
| 56 | t.Errorf("failure exposed a partial identity/runtime/prompt restore: session=%+v route=%q title=%q runtime=%+v error=%q", tab.session, tab.routing.currentPath, tab.topicTitle, tab.runtime, tab.err) |
| 57 | } |
| 58 | } |
| 59 | path := targetPath |
| 60 | if kind == "listing" || kind == "notfound" { |
| 61 | path = "" |
| 62 | } |
| 63 | a.resumeRemoteTabSessionPathForOpenSelection(tab.id, "target", path, "Target", 9, previous) |
| 64 | if failures != 1 { |
| 65 | t.Fatalf("failure publications = %d, want 1", failures) |
| 66 | } |
| 67 | }) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestRemoteRejectedResumePreservesProbedAuthoritativeSelection(t *testing.T) { |
| 72 | isolateDesktopUserDirs(t) |
| 73 | const previousPath = "/sessions/previous.jsonl" |
| 74 | const targetPath = "/sessions/target.jsonl" |
| 75 | const authoritativePath = "/sessions/authoritative.jsonl" |
| 76 | client := &http.Client{} |
| 77 | tab := &remoteTab{ |
| 78 | id: "remote-1", state: "ready", client: client, gen: 7, selectionRevision: 11, |
| 79 | session: remoteTabSessionState{name: "previous", path: previousPath}, topicTitle: "Previous", |
| 80 | routing: remoteTabSessionRouting{currentPath: previousPath, running: map[string]bool{}}, |
| 81 | } |
| 82 | a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} |
| 83 | previous := &remoteTabOpenSelection{ |
| 84 | session: tab.session, topicTitle: tab.topicTitle, currentPath: previousPath, revision: tab.selectionRevision, |
| 85 | } |
| 86 | route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, tab.gen, targetPath) |
| 87 | route.previousSelection = previous |
| 88 | handled := a.reconcileRemoteTabRejectedResume( |
| 89 | tab.id, tab, client, tab.gen, route, |
| 90 | serveSessionEntry{Name: "authoritative", Path: authoritativePath, Title: "Authoritative"}, |
| 91 | errors.New("resume response lost"), |
| 92 | ) |
| 93 | if !handled { |
| 94 | t.Fatal("authoritative reconciliation requested stale rollback") |
| 95 | } |
| 96 | a.remoteTabMu.Lock() |
| 97 | gotPath, gotSession, gotTitle := tab.routing.currentPath, tab.session.path, tab.topicTitle |
| 98 | a.remoteTabMu.Unlock() |
| 99 | if gotPath != authoritativePath || gotSession != authoritativePath || gotTitle != "Authoritative" { |
| 100 | t.Fatalf("ambiguous resume restored stale selection: route/session/title = %q/%q/%q", gotPath, gotSession, gotTitle) |
| 101 | } |
| 102 | } |
| 103 |