1import {
2 AuthOrchestrationOperateScope,
3 AuthTerminalOperateScope,
4 EnvironmentId,
5 ThreadId,
6 type AuthEnvironmentScope,
7} from "@t3tools/contracts";
8import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
9import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal";
10import { AsyncResult } from "effect/reactivity";
11import { act, create, type ReactTestRenderer } from "react-test-renderer";
12import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
13import type { TerminalSurface, TerminalSurfaceOptions } from "~/terminal/TerminalSurface";
14
15const state = vi.hoisted(() => ({
16 allowed: true,
17 editorAllowed: true,
18 listeners: new Set<() => void>(),
19 resize: vi.fn(),
20 otherCommand: vi.fn(),
21 createSurface:
22 vi.fn<(mount: HTMLElement, options: TerminalSurfaceOptions) => Promise<TerminalSurface>>(),
23}));
24
25vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
26vi.mock("../hooks/useSettings", () => ({
27 getClientSettings: () => DEFAULT_CLIENT_SETTINGS,
28 useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
29 select(DEFAULT_CLIENT_SETTINGS),
30}));
31vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => state.otherCommand }));
32vi.mock("../localApi", () => ({ readLocalApi: () => null }));
33vi.mock("../state/server", () => ({
34 serverEnvironment: { configValueAtom: () => "config" },
35}));
36vi.mock("../state/preview", () => ({ previewEnvironment: { open: "open" } }));
37vi.mock("../state/terminal", () => ({ terminalEnvironment: { resize: "resize", write: "write" } }));
38vi.mock("../state/use-atom-command", () => ({
39 useAtomCommand: (command: string) => (command === "resize" ? state.resize : state.otherCommand),
40}));
41vi.mock("../state/terminalSessions", () => ({
42 useAttachedTerminalSession: () => session,
43}));
44vi.mock("~/terminal/TerminalSurface", () => ({
45 TerminalSurface: { create: state.createSurface },
46}));
47vi.mock("../state/session", async () => {
48 const { useSyncExternalStore } = await import("react");
49 const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => {
50 const granted =
51 scope === AuthTerminalOperateScope
52 ? state.allowed
53 : scope === AuthOrchestrationOperateScope
54 ? state.editorAllowed
55 : true;
56 return id !== null && (id !== threadRef.environmentId || granted);
57 };
58 return {
59 readEnvironmentScope,
60 useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
61 useSyncExternalStore(
62 (listener) => {
63 state.listeners.add(listener);
64 return () => state.listeners.delete(listener);
65 },
66 () => readEnvironmentScope(id, scope),
67 ),
68 };
69});
70
71import { TerminalViewport } from "./ThreadTerminalDrawer";
72
73const threadRef = {
74 environmentId: EnvironmentId.make("secondary-terminal"),
75 threadId: ThreadId.make("thread"),
76};
77const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 };
78let renderer: ReactTestRenderer | undefined;
79
80beforeEach(() => {
81 state.allowed = true;
82 state.editorAllowed = true;
83 state.listeners.clear();
84 state.resize.mockReset().mockResolvedValue(AsyncResult.success(undefined));
85 state.otherCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined));
86 // A surface can report its initial grid while asynchronous WASM setup is
87 // pending. Keep that unrelated setup pending while exercising its callback.
88 state.createSurface.mockReset().mockReturnValue(new Promise(() => undefined));
89 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
90 vi.stubGlobal("document", {
91 body: {},
92 documentElement: { classList: { contains: () => false } },
93 querySelector: () => null,
94 createElement: () => ({ getContext: () => null }),
95 });
96 vi.stubGlobal("getComputedStyle", () => ({
97 colorScheme: "light",
98 backgroundColor: "",
99 color: "",
100 getPropertyValue: () => "",
101 }));
102});
103
104afterEach(async () => {
105 await act(async () => renderer?.unmount());
106 renderer = undefined;
107 vi.unstubAllGlobals();
108});
109
110async function renderViewport(cwd = "/repo") {
111 await act(async () => {
112 renderer = create(
113 <TerminalViewport
114 advancedTypography={false}
115 threadRef={threadRef}
116 threadId={threadRef.threadId}
117 terminalId="terminal-1"
118 terminalLabel="Terminal 1"
119 cwd={cwd}
120 onSessionExited={state.otherCommand}
121 focusRequestId={0}
122 autoFocus={false}
123 visible={true}
124 resizeEpoch={0}
125 drawerHeight={200}
126 keybindings={[]}
127 />,
128 { createNodeMock: () => ({ closest: () => null, contains: () => false }) },
129 );
130 });
131}
132
133it("rechecks the target terminal grant when a retained surface callback reports a resize", async () => {
134 await renderViewport();
135 expect(state.createSurface).toHaveBeenCalledOnce();
136 const onResize = state.createSurface.mock.calls[0]![1].onResize;
137 if (!onResize) throw new Error("The terminal did not register its resize callback.");
138
139 onResize(80, 24);
140 expect(state.resize).toHaveBeenCalledExactlyOnceWith({
141 environmentId: threadRef.environmentId,
142 input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 80, rows: 24 },
143 });
144
145 // The connection updates before React commits its external-store update.
146 // Other environments retain access; only this terminal's target is revoked.
147 state.allowed = false;
148 onResize(120, 40);
149 expect(state.resize).toHaveBeenCalledTimes(1);
150
151 state.allowed = true;
152 onResize(100, 30);
153 expect(state.resize).toHaveBeenCalledTimes(2);
154 expect(state.resize).toHaveBeenLastCalledWith({
155 environmentId: threadRef.environmentId,
156 input: { threadId: threadRef.threadId, terminalId: "terminal-1", cols: 100, rows: 30 },
157 });
158
159 await act(async () => renderer!.unmount());
160 renderer = undefined;
161 onResize(120, 40);
162 expect(state.resize).toHaveBeenCalledTimes(2);
163});
164
165it("keeps host paths outside cwd available while enforcing the live editor grant", async () => {
166 await renderViewport("/Users/clo/repo");
167 const validatePath = state.createSurface.mock.calls[0]![1].validatePath!;
168 expect(validatePath(String.raw`C:\repo\src\file.ts`)).toBe(true);
169 expect(validatePath(String.raw`\\server\share\file.ts`)).toBe(true);
170 expect(validatePath("~/src/file.ts")).toBe(true);
171 state.editorAllowed = false;
172 expect(validatePath(String.raw`C:\repo\src\file.ts`)).toBe(false);
173 expect(validatePath(String.raw`\\server\share\file.ts`)).toBe(false);
174 expect(validatePath("~/src/file.ts")).toBe(false);
175});