1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as TestClock from "effect/testing/TestClock";
8import type * as Electron from "electron";
9
10import * as ElectronWindow from "../electron/ElectronWindow.ts";
11import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts";
12import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
13
14interface SentMessage {
15 readonly channel: string;
16 readonly args: readonly unknown[];
17}
18
19function makeTestWindow(
20 options: {
21 readonly isDestroyedError?: unknown;
22 readonly isMinimizedError?: unknown;
23 readonly sendError?: unknown;
24 } = {},
25) {
26 const listeners = new Map<string, Set<() => void>>();
27 const sentMessages: SentMessage[] = [];
28 let destroyed = false;
29 let minimized = true;
30 let restored = false;
31 let focused = false;
32
33 const window = {
34 isDestroyed: () => {
35 if (options.isDestroyedError !== undefined) {
36 throw options.isDestroyedError;
37 }
38 return destroyed;
39 },
40 isMinimized: () => {
41 if (options.isMinimizedError !== undefined) {
42 throw options.isMinimizedError;
43 }
44 return minimized;
45 },
46 restore: () => {
47 restored = true;
48 minimized = false;
49 },
50 focus: () => {
51 focused = true;
52 },
53 once: (eventName: string, listener: () => void) => {
54 const eventListeners = listeners.get(eventName) ?? new Set<() => void>();
55 eventListeners.add(listener);
56 listeners.set(eventName, eventListeners);
57 },
58 removeListener: (eventName: string, listener: () => void) => {
59 listeners.get(eventName)?.delete(listener);
60 },
61 webContents: {
62 send: (channel: string, ...args: readonly unknown[]) => {
63 const message = { channel, args };
64 sentMessages.push(message);
65 if (options.sendError !== undefined) {
66 throw options.sendError;
67 }
68 },
69 },
70 };
71
72 return {
73 window,
74 sentMessages,
75 isRestored: () => restored,
76 isFocused: () => focused,
77 closedListenerCount: () => listeners.get("closed")?.size ?? 0,
78 close: () => {
79 destroyed = true;
80 const closedListeners = [...(listeners.get("closed") ?? [])];
81 listeners.delete("closed");
82 for (const listener of closedListeners) {
83 listener();
84 }
85 },
86 };
87}
88
89function layerElectronWindow(window: ReturnType<typeof makeTestWindow>["window"]) {
90 return Layer.succeed(
91 ElectronWindow.ElectronWindow,
92 ElectronWindow.ElectronWindow.of({
93 create: () => Effect.die("unexpected BrowserWindow creation"),
94 main: Effect.succeedSome(window as Electron.BrowserWindow),
95 currentMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow),
96 focusedMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow),
97 setMain: () => Effect.void,
98 clearMain: () => Effect.void,
99 prepareReveal: () => Effect.succeed(false),
100 reveal: () => Effect.void,
101 sendAll: () => Effect.void,
102 destroyAll: Effect.void,
103 syncAllAppearance: () => Effect.void,
104 }),
105 );
106}
107
108function layer(window: ReturnType<typeof makeTestWindow>["window"]) {
109 return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe(
110 Layer.provide(layerElectronWindow(window)),
111 Layer.provide(NodeServices.layer),
112 Layer.provideMerge(TestClock.layer()),
113 );
114}
115
116describe("DesktopSshPasswordPrompts", () => {
117 it.effect("sends renderer prompts and resolves them by request id", () => {
118 const testWindow = makeTestWindow();
119
120 return Effect.gen(function* () {
121 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
122 const fiber = yield* prompts
123 .request({
124 destination: "devbox",
125 username: "julius",
126 prompt: "Enter the SSH password.",
127 attempt: 1,
128 })
129 .pipe(Effect.forkScoped);
130
131 yield* Effect.yieldNow;
132 yield* Effect.yieldNow;
133 assert.equal(testWindow.sentMessages.length, 1);
134 const sent = testWindow.sentMessages[0];
135 assert.ok(sent);
136 assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL);
137 const request = sent.args[0] as { readonly requestId: string; readonly destination: string };
138 assert.equal(request.destination, "devbox");
139 assert.equal(testWindow.isRestored(), true);
140 assert.equal(testWindow.isFocused(), true);
141
142 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
143 assert.equal(yield* Fiber.join(fiber), "secret");
144 }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped);
145 });
146
147 it.effect("times out pending renderer prompts with a typed error", () => {
148 const testWindow = makeTestWindow();
149
150 return Effect.gen(function* () {
151 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
152 const fiber = yield* prompts
153 .request({
154 destination: "devbox",
155 username: null,
156 prompt: "Enter the SSH password.",
157 attempt: 1,
158 })
159 .pipe(Effect.forkScoped);
160
161 yield* Effect.yieldNow;
162 yield* TestClock.adjust(Duration.millis(1_000));
163 const error = yield* Fiber.join(fiber).pipe(Effect.flip);
164 assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError);
165 assert.equal(error.destination, "devbox");
166 }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped);
167 });
168
169 it.effect("cleans up a prompt that fails during renderer delivery", () => {
170 const cause = new Error("renderer unavailable");
171 const testWindow = makeTestWindow({ sendError: cause });
172
173 return Effect.gen(function* () {
174 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
175 const error = yield* prompts
176 .request({
177 destination: "devbox",
178 username: "julius",
179 prompt: "Enter the SSH password.",
180 attempt: 1,
181 })
182 .pipe(Effect.flip);
183
184 assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
185 assert.equal(error.operation, "send-prompt-request");
186 assert.equal(error.destination, "devbox");
187 const requestId = error.requestId;
188 if (requestId === null) {
189 assert.fail("renderer delivery failures must retain their request id");
190 }
191 assert.equal(testWindow.closedListenerCount(), 0);
192
193 const resolveError = yield* prompts
194 .resolve({ requestId, password: "secret" })
195 .pipe(Effect.flip);
196 assert.instanceOf(resolveError, DesktopSshPasswordPrompts.DesktopSshPromptExpiredError);
197 }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped);
198 });
199
200 it.effect("keeps a submitted password when a later presentation step fails", () => {
201 const testWindow = makeTestWindow({
202 isMinimizedError: new Error("failed to read minimized state"),
203 });
204
205 return Effect.gen(function* () {
206 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
207 const requestFiber = yield* prompts
208 .request({
209 destination: "devbox",
210 username: "julius",
211 prompt: "Enter the SSH password.",
212 attempt: 1,
213 })
214 .pipe(Effect.forkScoped);
215
216 yield* Effect.yieldNow;
217 const sent = testWindow.sentMessages[0];
218 assert.ok(sent);
219 const request = sent.args[0] as { readonly requestId: string };
220 yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
221 const password = yield* Fiber.join(requestFiber);
222
223 assert.equal(password, "secret");
224 assert.equal(testWindow.isFocused(), false);
225 assert.equal(testWindow.closedListenerCount(), 0);
226 }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped);
227 });
228
229 it.effect("classifies a failed initial window availability check", () => {
230 const testWindow = makeTestWindow({ isDestroyedError: new Error("window unavailable") });
231
232 return Effect.gen(function* () {
233 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
234 const error = yield* prompts
235 .request({
236 destination: "devbox",
237 username: "julius",
238 prompt: "Enter the SSH password.",
239 attempt: 1,
240 })
241 .pipe(Effect.flip);
242
243 assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptPresentationError);
244 assert.equal(error.operation, "check-window-before-request");
245 assert.equal(error.requestId, null);
246 assert.deepEqual(testWindow.sentMessages, []);
247 }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped);
248 });
249});