| 1 | import { |
| 2 | AuthOrchestrationOperateScope, |
| 3 | AuthTerminalOperateScope, |
| 4 | EnvironmentId, |
| 5 | ThreadId, |
| 6 | type AuthEnvironmentScope, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 9 | import { nextTerminalAttachSeedState } from "@t3tools/client-runtime/state/terminal"; |
| 10 | import { AsyncResult } from "effect/reactivity"; |
| 11 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; |
| 12 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 13 | import type { TerminalSurface, TerminalSurfaceOptions } from "~/terminal/TerminalSurface"; |
| 14 | |
| 15 | const 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 | |
| 25 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 26 | vi.mock("../hooks/useSettings", () => ({ |
| 27 | getClientSettings: () => DEFAULT_CLIENT_SETTINGS, |
| 28 | useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => |
| 29 | select(DEFAULT_CLIENT_SETTINGS), |
| 30 | })); |
| 31 | vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => state.otherCommand })); |
| 32 | vi.mock("../localApi", () => ({ readLocalApi: () => null })); |
| 33 | vi.mock("../state/server", () => ({ |
| 34 | serverEnvironment: { configValueAtom: () => "config" }, |
| 35 | })); |
| 36 | vi.mock("../state/preview", () => ({ previewEnvironment: { open: "open" } })); |
| 37 | vi.mock("../state/terminal", () => ({ terminalEnvironment: { resize: "resize", write: "write" } })); |
| 38 | vi.mock("../state/use-atom-command", () => ({ |
| 39 | useAtomCommand: (command: string) => (command === "resize" ? state.resize : state.otherCommand), |
| 40 | })); |
| 41 | vi.mock("../state/terminalSessions", () => ({ |
| 42 | useAttachedTerminalSession: () => session, |
| 43 | })); |
| 44 | vi.mock("~/terminal/TerminalSurface", () => ({ |
| 45 | TerminalSurface: { create: state.createSurface }, |
| 46 | })); |
| 47 | vi.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 | |
| 71 | import { TerminalViewport } from "./ThreadTerminalDrawer"; |
| 72 | |
| 73 | const threadRef = { |
| 74 | environmentId: EnvironmentId.make("secondary-terminal"), |
| 75 | threadId: ThreadId.make("thread"), |
| 76 | }; |
| 77 | const session = { ...nextTerminalAttachSeedState(), status: "running" as const, version: 1 }; |
| 78 | let renderer: ReactTestRenderer | undefined; |
| 79 | |
| 80 | beforeEach(() => { |
| 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 | |
| 104 | afterEach(async () => { |
| 105 | await act(async () => renderer?.unmount()); |
| 106 | renderer = undefined; |
| 107 | vi.unstubAllGlobals(); |
| 108 | }); |
| 109 | |
| 110 | async 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 | |
| 133 | it("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 | |
| 165 | it("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 | }); |