| 1 | // Run: tsx src/__tests__/remote-composer-profile-sync.test.tsx |
| 2 | // useRemoteComposerProfileSync adopts the backend profile when a remote tab |
| 3 | // rotates to another session, but a tab switch away and back is not a |
| 4 | // rotation: the tab's in-flight mode/goal choice (pending flags) must survive |
| 5 | // A -> B -> A, and forgotten tabs must not read as rotations when reopened. |
| 6 | import assert from "node:assert/strict"; |
| 7 | import React, { act, useState } from "react"; |
| 8 | import { createRoot } from "react-dom/client"; |
| 9 | import { JSDOM } from "jsdom"; |
| 10 | import { useRemoteComposerProfileSync } from "../lib/useRemoteComposerIntegration"; |
| 11 | import type { ComposerProfile, ComposerProfilesByTab } from "../lib/composerProfile"; |
| 12 | import type { RemoteSessionApi } from "../lib/useRemoteSession"; |
| 13 | |
| 14 | const dom = new JSDOM("<div id='root'></div>"); |
| 15 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 16 | |
| 17 | type RemoteProfile = NonNullable<RemoteSessionApi["composerProfile"]>; |
| 18 | const backend: RemoteProfile = { collaborationMode: "normal", toolApprovalMode: "ask", goal: "", qualityFloor: "standard" }; |
| 19 | const chosen = (tabId: string): ComposerProfile => ({ collaborationMode: "plan", goalDraftMode: false, toolApprovalMode: "ask", goal: "", qualityFloor: "standard", pending: { collaborationMode: true } }); |
| 20 | let profiles: ComposerProfilesByTab = {}; |
| 21 | let setProfilesRef: React.Dispatch<React.SetStateAction<ComposerProfilesByTab>> | undefined; |
| 22 | function Probe({ activeTabId, sessionRoute }: { activeTabId: string; sessionRoute: string }) { |
| 23 | const [state, setState] = useState<ComposerProfilesByTab>({ A: chosen("A"), B: chosen("B") }); |
| 24 | profiles = state; |
| 25 | setProfilesRef = setState; |
| 26 | const profile = state[activeTabId]; |
| 27 | useRemoteComposerProfileSync({ activeTabId, sessionRoute, remote: true, remoteProfile: backend, |
| 28 | collaborationMode: profile?.collaborationMode ?? "normal", toolApprovalMode: profile?.toolApprovalMode ?? "ask", |
| 29 | goal: profile?.goal ?? "", qualityFloor: profile?.qualityFloor ?? "standard", pending: profile?.pending ?? {}, setProfiles: setState }); |
| 30 | return null; |
| 31 | } |
| 32 | const root = createRoot(document.getElementById("root")!); |
| 33 | const show = (activeTabId: string, sessionRoute: string) => act(async () => { root.render(React.createElement(Probe, { activeTabId, sessionRoute })); }); |
| 34 | const edit = (update: (current: ComposerProfilesByTab) => ComposerProfilesByTab) => act(async () => { setProfilesRef?.(update); }); |
| 35 | |
| 36 | try { |
| 37 | await show("A", "session-id:a1"); |
| 38 | assert.equal(profiles.A.pending.collaborationMode, true, "first sync of a tab reconciles over its existing choice"); |
| 39 | assert.equal(profiles.A.collaborationMode, "plan"); |
| 40 | await show("B", "session-id:b1"); |
| 41 | assert.equal(profiles.B.pending.collaborationMode, true, "B's first sync keeps B's choice"); |
| 42 | await show("A", "session-id:a1"); |
| 43 | assert.equal(profiles.A.pending.collaborationMode, true, "A -> B -> A preserves A's pending choice"); |
| 44 | assert.equal(profiles.A.collaborationMode, "plan", "A -> B -> A keeps the chosen mode visible"); |
| 45 | assert.equal(profiles.B.pending.collaborationMode, true, "switching back to A leaves B untouched"); |
| 46 | await show("A", "session-id:a2"); |
| 47 | assert.deepEqual(profiles.A.pending, {}, "A's own session rotation adopts the backend profile"); |
| 48 | assert.equal(profiles.A.collaborationMode, "normal", "the rotated session shows the backend mode"); |
| 49 | assert.equal(profiles.B.pending.collaborationMode, true, "A's rotation does not touch B"); |
| 50 | // Closing B drops it from the profile table; the next sync (any status or |
| 51 | // route change on the active tab) forgets B's route, so a later tab with |
| 52 | // the same id on another session reads as a first sync, not a rotation. |
| 53 | await edit(({ B: _closed, ...rest }) => rest); |
| 54 | await show("A", "session-id:a3"); |
| 55 | await edit((current) => ({ ...current, B: chosen("B") })); |
| 56 | await show("B", "session-id:b2"); |
| 57 | assert.equal(profiles.B.pending.collaborationMode, true, "a tab id forgotten after its removal is not treated as a session rotation"); |
| 58 | await show("B", "session-id:b3"); |
| 59 | assert.deepEqual(profiles.B.pending, {}, "the re-registered tab still adopts the backend on its next real rotation"); |
| 60 | console.log("remote composer profile sync: per-tab route memory, A->B->A preservation, rotation adoption and pruning passed"); |
| 61 | } finally { |
| 62 | await act(async () => root.unmount()); |
| 63 | } |
| 64 |