1import { act, type ReactElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { renderToStaticMarkup } from "react-dom/server";
4import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";
6import * as Schema from "effect/Schema";
7import { AsyncResult } from "effect/reactivity";
8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10const testState = vi.hoisted(() => ({
11 updateServer: vi.fn(),
12 toast: vi.fn(),
13 clipboard: vi.fn(),
14 continueThreadsAfterServerUpdate: false,
15 session: null as AsyncResult.AsyncResult<AuthSessionState, Error> | null,
16 sessionAtom: Symbol("session"),
17}));
18
19vi.mock("~/hooks/useCopyToClipboard", () => ({
20 useCopyToClipboard: (options: { onCopy: (context: { command: string }) => void }) => ({
21 copyToClipboard: (command: string, context: { command: string }) => {
22 testState.clipboard(command);
23 options.onCopy(context);
24 },
25 }),
26}));
27vi.mock("~/hooks/useSettings", () => ({
28 useEnvironmentSettings: (
29 _environmentId: EnvironmentId,
30 selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,
31 ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),
32}));
33vi.mock("@effect/atom-react", () => ({ useAtomValue: () => testState.session }));
34vi.mock("~/rpc/atomRegistry", () => ({
35 appAtomRegistry: { get: () => testState.session },
36}));
37vi.mock("~/state/session", () => ({
38 environmentSession: { sessionStateAtom: () => testState.sessionAtom },
39}));
40vi.mock("~/state/server", () => ({
41 serverEnvironment: { updateServer: Symbol("updateServer") },
42}));
43vi.mock("~/state/use-atom-command", () => ({
44 useAtomCommand: () => testState.updateServer,
45}));
46vi.mock("./ui/toast", () => ({
47 toastManager: { add: testState.toast },
48}));
49
50import {
51 readConfirmDialogState,
52 registerConfirmDialogHost,
53 resetConfirmDialogForTests,
54 respondToConfirmDialog,
55} from "~/confirmDialog";
56import {
57 ServerUpdateAction,
58 ServerUpdateProgress,
59 ServerUpdatesAction,
60 type ServerUpdateTarget,
61} from "./ServerUpdateAction";
62
63const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState);
64
65type ActionElement = ReactElement<{
66 readonly onClick?: () => void;
67}>;
68
69function renderAction(): ActionElement {
70 return ServerUpdateAction({
71 environmentId: "env-test" as EnvironmentId,
72 serverLabel: "Test server",
73 selfUpdate: "boot-service",
74 targetVersion: "0.0.31",
75 }) as ActionElement;
76}
77
78async function flushPromises(): Promise<void> {
79 await Promise.resolve();
80 await Promise.resolve();
81}
82
83const legacyAuth = {
84 policy: "remote-reachable",
85 bootstrapMethods: ["one-time-token"],
86 sessionMethods: ["bearer-access-token"],
87 sessionCookieName: "t3_session",
88} as const;
89
90const currentSession = {
91 authenticated: true,
92 scopes: ["environment:maintain"],
93 auth: {
94 ...legacyAuth,
95 serverUpdateScope: "environment:maintain",
96 },
97} as const satisfies AuthSessionState;
98
99describe("ServerUpdateAction", () => {
100 beforeEach(() => {
101 testState.updateServer.mockReset();
102 testState.toast.mockReset();
103 testState.clipboard.mockReset();
104 testState.continueThreadsAfterServerUpdate = false;
105 testState.session = AsyncResult.success(currentSession);
106 });
107
108 it.each([
109 { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true },
110 { serverUpdateScope: undefined, scopes: ["orchestration:read"], allowed: false },
111 {
112 serverUpdateScope: "environment:maintain",
113 scopes: ["orchestration:operate"],
114 allowed: false,
115 },
116 {
117 serverUpdateScope: "environment:maintain",
118 scopes: ["environment:maintain"],
119 allowed: true,
120 },
121 ] as const)(
122 "uses the advertised update scope $serverUpdateScope with grant $scopes",
123 async ({ serverUpdateScope, scopes, allowed }) => {
124 testState.session = AsyncResult.success(
125 decodeSessionState({
126 ...currentSession,
127 scopes,
128 auth: {
129 ...legacyAuth,
130 ...(serverUpdateScope === undefined ? {} : { serverUpdateScope }),
131 },
132 }),
133 );
134 testState.updateServer.mockResolvedValue(
135 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
136 );
137
138 renderAction().props.onClick?.();
139 await flushPromises();
140
141 expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0);
142 },
143 );
144
145 it("keeps a known grant usable while the session refreshes", async () => {
146 testState.session = AsyncResult.waiting(AsyncResult.success(currentSession));
147 testState.updateServer.mockResolvedValue(
148 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
149 );
150
151 renderAction().props.onClick?.();
152 await flushPromises();
153
154 expect(testState.updateServer).toHaveBeenCalledOnce();
155 });
156
157 it("does not dispatch an update after maintenance access is removed", async () => {
158 const action = renderAction();
159 testState.session = AsyncResult.success({ ...currentSession, scopes: [] });
160 action.props.onClick?.();
161 await flushPromises();
162 expect(testState.updateServer).not.toHaveBeenCalled();
163 });
164
165 it.each([
166 [
167 { kind: "npm-global", prefix: "/opt/node" },
168 "npm install --global --prefix '/opt/node' t3@0.0.45",
169 "Update command copied",
170 "then restart t3",
171 ],
172 [
173 { kind: "npx" },
174 "npx t3@0.0.45",
175 "Relaunch command copied",
176 "This does not update an installed t3 command.",
177 ],
178 [
179 undefined,
180 "npx t3@0.0.45",
181 "Relaunch command copied",
182 "This does not update an installed t3 command.",
183 ],
184 ] satisfies ReadonlyArray<readonly [ServerInstallation | undefined, string, string, string]>)(
185 "copies an honest manual command for %j without invoking remote update",
186 (installation, command, title, guidance) => {
187 const action = ServerUpdateAction({
188 environmentId: "env-test" as EnvironmentId,
189 serverLabel: "Test server",
190 selfUpdate: null,
191 installation,
192 targetVersion: "0.0.45",
193 }) as ActionElement;
194 action.props.onClick?.();
195 expect(testState.clipboard).toHaveBeenCalledWith(command);
196 expect(testState.toast).toHaveBeenCalledWith(
197 expect.objectContaining({
198 title,
199 description: expect.stringContaining(guidance),
200 }),
201 );
202 expect(testState.updateServer).not.toHaveBeenCalled();
203 },
204 );
205
206 it("reports success only after the shared update flow reconnects", async () => {
207 testState.updateServer.mockResolvedValue(
208 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
209 );
210
211 renderAction().props.onClick?.();
212 await flushPromises();
213
214 expect(testState.updateServer).toHaveBeenCalledWith({
215 environmentId: "env-test",
216 input: { targetVersion: "0.0.31" },
217 });
218 expect(testState.toast).toHaveBeenCalledWith({
219 type: "success",
220 title: "Test server updated",
221 description: "Reconnected on t3@0.0.31.",
222 });
223 });
224
225 it("reports one result when the update action is double-clicked", async () => {
226 let finishUpdate: (() => void) | undefined;
227 testState.updateServer.mockImplementation(
228 () =>
229 new Promise((resolve) => {
230 finishUpdate = () =>
231 resolve(
232 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
233 );
234 }),
235 );
236
237 const action = renderAction();
238 action.props.onClick?.();
239 action.props.onClick?.();
240
241 expect(testState.updateServer).toHaveBeenCalledTimes(1);
242 finishUpdate?.();
243 await flushPromises();
244 expect(testState.toast).toHaveBeenCalledTimes(1);
245 });
246
247 it("quietly releases the action when the operation is interrupted", async () => {
248 testState.updateServer.mockResolvedValue(AsyncResult.failure(Cause.interrupt()));
249
250 renderAction().props.onClick?.();
251 await flushPromises();
252
253 expect(testState.toast).not.toHaveBeenCalled();
254 });
255
256 it("keeps the manual instruction for desktop servers without remote update support", () => {
257 const markup = renderToStaticMarkup(
258 <ServerUpdateAction
259 environmentId={"env-test" as EnvironmentId}
260 serverLabel="Test server"
261 selfUpdate="desktop-managed"
262 targetVersion="0.0.31"
263 />,
264 );
265
266 expect(markup).toContain("Update the desktop app on that machine to update this server.");
267 expect(markup).not.toContain("<button");
268 });
269
270 it("updates remote desktop apps through the shared update flow", async () => {
271 testState.updateServer.mockResolvedValue(
272 AsyncResult.success({ targetVersion: "0.0.34", method: "desktop-app" as const }),
273 );
274
275 const action = ServerUpdateAction({
276 environmentId: "env-test" as EnvironmentId,
277 serverLabel: "Test server",
278 selfUpdate: "desktop-managed",
279 desktopAppUpdate: true,
280 targetVersion: "0.0.31",
281 }) as ActionElement;
282
283 // No confirm-dialog host is mounted in this test, which the component
284 // treats as consent: the click itself was the request.
285 action.props.onClick?.();
286 await flushPromises();
287
288 expect(testState.updateServer).toHaveBeenCalledWith({
289 environmentId: "env-test",
290 input: { targetVersion: "0.0.31" },
291 });
292 expect(testState.toast).toHaveBeenCalledWith({
293 type: "success",
294 title: "Test server updated",
295 description: "Desktop app relaunched on 0.0.34.",
296 });
297 });
298
299 it("leaves thread continuation off by default", async () => {
300 testState.updateServer.mockResolvedValue(
301 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
302 );
303 const action = ServerUpdateAction({
304 environmentId: "env-test" as EnvironmentId,
305 serverLabel: "Test server",
306 selfUpdate: "boot-service",
307 threadContinuation: true,
308 targetVersion: "0.0.31",
309 }) as ActionElement;
310
311 action.props.onClick?.();
312 await flushPromises();
313
314 expect(testState.updateServer).toHaveBeenCalledWith({
315 environmentId: "env-test",
316 input: { targetVersion: "0.0.31" },
317 });
318 });
319
320 it("applies the saved thread continuation preference automatically", async () => {
321 testState.updateServer.mockResolvedValue(
322 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
323 );
324 testState.continueThreadsAfterServerUpdate = true;
325 const action = ServerUpdateAction({
326 environmentId: "env-test" as EnvironmentId,
327 serverLabel: "Test server",
328 selfUpdate: "boot-service",
329 threadContinuation: true,
330 targetVersion: "0.0.31",
331 }) as ActionElement;
332
333 action.props.onClick?.();
334 await flushPromises();
335
336 expect(testState.updateServer).toHaveBeenCalledWith({
337 environmentId: "env-test",
338 input: { targetVersion: "0.0.31", continueRunningThreads: true },
339 });
340 });
341});
342
343describe("ServerUpdatesAction", () => {
344 let renderer: ReactTestRenderer | undefined;
345 const targets: ReadonlyArray<ServerUpdateTarget> = [
346 {
347 environmentId: "batch-a" as EnvironmentId,
348 serverLabel: "Laptop",
349 selfUpdate: "boot-service",
350 targetVersion: "0.0.31",
351 threadContinuation: true,
352 continueThreadsAfterServerUpdate: true,
353 },
354 {
355 environmentId: "batch-b" as EnvironmentId,
356 serverLabel: "Office",
357 selfUpdate: "respawn",
358 targetVersion: "0.0.31",
359 threadContinuation: true,
360 continueThreadsAfterServerUpdate: false,
361 },
362 {
363 environmentId: "batch-c" as EnvironmentId,
364 serverLabel: "Manual",
365 selfUpdate: null,
366 targetVersion: "0.0.31",
367 },
368 ];
369 const success = AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const });
370
371 async function mount(batch = targets) {
372 await act(async () => {
373 renderer = create(<ServerUpdatesAction targets={batch} />);
374 });
375 return renderer!.root.findByType("button");
376 }
377
378 beforeEach(() => {
379 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
380 testState.updateServer.mockReset();
381 testState.toast.mockReset();
382 resetConfirmDialogForTests();
383 });
384 afterEach(async () => {
385 await act(async () => {
386 renderer?.unmount();
387 });
388 renderer = undefined;
389 resetConfirmDialogForTests();
390 vi.unstubAllGlobals();
391 });
392
393 it("updates both supported machines with their own continuation preference and skips the manual machine", async () => {
394 testState.updateServer.mockResolvedValue(success);
395 const button = await mount();
396 await act(async () => {
397 button.props.onClick();
398 });
399
400 expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([
401 {
402 environmentId: "batch-a",
403 input: { targetVersion: "0.0.31", continueRunningThreads: true },
404 },
405 { environmentId: "batch-b", input: { targetVersion: "0.0.31" } },
406 ]);
407 expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([
408 "Laptop updated",
409 "Office updated",
410 ]);
411 });
412
413 it("names a failed machine while letting the other machine complete", async () => {
414 testState.updateServer
415 .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed"))))
416 .mockResolvedValueOnce(success);
417 const button = await mount();
418 await act(async () => {
419 button.props.onClick();
420 });
421
422 expect(testState.updateServer).toHaveBeenCalledTimes(2);
423 expect(testState.toast).toHaveBeenCalledWith({
424 type: "error",
425 title: "Laptop update failed",
426 description: "Download failed",
427 });
428 expect(testState.toast).toHaveBeenCalledWith(
429 expect.objectContaining({ type: "success", title: "Office updated" }),
430 );
431 expect(button.props.disabled).toBe(false);
432 });
433
434 it("starts each machine once when double-clicked and disables the action until both finish", async () => {
435 const completions: Array<() => void> = [];
436 testState.updateServer.mockImplementation(
437 () =>
438 new Promise((resolve) => {
439 completions.push(() => resolve(success));
440 }),
441 );
442 const button = await mount();
443 await act(async () => {
444 button.props.onClick();
445 button.props.onClick();
446 });
447 expect(testState.updateServer).toHaveBeenCalledTimes(2);
448 expect(button.props.disabled).toBe(true);
449 await act(async () => {
450 completions[0]!();
451 });
452 expect(button.props.disabled).toBe(true);
453 await act(async () => {
454 completions[1]!();
455 });
456 expect(button.props.disabled).toBe(false);
457 expect(testState.toast).toHaveBeenCalledTimes(2);
458 });
459
460 it("asks once for desktop machines and cancels the entire batch", async () => {
461 registerConfirmDialogHost();
462 const button = await mount(
463 targets.map((target, index) =>
464 index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target,
465 ),
466 );
467 await act(async () => {
468 button.props.onClick();
469 });
470 const confirmation = readConfirmDialogState();
471 expect(confirmation).toEqual(
472 expect.objectContaining({
473 status: "confirming",
474 message: expect.stringContaining("Laptop, Office"),
475 }),
476 );
477 expect(testState.updateServer).not.toHaveBeenCalled();
478 await act(async () => {
479 respondToConfirmDialog(false);
480 });
481 expect(testState.updateServer).not.toHaveBeenCalled();
482 expect(button.props.disabled).toBe(false);
483 });
484});
485
486describe("ServerUpdateProgress", () => {
487 it("shows one calm status row for the restart wait", () => {
488 const markup = renderToStaticMarkup(
489 <ServerUpdateProgress
490 state={{
491 status: "running",
492 stage: "resuming",
493 fromVersion: "0.0.30",
494 targetVersion: "0.0.31",
495 }}
496 />,
497 );
498
499 expect(markup).toContain("Restarting…");
500 // The wait state is monochrome and calm: no versions, no step rail, no
501 // success/warning colors, and a static dot that never repaints.
502 expect(markup).not.toContain("0.0.30");
503 expect(markup).not.toContain("Resum");
504 expect(markup).not.toContain("text-success");
505 expect(markup).not.toContain("text-primary");
506 expect(markup).not.toContain("animate-");
507 });
508
509 it("folds the sub-second installing handoff into the download phase", () => {
510 const markup = renderToStaticMarkup(
511 <ServerUpdateProgress
512 state={{
513 status: "running",
514 stage: "installing",
515 fromVersion: "0.0.30",
516 targetVersion: "0.0.31",
517 }}
518 />,
519 );
520
521 expect(markup).toContain("Downloading…");
522 expect(markup).not.toContain("Install");
523 });
524
525 it("keeps the failure visible with its retryable error", () => {
526 const markup = renderToStaticMarkup(
527 <ServerUpdateProgress
528 state={{
529 status: "failed",
530 stage: "installing",
531 fromVersion: "0.0.30",
532 targetVersion: "0.0.31",
533 message: "The package could not be verified.",
534 }}
535 />,
536 );
537
538 expect(markup).toContain('role="alert"');
539 expect(markup).toContain("The package could not be verified.");
540 expect(markup).not.toContain("animate-status-pulse");
541 });
542});