1import * as NodeModule from "node:module";
2import { EMPTY_TERMINAL_BUFFER_STATE } from "@t3tools/client-runtime/state/terminal";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { act, createElement, type ReactNode } from "react";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7import { useTerminalGridSync } from "./useTerminalGridSync";
8
9const access = vi.hoisted(() => ({ environments: new Set<string>() }));
10vi.mock("../../state/session", () => ({
11 readEnvironmentScope: (environmentId: string, scope: string) =>
12 scope === "terminal:operate" && access.environments.has(environmentId),
13}));
14
15// Mobile already depends on ReactDOM but does not install its browser type declarations.
16const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as {
17 createRoot(container: Element): {
18 render(children: ReactNode): void;
19 unmount(): void;
20 };
21};
22
23type Input = Parameters<typeof useTerminalGridSync>[0];
24let root: ReturnType<typeof createRoot>;
25const resize = vi.fn<Input["resize"]>();
26
27function GridProbe(input: Input) {
28 useTerminalGridSync(input);
29 return null;
30}
31
32function session({
33 generation = 1,
34 version = 1,
35 status = "running",
36}: {
37 readonly generation?: number;
38 readonly version?: number;
39 readonly status?: Input["terminal"]["status"];
40} = {}): Input["terminal"] {
41 return { output: { ...EMPTY_TERMINAL_BUFFER_STATE.output, generation }, version, status };
42}
43
44function input(): Input {
45 return {
46 environmentId: EnvironmentId.make("environment"),
47 threadId: ThreadId.make("thread"),
48 terminalId: "term-1",
49 canOperate: true,
50 terminal: session(),
51 size: { cols: 80, rows: 24 },
52 resize,
53 };
54}
55
56function render(value: Input) {
57 return act(() => root.render(createElement(GridProbe, value)));
58}
59
60beforeEach(() => {
61 resize.mockClear();
62 access.environments.clear();
63 access.environments.add("environment");
64 // The probe renders no DOM, but ReactDOM needs an event target to run real effects.
65 const document = {
66 nodeType: 9,
67 addEventListener() {},
68 removeEventListener() {},
69 };
70 const container = {
71 nodeType: 1,
72 tagName: "DIV",
73 namespaceURI: "http://www.w3.org/1999/xhtml",
74 ownerDocument: document,
75 addEventListener() {},
76 removeEventListener() {},
77 };
78 vi.stubGlobal("document", document);
79 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 root = createRoot(container as unknown as HTMLElement);
82});
83
84it("checks the current target grant before dispatching a delayed resize", async () => {
85 const current = input();
86 await render(current);
87 expect(resize).toHaveBeenCalledOnce();
88
89 access.environments.clear();
90 await render({ ...current, size: { cols: 120, rows: 40 } });
91 expect(resize).toHaveBeenCalledOnce();
92
93 access.environments.add("secondary");
94 await render({ ...current, size: { cols: 120, rows: 41 } });
95 expect(resize).toHaveBeenCalledOnce();
96
97 await render({ ...current, environmentId: EnvironmentId.make("secondary") });
98 expect(resize).toHaveBeenCalledTimes(2);
99 expect(resize).toHaveBeenLastCalledWith({
100 environmentId: "secondary",
101 input: { threadId: "thread", terminalId: "term-1", cols: 80, rows: 24 },
102 });
103});
104
105afterEach(async () => {
106 await act(() => root.unmount());
107 vi.unstubAllGlobals();
108});
109
110it("replays an observer's measured grid once its writable attachment snapshot arrives", async () => {
111 let current = { ...input(), canOperate: false };
112 await render(current);
113 current = { ...current, size: { cols: 120, rows: 40 } };
114 await render(current);
115 expect(resize).not.toHaveBeenCalled();
116
117 current = {
118 ...current,
119 canOperate: true,
120 terminal: session({ generation: 2, version: 0 }),
121 };
122 await render(current);
123 expect(resize).not.toHaveBeenCalled();
124
125 current = { ...current, terminal: session({ generation: 2 }) };
126 await render(current);
127 expect(resize).toHaveBeenCalledExactlyOnceWith({
128 environmentId: "environment",
129 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 40 },
130 });
131
132 await render({ ...current, terminal: session({ generation: 2, version: 3 }) });
133 await render({ ...current, size: { ...current.size } });
134 expect(resize).toHaveBeenCalledOnce();
135
136 await render({ ...current, size: { cols: 120, rows: 41 } });
137 expect(resize).toHaveBeenCalledTimes(2);
138 expect(resize).toHaveBeenLastCalledWith({
139 environmentId: "environment",
140 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 41 },
141 });
142});
143
144it("replays an unchanged grid after reconnecting with a new attachment generation", async () => {
145 const current = { ...input(), size: { cols: 120, rows: 40 } };
146 await render(current);
147 expect(resize).toHaveBeenCalledOnce();
148
149 await render({ ...current, terminal: session({ generation: 2, version: 0 }) });
150 expect(resize).toHaveBeenCalledOnce();
151
152 await render({ ...current, terminal: session({ generation: 2 }) });
153 expect(resize).toHaveBeenCalledTimes(2);
154 expect(resize).toHaveBeenLastCalledWith({
155 environmentId: "environment",
156 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 40 },
157 });
158
159 // React may observe the next snapshot without rendering the empty seed first.
160 await render({ ...current, terminal: session({ generation: 3 }) });
161 expect(resize).toHaveBeenCalledTimes(3);
162});
163
164it.each([
165 { name: "permission is revoked", change: { canOperate: false } },
166 { name: "the attachment is pending", change: { terminal: session({ version: 0 }) } },
167 { name: "the process has exited", change: { terminal: session({ status: "exited" }) } },
168])("retains measurements without resizing while $name", async ({ change }) => {
169 const current = input();
170 await render(current);
171 expect(resize).toHaveBeenCalledOnce();
172
173 await render({ ...current, ...change, size: { cols: 120, rows: 40 } });
174 await render({ ...current, ...change, size: { cols: 120, rows: 41 } });
175 expect(resize).toHaveBeenCalledOnce();
176
177 await render({ ...current, size: { cols: 120, rows: 41 } });
178 expect(resize).toHaveBeenCalledTimes(2);
179 expect(resize).toHaveBeenLastCalledWith({
180 environmentId: "environment",
181 input: { threadId: "thread", terminalId: "term-1", cols: 120, rows: 41 },
182 });
183});