1import { RegistryContext } from "@effect/atom-react";
2import {
3 BearerConnectionTarget,
4 PrimaryConnectionTarget,
5} from "@t3tools/client-runtime/connection";
6import {
7 AuthTerminalOperateScope,
8 DEFAULT_SERVER_SETTINGS,
9 EnvironmentId,
10 ProviderDriverKind,
11 ProviderInstanceId,
12 type AuthEnvironmentScope,
13 type AuthSessionState,
14 type ResolvedKeybindingsConfig,
15 type ServerProvider,
16 type ServerSettings,
17 type TerminalCloseInput,
18 type TerminalOpenInput,
19 type TerminalWriteInput,
20} from "@t3tools/contracts";
21import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
22import * as Cause from "effect/Cause";
23import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
24import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
25import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
26
27type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
28type CommandResult = AsyncResult.AsyncResult<void, Error>;
29type Request<Input> = { environmentId: EnvironmentId; input: Input };
30type SetupConfig = {
31 cwd: string;
32 settings: ServerSettings;
33 environment: { platform: { os: "darwin" } };
34 keybindings: ResolvedKeybindingsConfig;
35};
36const state = vi.hoisted(() => ({
37 registry: null as AtomRegistry.AtomRegistry | null,
38 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
39 providers: new Map<EnvironmentId, Atom.Writable<ReadonlyArray<ServerProvider>>>(),
40 configs: new Map<EnvironmentId, Atom.Atom<SetupConfig>>(),
41 open: vi.fn<(request: Request<TerminalOpenInput>) => Promise<CommandResult>>(),
42 write: vi.fn<(request: Request<TerminalWriteInput>) => Promise<CommandResult>>(),
43 close: vi.fn<(request: Request<TerminalCloseInput>) => Promise<CommandResult>>(),
44 refresh: vi.fn(),
45 pair: vi.fn(),
46 createProject: vi.fn(),
47 importThreads: vi.fn(),
48 complete: vi.fn<() => Promise<void>>(),
49 done: vi.fn(),
50}));
51
52vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null }));
53vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
54vi.mock("@t3tools/client-runtime/state/session", () => ({
55 createEnvironmentSessionAtoms: () => ({
56 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
57 }),
58}));
59vi.mock("../../rpc/atomRegistry", () => ({
60 get appAtomRegistry() {
61 return state.registry;
62 },
63}));
64vi.mock("../../state/environments", () => ({
65 useEnvironments: () => ({ environments: [remoteEnvironment] }),
66 usePrimaryEnvironment: () => primaryEnvironment,
67}));
68vi.mock("../../state/server", () => ({
69 serverEnvironment: {
70 providersValueAtom: (id: EnvironmentId) => state.providers.get(id)!,
71 configValueAtom: (id: EnvironmentId) => state.configs.get(id)!,
72 refreshProviders: "refresh",
73 },
74}));
75vi.mock("../../state/terminal", () => ({
76 terminalEnvironment: { open: "open", write: "write", close: "close" },
77}));
78vi.mock("../../state/use-atom-command", () => ({
79 useAtomCommand: (
80 command: "open" | "write" | "close" | "refresh" | "pair" | "createProject" | "importThreads",
81 ) => state[command],
82}));
83vi.mock("../../connection/onboarding", () => ({ connectPairing: "pair" }));
84vi.mock("../../state/agentSessions", () => ({
85 agentSessionImport: "importThreads",
86 agentSessionScan: vi.fn(),
87}));
88vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "createProject" } }));
89vi.mock("../../state/entities", () => ({ readProjects: () => [], useProjects: () => [] }));
90vi.mock("../../onboarding/useProjectScans", () => ({ useProjectScans: () => [] }));
91vi.mock("../ui/dialog", () => ({
92 Dialog: "div",
93 DialogPopup: "div",
94 DialogTitle: "div",
95 DialogHeader: "div",
96}));
97vi.mock("../ui/wizard", () => ({
98 WizardPanel: "div",
99 WizardPopup: "div",
100 WizardHeader: "div",
101 WizardSteps: ({ onStepChange }: { onStepChange: (step: number) => void }) => (
102 <button onClick={() => onStepChange(0)}>Back</button>
103 ),
104}));
105vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
106vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => state.complete }));
107vi.mock("../../hooks/useTheme", () => ({ mountOnboardingTheme: () => undefined }));
108vi.mock("../../hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, vi.fn()] }));
109vi.mock("../../hooks/useCopyToClipboard", () => ({
110 useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), isCopied: false }),
111}));
112vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
113vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
114vi.mock("@clerk/react", () => ({ useAuth: vi.fn() }));
115vi.mock("../../env", () => ({ isElectron: false }));
116vi.mock("../../providerInstances", () => ({ resolveDefaultProviderModelSelection: vi.fn() }));
117vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
118vi.mock("../settings/CodexSetupSection", () => ({
119 CodexSetupSection: () => null,
120 AddManagedCodexAccountDialog: () => null,
121}));
122vi.mock("../settings/providerDriverMeta", () => ({
123 providerClients: { get: (driver: string) => ({ label: driver }) },
124}));
125vi.mock("../settings/providerStatus", () => ({
126 getProviderSummary: () => ({ headline: "Setup required" }),
127}));
128vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
129 CloudEnvironmentConnectRows: () => null,
130}));
131vi.mock("../ThreadTerminalDrawer", () => ({
132 TerminalViewport: () => <div data-terminal-viewport />,
133}));
134vi.mock("../ui/button", () => ({ Button: "button" }));
135vi.mock("../ui/input", () => ({ Input: "input" }));
136vi.mock("../ui/checkbox", () => ({ Checkbox: "input" }));
137vi.mock("../ui/collapsible", () => ({
138 Collapsible: "div",
139 CollapsiblePanel: "div",
140 CollapsibleTrigger: "button",
141}));
142vi.mock("../ui/toast", () => ({
143 toastManager: { add: vi.fn(), update: vi.fn(), close: vi.fn() },
144}));
145
146import { WelcomeWizard } from "./WelcomeWizard";
147
148const primaryId = EnvironmentId.make("primary");
149const remoteId = EnvironmentId.make("paired-remote");
150const primaryEnvironment = {
151 environmentId: primaryId,
152 label: "This computer",
153 connection: { phase: "connected" },
154 entry: {
155 enabled: true,
156 target: new PrimaryConnectionTarget({
157 environmentId: primaryId,
158 label: "This computer",
159 httpBaseUrl: "http://127.0.0.1:3773",
160 wsBaseUrl: "ws://127.0.0.1:3773",
161 }),
162 },
163} as const;
164const remoteEnvironment = {
165 environmentId: remoteId,
166 label: "Paired computer",
167 connection: { phase: "connected" },
168 entry: {
169 enabled: true,
170 target: new BearerConnectionTarget({
171 environmentId: remoteId,
172 label: "Paired computer",
173 connectionId: "paired-remote",
174 }),
175 },
176} as const;
177const missingClaude: ServerProvider = {
178 instanceId: ProviderInstanceId.make("claude-work"),
179 driver: ProviderDriverKind.make("claudeAgent"),
180 enabled: true,
181 installed: false,
182 version: null,
183 status: "error",
184 auth: { status: "unknown" },
185 checkedAt: "2026-09-05T00:00:00.000Z",
186 models: [],
187 slashCommands: [],
188 skills: [],
189};
190const signedOutCodex: ServerProvider = {
191 ...missingClaude,
192 instanceId: ProviderInstanceId.make("codex-work"),
193 driver: ProviderDriverKind.make("codex"),
194 installed: true,
195 auth: { status: "unauthenticated" },
196};
197const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({
198 authenticated: true,
199 scopes,
200 auth: {
201 policy: "remote-reachable",
202 bootstrapMethods: ["one-time-token"],
203 sessionMethods: ["bearer-access-token"],
204 sessionCookieName: "t3_session",
205 },
206});
207function setAccess(environmentId: EnvironmentId, allowed: boolean) {
208 state.registry!.set(
209 state.sessions.get(environmentId)!,
210 AsyncResult.success(session(allowed ? [AuthTerminalOperateScope] : [])),
211 );
212}
213function deferred<T>() {
214 let resolve!: (value: T) => void;
215 const promise = new Promise<T>((complete) => {
216 resolve = complete;
217 });
218 return { promise, resolve };
219}
220function text(node: ReactTestInstance | string): string {
221 return typeof node === "string" ? node : node.children.map(text).join("");
222}
223let renderer: ReactTestRenderer | undefined;
224function button(label: string) {
225 return renderer!.root.findAllByType("button").find((node) => text(node) === label)!;
226}
227async function click(label: string) {
228 const node = button(label);
229 expect(node).toBeDefined();
230 expect(node.props.disabled).not.toBe(true);
231 await act(async () => node.props.onClick());
232}
233function hasViewport() {
234 return renderer!.root.findAllByProps({ "data-terminal-viewport": true }).length > 0;
235}
236async function enterRemoteAgents() {
237 await act(async () => {
238 renderer = create(
239 <RegistryContext.Provider value={state.registry!}>
240 <WelcomeWizard localAvailable onDone={state.done} />
241 </RegistryContext.Provider>,
242 );
243 });
244 await click("Continue");
245 expect(text(renderer!.root)).toContain("Paired computer");
246}
247
248beforeEach(() => {
249 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
250 vi.stubGlobal("document", { activeElement: null, body: {}, getElementById: () => null });
251 state.registry = AtomRegistry.make();
252 for (const id of [primaryId, remoteId]) {
253 state.sessions.set(
254 id,
255 Atom.make<SessionResult>(AsyncResult.success(session([AuthTerminalOperateScope]))).pipe(
256 Atom.keepAlive,
257 ),
258 );
259 state.providers.set(
260 id,
261 Atom.make<ReadonlyArray<ServerProvider>>([missingClaude, signedOutCodex]).pipe(
262 Atom.keepAlive,
263 ),
264 );
265 state.configs.set(
266 id,
267 Atom.make<SetupConfig>({
268 cwd: `/fixtures/${id}`,
269 settings: {
270 ...DEFAULT_SERVER_SETTINGS,
271 providerInstances: {
272 [signedOutCodex.instanceId]: {
273 driver: signedOutCodex.driver,
274 config: { binaryPath: "/opt/codex-work", setupMode: "existing" },
275 },
276 },
277 },
278 environment: { platform: { os: "darwin" } },
279 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
280 }),
281 );
282 }
283 state.open.mockReset().mockResolvedValue(AsyncResult.success(undefined));
284 state.write.mockReset().mockResolvedValue(AsyncResult.success(undefined));
285 state.close.mockReset().mockResolvedValue(AsyncResult.success(undefined));
286 state.refresh.mockReset().mockResolvedValue(AsyncResult.success(undefined));
287 state.pair.mockReset().mockResolvedValue(AsyncResult.success(remoteId));
288 state.complete.mockReset().mockResolvedValue(undefined);
289 state.done.mockReset();
290});
291
292afterEach(async () => {
293 await act(async () => renderer?.unmount());
294 renderer = undefined;
295 state.registry?.dispose();
296 state.sessions.clear();
297 state.providers.clear();
298 state.configs.clear();
299 vi.unstubAllGlobals();
300});
301
302describe("welcome agent terminal setup", () => {
303 it("disables both setup actions for the paired read-only connection and preserves local completion", async () => {
304 setAccess(remoteId, false);
305 await enterRemoteAgents();
306 expect(button("Install").props.disabled).toBe(true);
307 expect(button("Sign in").props.disabled).toBe(true);
308 expect(text(renderer!.root)).toContain("This connection cannot control terminals.");
309 await act(async () => {
310 button("Install").props.onClick();
311 button("Sign in").props.onClick();
312 });
313 expect(state.open).not.toHaveBeenCalled();
314 expect(state.write).not.toHaveBeenCalled();
315 expect(state.close).not.toHaveBeenCalled();
316 await click("Continue");
317 await click("Do not import projects");
318 expect(state.complete).toHaveBeenCalledOnce();
319 expect(state.done).toHaveBeenCalledExactlyOnceWith(undefined);
320 });
321
322 it("rejects a retained install action after revocation and allows a terminal-only regrant", async () => {
323 setAccess(primaryId, false);
324 await enterRemoteAgents();
325 const install = button("Install").props.onClick;
326 await act(async () => {
327 setAccess(remoteId, false);
328 install();
329 });
330 expect(state.open).not.toHaveBeenCalled();
331 expect(button("Install").props.disabled).toBe(true);
332 await act(async () => setAccess(remoteId, true));
333 expect(button("Install").props.disabled).toBe(false);
334 await act(async () => install());
335 expect(state.open).toHaveBeenCalledExactlyOnceWith({
336 environmentId: remoteId,
337 input: {
338 threadId: "onboarding-agent-setup",
339 terminalId: expect.any(String),
340 cwd: "/fixtures/paired-remote",
341 providerInstanceId: missingClaude.instanceId,
342 },
343 });
344 expect(state.write).toHaveBeenCalledExactlyOnceWith({
345 environmentId: remoteId,
346 input: {
347 threadId: "onboarding-agent-setup",
348 terminalId: state.open.mock.calls[0]![0].input.terminalId,
349 data: "curl -fsSL https://claude.ai/install.sh | bash",
350 },
351 });
352 expect(hasViewport()).toBe(true);
353 await click("Close");
354 expect(state.close).toHaveBeenCalledExactlyOnceWith({
355 environmentId: remoteId,
356 input: {
357 threadId: "onboarding-agent-setup",
358 terminalId: state.open.mock.calls[0]![0].input.terminalId,
359 deleteHistory: true,
360 },
361 });
362 });
363
364 it("pretypes the selected provider's sign-in command without executing it", async () => {
365 setAccess(primaryId, false);
366 await enterRemoteAgents();
367 await click("Sign in");
368 expect(state.open).toHaveBeenCalledExactlyOnceWith({
369 environmentId: remoteId,
370 input: {
371 threadId: "onboarding-agent-setup",
372 terminalId: expect.any(String),
373 cwd: "/fixtures/paired-remote",
374 providerInstanceId: signedOutCodex.instanceId,
375 },
376 });
377 expect(state.write).toHaveBeenCalledExactlyOnceWith({
378 environmentId: remoteId,
379 input: {
380 threadId: "onboarding-agent-setup",
381 terminalId: state.open.mock.calls[0]![0].input.terminalId,
382 data: "/opt/codex-work login",
383 },
384 });
385 expect(text(renderer!.root)).toContain("Review the command, then press Enter to run it.");
386 });
387
388 it("checks access when a queued retry starts and recovers after regrant", async () => {
389 state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Could not open"))));
390 await enterRemoteAgents();
391 await click("Install");
392 const closing = deferred<CommandResult>();
393 state.close.mockReturnValueOnce(closing.promise);
394 await click("Retry");
395 expect(state.close).toHaveBeenCalledOnce();
396 expect(state.open).toHaveBeenCalledOnce();
397 await act(async () => {
398 setAccess(remoteId, false);
399 closing.resolve(AsyncResult.success(undefined));
400 });
401 expect(state.open).toHaveBeenCalledOnce();
402 expect(button("Retry").props.disabled).toBe(true);
403 expect(state.write).not.toHaveBeenCalled();
404 await act(async () => setAccess(remoteId, true));
405 await click("Retry");
406 expect(state.open).toHaveBeenCalledTimes(2);
407 expect(state.write).toHaveBeenCalledOnce();
408 expect(hasViewport()).toBe(true);
409 });
410
411 it("keeps an accepted terminal visible without pretyping when access is revoked during open", async () => {
412 const opening = deferred<CommandResult>();
413 state.open.mockReturnValueOnce(opening.promise);
414 await enterRemoteAgents();
415 await click("Install");
416 await act(async () => {
417 setAccess(remoteId, false);
418 opening.resolve(AsyncResult.success(undefined));
419 });
420 expect(state.write).not.toHaveBeenCalled();
421 expect(state.close).not.toHaveBeenCalled();
422 expect(hasViewport()).toBe(true);
423 expect(text(renderer!.root)).toContain("This connection cannot control terminals.");
424 await act(async () => setAccess(remoteId, true));
425 expect(state.open).toHaveBeenCalledOnce();
426 expect(state.write).not.toHaveBeenCalled();
427 expect(text(renderer!.root)).toContain(
428 "Run curl -fsSL https://claude.ai/install.sh | bash in this",
429 );
430 await act(async () => setAccess(remoteId, false));
431 await click("Close");
432 expect(hasViewport()).toBe(false);
433 expect(state.close).not.toHaveBeenCalled();
434 await click("Continue");
435 expect(text(renderer!.root)).toContain("Import your projects");
436 });
437
438 it("settles accepted pretyping locally after revocation without closing the PTY", async () => {
439 const writing = deferred<CommandResult>();
440 state.write.mockReturnValueOnce(writing.promise);
441 await enterRemoteAgents();
442 await click("Sign in");
443 await act(async () => {
444 setAccess(remoteId, false);
445 writing.resolve(AsyncResult.success(undefined));
446 });
447 expect(state.open).toHaveBeenCalledOnce();
448 expect(state.write).toHaveBeenCalledOnce();
449 expect(hasViewport()).toBe(true);
450 await act(async () => setAccess(remoteId, true));
451 expect(text(renderer!.root)).toContain("Review the command, then press Enter to run it.");
452 expect(state.open).toHaveBeenCalledOnce();
453 expect(state.write).toHaveBeenCalledOnce();
454 await act(async () => setAccess(remoteId, false));
455 await click("Close");
456 expect(state.close).not.toHaveBeenCalled();
457 });
458
459 it("rechecks the original environment when cleanup runs after pending pretyping", async () => {
460 const writing = deferred<CommandResult>();
461 state.write.mockReturnValueOnce(writing.promise);
462 await enterRemoteAgents();
463 await click("Install");
464 await click("Back");
465 expect(state.close).not.toHaveBeenCalled();
466 await act(async () => {
467 setAccess(remoteId, false);
468 writing.resolve(AsyncResult.success(undefined));
469 });
470 expect(state.close).not.toHaveBeenCalled();
471 expect(text(renderer!.root)).toContain("Connect your computers");
472 });
473});