返回 DeepSeek-Reasonix
remote-resident-cache-paint.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-resident-cache-paint.test.tsx
1 // Switching back to a remote session whose transcript is already resident must
2 // paint it immediately and reconcile in the background — the local session
3 // switch experience — instead of paying the network round trips behind a
4 // loading placeholder every time.
5 import React, { act } from "react";
6 import { JSDOM } from "jsdom";
7 import type { AppBindings } from "../lib/bridge";
8 import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
9 import type { RemoteSessionApi } from "../lib/useRemoteSession";
10 import { installDesktopHostStub } from "./desktopHostStub";
11
12 let passed = 0, failed = 0;
13 function ok(value: boolean, label: string) {
14 process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
15 if (value) passed += 1; else failed += 1;
16 }
17 console.log("\nRemote session resident-cache paint");
18 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
19 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20 globalThis.window = dom.window as unknown as Window & typeof globalThis;
21 globalThis.document = dom.window.document;
22 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
23 globalThis.localStorage = dom.window.localStorage;
24 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
25 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
26
27 type FollowMode = "ok" | "deferred";
28 const followMode = new Map<string, FollowMode>();
29 const deferred = new Map<string, (response: TranscriptFollowResponse) => void>();
30 const answerFor = new Map<string, string>();
31 const statusFor = () => ({ running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: false });
32 const inline = (id: string, content: string) => ({ messageId: id, position: 0, version: 1, role: "assistant", eventSequence: 1, visibleTurn: 1, preview: "", inline: { id, role: "assistant", content } });
33 const followResponse = (tabId: string): TranscriptFollowResponse => ({
34 protocolVersion: 2, subscription: `sub-${tabId}`, changes: [], resetRequired: false,
35 snapshot: {
36 protocolVersion: 1, snapshotId: `cut-${tabId}`, identity: { sessionId: tabId, runtimeEpoch: "epoch", rewriteEpoch: 0, headId: "" },
37 projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4, records: [], activeRecords: [], activeAttempts: [],
38 runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false,
39 },
40 history: { status: "ready", snapshotSequence: 4, coverageSequence: 4, generation: "gen", totalTurns: 1, hasOlder: false, hasNewer: false, messages: [inline(`answer-${tabId}`, answerFor.get(tabId) ?? "follower answer")] },
41 });
42 installDesktopHostStub({
43 async RegisterNavigationIntent() {},
44 async SetActiveTab() {},
45 async ForkTargetsRemoteTab() { return { targets: [], verifiable: false }; },
46 async RemoteTabStatus() { return statusFor(); },
47 async RemoteTabMetadata() { return { status: statusFor() }; },
48 async RemoteTabSnapshot() { return { history: [], status: statusFor() }; },
49 async RemoteTranscriptFollowForTab(tabId: string, request: FollowRequest) {
50 if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false };
51 if (request.subscription) return new Promise<TranscriptFollowResponse>(() => {});
52 if ((followMode.get(tabId) ?? "ok") === "deferred") {
53 return new Promise<TranscriptFollowResponse>((resolve) => { deferred.set(tabId, resolve); });
54 }
55 return followResponse(tabId);
56 },
57 async RemoteSessionHistoryWindowForTab() { return new Promise(() => {}); },
58 } as Partial<AppBindings> as AppBindings);
59
60 const [{ createRoot }, { useRemoteSession }, { getTranscriptStore }, { setTranscriptBindingIdentity }] = await Promise.all([
61 import("react-dom/client"), import("../lib/useRemoteSession"), import("../lib/transcriptStore"), import("../lib/canonicalTranscriptBackend"),
62 ]);
63 setTranscriptBindingIdentity(() => "remote");
64
65 async function flush(ticks = 8) {
66 for (let i = 0; i < ticks; i++) await Promise.resolve();
67 await new Promise((resolve) => setTimeout(resolve, 30));
68 }
69 let probe: RemoteSessionApi | undefined;
70 function Probe({ tabId, sessionPath }: { tabId: string; sessionPath: string }) {
71 probe = useRemoteSession(tabId, "ready", sessionPath);
72 return null;
73 }
74 const root = createRoot(document.getElementById("root")!);
75 const mount = (tabId: string, sessionPath: string) => act(async () => {
76 root.render(<Probe tabId={tabId} sessionPath={sessionPath} />);
77 await flush();
78 // The follower runtime is dynamically imported on first use.
79 for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 40));
80 await flush();
81 });
82 const texts = () => (probe?.transcript.items ?? []).flatMap((item) => item.kind === "assistant" ? [item.text] : []);
83
84 // 1. Visit session A so its transcript becomes resident, then switch away.
85 followMode.set("tab-switch", "ok");
86 answerFor.set("tab-switch", "session A answer");
87 await mount("tab-switch", "session-a");
88 // The mount effect's hydrate depends on the follower runtime's dynamic import,
89 // which settles unreliably in JSDOM (the repo's prime suite is red on HEAD for
90 // the same reason). Drive it explicitly to reach the state under test.
91 await act(async () => { await probe?.retryHydration(); await flush(); });
92 ok(probe?.hydrated === true && texts().includes("session A answer"), "session A hydrates and becomes resident");
93
94 // 2. Switching to a session with no resident cut keeps the network path.
95 followMode.set("tab-switch", "deferred");
96 await mount("tab-switch", "session-b");
97 ok(probe?.hydrated === false && probe?.revalidating === false, "an uncached session still waits for its hydrate");
98
99 // 3. Switching back paints the resident cut with no network wait.
100 await mount("tab-switch", "session-a");
101 ok(texts().includes("session A answer"), "the resident session is visible immediately on switch-back");
102 ok(probe?.hydrated === true && probe?.revalidating === true, "the painted surface reports revalidating while the hydrate runs");
103 ok(getTranscriptStore().peek("tab-switch", "session-a") !== undefined, "the paint reads the session-keyed resident cut");
104
105 // 4. The background hydrate supersedes the paint.
106 answerFor.set("tab-switch", "session A revalidated");
107 await act(async () => { void probe?.retryHydration(); await flush(); });
108 await act(async () => { deferred.get("tab-switch")?.(followResponse("tab-switch")); await flush(); });
109 ok(texts().includes("session A revalidated") && !texts().includes("session A answer"), "the authoritative cut replaces the painted one");
110 ok(probe?.revalidating === false && probe?.hydrated === true, "revalidating clears once the hydrate settles");
111
112 await act(async () => { root.unmount(); });
113 dom.window.close();
114 console.log(`\n${passed} passed, ${failed} failed`);
115 process.exit(failed === 0 ? 0 : 1);
116
116 lines Plain Text