返回 DeepSeek-Reasonix
remote-store.test.ts
根目录 / desktop / frontend / src / __tests__ / remote-store.test.ts
1 // Run: tsx src/__tests__/remote-store.test.ts
2 //
3 // Tests for the remote store's status/fingerprint reconciliation and the
4 // bridge mock's remote:* event fan-out.
5
6 import { RemoteConnectionTimeoutError, useRemoteStore, waitForRemoteConnection } from "../store/remote";
7 import { onRemoteStatus, __emitMockRemote } from "../lib/bridge";
8 import { isRemoteHostKeyMismatch, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors";
9 import type { RemoteConnectionStatus } from "../lib/types";
10
11 let passed = 0;
12 let failed = 0;
13
14 function eq(a: unknown, b: unknown, label: string) {
15 if (JSON.stringify(a) === JSON.stringify(b)) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
20 failed += 1;
21 }
22 }
23
24 function reset() {
25 useRemoteStore.setState({ hosts: [], statuses: {}, pendingFingerprint: null, pendingSecretPrompt: null, statusPopoverRequest: null });
26 }
27
28 useRemoteStore.getState().setHosts([
29 { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false },
30 ]);
31 eq(useRemoteStore.getState().hosts[0]?.defaultWorkspace, "/srv/app", "configured hosts hydrate persistent UI state");
32
33 // pending_hostkey status sets the pending fingerprint that drives the dialog.
34 reset();
35 useRemoteStore.getState().applyStatus({
36 hostId: "box",
37 state: "pending_hostkey",
38 fingerprint: { hostId: "box", address: "1.2.3.4:22", keyType: "ssh-ed25519", sha256: "AAAA" },
39 });
40 eq(useRemoteStore.getState().pendingFingerprint?.sha256, "AAAA", "pending_hostkey sets fingerprint");
41
42 // A stale dialog completion must not clear a newer fingerprint.
43 const oldFingerprint = useRemoteStore.getState().pendingFingerprint!;
44 useRemoteStore.getState().applyStatus({
45 hostId: "other",
46 state: "pending_hostkey",
47 fingerprint: { hostId: "other", address: "2.3.4.5:22", keyType: "ssh-ed25519", sha256: "BBBB" },
48 });
49 useRemoteStore.getState().clearPendingFingerprint(oldFingerprint);
50 eq(useRemoteStore.getState().pendingFingerprint?.sha256, "BBBB", "stale dialog cannot clear newer fingerprint");
51
52 // A subsequent non-pending status for the same host clears the fingerprint.
53 useRemoteStore.getState().applyStatus({ hostId: "other", state: "connected" });
54 eq(useRemoteStore.getState().pendingFingerprint, null, "resolution clears fingerprint");
55 eq(useRemoteStore.getState().statuses["other"]?.state, "connected", "status recorded");
56
57 // Interactive credentials expose metadata to the UI, never the secret value.
58 reset();
59 useRemoteStore.getState().applyStatus({
60 hostId: "box",
61 state: "pending_secret",
62 secretPrompt: { promptId: "prompt-1", hostId: "box", host: "dev@box.test", kind: "password" },
63 });
64 eq(useRemoteStore.getState().pendingSecretPrompt?.kind, "password", "pending_secret opens the one-shot credential dialog");
65 eq(JSON.stringify(useRemoteStore.getState().statuses.box).includes("secret-value"), false, "status contains no credential plaintext");
66 const oldSecretPrompt = useRemoteStore.getState().pendingSecretPrompt!;
67 useRemoteStore.getState().applyStatus({
68 hostId: "other",
69 state: "pending_secret",
70 secretPrompt: { promptId: "prompt-2", hostId: "other", host: "other.test", kind: "passphrase" },
71 });
72 useRemoteStore.getState().clearPendingSecretPrompt(oldSecretPrompt);
73 eq(useRemoteStore.getState().pendingSecretPrompt?.hostId, "other", "stale credential dialog cannot clear a newer prompt");
74 const firstIdentityPrompt = {
75 promptId: "prompt-3", hostId: "other", host: "other.test", kind: "passphrase" as const, identity: "id_first",
76 };
77 useRemoteStore.getState().applyStatus({ hostId: "other", state: "pending_secret", secretPrompt: firstIdentityPrompt });
78 useRemoteStore.getState().applyStatus({
79 hostId: "other",
80 state: "pending_secret",
81 secretPrompt: { promptId: "prompt-4", hostId: "other", host: "other.test", kind: "passphrase", identity: "id_second" },
82 });
83 useRemoteStore.getState().clearPendingSecretPrompt(firstIdentityPrompt);
84 eq(useRemoteStore.getState().pendingSecretPrompt?.identity, "id_second", "one key prompt cannot clear the next key prompt");
85 const oldSameMetadataPrompt = useRemoteStore.getState().pendingSecretPrompt!;
86 useRemoteStore.getState().applyStatus({
87 hostId: "other",
88 state: "pending_secret",
89 secretPrompt: { promptId: "prompt-5", hostId: "other", host: "other.test", kind: "passphrase", identity: "id_second" },
90 });
91 useRemoteStore.getState().clearPendingSecretPrompt(oldSameMetadataPrompt);
92 eq(useRemoteStore.getState().pendingSecretPrompt?.promptId, "prompt-5", "opaque prompt ID protects sequential prompts with identical metadata");
93 useRemoteStore.getState().applyStatus({ hostId: "other", state: "connecting" });
94 eq(useRemoteStore.getState().pendingSecretPrompt, null, "credential resolution clears the prompt");
95
96 // setStatuses replaces the whole map (mount hydration).
97 useRemoteStore.getState().setStatuses([
98 { hostId: "a", state: "connected" },
99 { hostId: "b", state: "reconnecting", attempt: 2 },
100 ]);
101 eq(Object.keys(useRemoteStore.getState().statuses).sort(), ["a", "b"], "setStatuses hydrates");
102 eq(useRemoteStore.getState().statuses["b"]?.attempt, 2, "attempt preserved");
103
104 // Late hydration fills missing hosts without overwriting a newer live event.
105 useRemoteStore.getState().applyStatus({ hostId: "live", state: "connected" });
106 useRemoteStore.getState().hydrateStatuses([
107 { hostId: "live", state: "connecting" },
108 { hostId: "snapshot-only", state: "connected" },
109 ]);
110 eq(useRemoteStore.getState().statuses["live"]?.state, "connected", "hydration preserves newer live status");
111 eq(useRemoteStore.getState().statuses["snapshot-only"]?.state, "connected", "hydration fills missing status");
112
113 useRemoteStore.getState().requestStatusPopover("box");
114 const firstReveal = useRemoteStore.getState().statusPopoverRequest!;
115 eq(firstReveal.hostId, "box", "connection failures can request the anchored status popover");
116 useRemoteStore.getState().requestStatusPopover("box");
117 const secondReveal = useRemoteStore.getState().statusPopoverRequest!;
118 eq(secondReveal.nonce > firstReveal.nonce, true, "repeated failures create a fresh popover request");
119 useRemoteStore.getState().clearStatusPopoverRequest(firstReveal);
120 eq(useRemoteStore.getState().statusPopoverRequest?.nonce, secondReveal.nonce, "stale popover completion cannot clear a newer request");
121 useRemoteStore.getState().clearStatusPopoverRequest(secondReveal);
122 eq(useRemoteStore.getState().statusPopoverRequest, null, "matching popover request is consumed");
123
124 const mismatchStatus: RemoteConnectionStatus = {
125 hostId: "box",
126 state: "stopped",
127 error: "raw path-bearing backend error",
128 errorDetails: {
129 code: "host_key_mismatch",
130 presentedSha256: "SHA256:new",
131 knownHostRecords: [{ path: "/home/dev/.ssh/known_hosts", line: 7 }],
132 },
133 };
134 eq(isRemoteHostKeyMismatch(mismatchStatus), true, "structured mismatch is recognized without parsing raw error text");
135 eq(remoteConnectionErrorSummaryKey(mismatchStatus), "remote.error.summary.host_key_mismatch", "mismatch uses the localized safe summary");
136 eq(
137 remoteConnectionErrorSummaryKey({ hostId: "legacy", state: "degraded", error: "forward attach failed" }),
138 "remote.error.summary.degraded",
139 "legacy degraded status falls back to the warning summary",
140 );
141
142 const connectionReady = waitForRemoteConnection("waiting", 1_000);
143 useRemoteStore.getState().applyStatus({ hostId: "waiting", state: "connected" });
144 await connectionReady;
145 eq(useRemoteStore.getState().statuses["waiting"]?.state, "connected", "connection waiter resolves on live connected status");
146
147 useRemoteStore.getState().applyStatus({ hostId: "failed", state: "stopped", error: "handshake failed" });
148 let failedConnection = "";
149 try {
150 await waitForRemoteConnection("failed", 1_000);
151 } catch (err) {
152 failedConnection = err instanceof Error ? err.message : String(err);
153 }
154 eq(failedConnection, "handshake failed", "connection waiter rejects the host error without waiting for timeout");
155
156 useRemoteStore.getState().applyStatus({ hostId: "timeout", state: "connecting" });
157 let timeoutError: unknown;
158 try {
159 await waitForRemoteConnection("timeout", 1);
160 } catch (err) {
161 timeoutError = err;
162 }
163 eq(timeoutError instanceof RemoteConnectionTimeoutError, true, "connection waiter exposes a typed timeout for recovery UI");
164 eq(useRemoteStore.getState().statuses.timeout?.state, "connecting", "connection timeout does not forge a backend terminal state");
165
166 // The bridge mock fan-out delivers remote:status to subscribers.
167 (function testMockFanout() {
168 if (typeof window === "undefined") {
169 (globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
170 }
171 let received: RemoteConnectionStatus | null = null;
172 const off = onRemoteStatus((s) => {
173 received = s;
174 });
175 __emitMockRemote("status", { hostId: "z", state: "connected" });
176 eq(received !== null && (received as RemoteConnectionStatus).hostId, "z", "mock fan-out delivers status");
177 off();
178 received = null;
179 __emitMockRemote("status", { hostId: "y", state: "connected" });
180 eq(received, null, "unsubscribe stops delivery");
181 })();
182
183 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
184 if (failed > 0) process.exit(1);
185
185 lines TYPESCRIPT