1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import type { CursorConfigureOptions } from "@cursor/sdk";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Path from "effect/Path";
11import { vi } from "vite-plus/test";
12
13import * as CursorSdk from "./CursorSdk.ts";
14
15const sdkDefaults = vi.hoisted(() => ({ useHttp1ForAgent: undefined as boolean | undefined }));
16
17// Holds the SDK's process-wide default the way it does: only keys present change, null clears.
18vi.mock("./sdk.ts", () => ({
19 loadCursorSdk: () => ({
20 Cursor: {
21 configure: ({ local }: CursorConfigureOptions) => {
22 if (local !== undefined && "useHttp1ForAgent" in local) {
23 sdkDefaults.useHttp1ForAgent = local.useHttp1ForAgent ?? undefined;
24 }
25 },
26 },
27 }),
28 loadCursorSqlite: () => ({}),
29}));
30
31const withHost = (environment: NodeJS.ProcessEnv, home: string) =>
32 Effect.provide(
33 Layer.mergeAll(
34 NodeServices.layer,
35 Layer.succeed(HostProcess.Environment, environment),
36 Layer.succeed(HostProcess.HomeDirectory, home),
37 ),
38 );
39
40// Shaped like the SDK's minified class: the constructor is not named after the error.
41// @effect-diagnostics-next-line extendsNativeError:off - mimics the Cursor SDK's own native Error class.
42class o extends Error {
43 override name = "WriteIterableClosedError";
44}
45
46// Calls the guard directly, with Vitest's own listener detached so the guard does not defer to it.
47const rejectThroughGuard = (reason: unknown) =>
48 Effect.gen(function* () {
49 const others = process.listeners("unhandledRejection");
50 yield* Layer.build(CursorSdk.layer);
51 const guard = process.listeners("unhandledRejection").find((l) => !others.includes(l));
52 assert.isDefined(guard);
53 using exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
54 using _log = vi.spyOn(console, "error").mockImplementation(() => {});
55 for (const listener of others) process.off("unhandledRejection", listener);
56 try {
57 guard(reason, Promise.resolve());
58 } finally {
59 for (const listener of others) process.on("unhandledRejection", listener);
60 }
61 return exit.mock.calls;
62 }).pipe(Effect.scoped, withHost({}, "/nonexistent-home"));
63
64describe("CursorSdk unhandled rejection guard", () => {
65 it.effect("keeps the server running when the SDK writes to a closed stream", () =>
66 Effect.gen(function* () {
67 assert.deepStrictEqual(yield* rejectThroughGuard(new o("WritableIterable is closed")), []);
68 }),
69 );
70
71 it.effect("keeps the server running when the SDK writes after its request stream ended", () =>
72 Effect.gen(function* () {
73 // Shaped like `ConnectError.from(e, Code.Aborted)` from the SDK's connect-node body pump.
74 const cause = Object.assign(new Error("write after end"), {
75 code: "ERR_STREAM_WRITE_AFTER_END",
76 });
77 const reason = Object.assign(new Error("[aborted] write after end", { cause }), {
78 name: "ConnectError",
79 code: 10,
80 rawMessage: "write after end",
81 });
82 assert.deepStrictEqual(yield* rejectThroughGuard(reason), []);
83 }),
84 );
85
86 it.effect("still exits on other rejections", () =>
87 Effect.gen(function* () {
88 assert.deepStrictEqual(yield* rejectThroughGuard(new Error("WritableIterable is closed")), [
89 [1],
90 ]);
91 }),
92 );
93
94 it.effect("still exits on aborted requests other than a write after end", () =>
95 Effect.gen(function* () {
96 const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" });
97 const reason = Object.assign(new Error("[aborted] socket hang up", { cause }), {
98 name: "ConnectError",
99 code: 10,
100 });
101 assert.deepStrictEqual(yield* rejectThroughGuard(reason), [[1]]);
102 }),
103 );
104});
105
106const http1 = JSON.stringify({ network: { useHttp1ForAgent: true } });
107
108describe("CursorSdk agent transport", () => {
109 it.effect.each([
110 {
111 name: "when set in CURSOR_CONFIG_DIR over XDG_CONFIG_HOME",
112 env: { CURSOR_CONFIG_DIR: "config", XDG_CONFIG_HOME: "xdg" },
113 file: "config/cli-config.json",
114 text: http1,
115 expected: true,
116 },
117 {
118 name: "when set in XDG_CONFIG_HOME",
119 env: { XDG_CONFIG_HOME: "xdg" },
120 file: "xdg/cursor/cli-config.json",
121 text: http1,
122 expected: true,
123 },
124 {
125 name: "when set in the home directory",
126 env: {},
127 file: "home/.cursor/cli-config.json",
128 text: http1,
129 expected: true,
130 },
131 {
132 name: "when it is off",
133 env: {},
134 file: "home/.cursor/cli-config.json",
135 text: JSON.stringify({ network: { useHttp1ForAgent: false } }),
136 expected: undefined,
137 },
138 {
139 name: "when the file is damaged",
140 env: {},
141 file: "home/.cursor/cli-config.json",
142 text: "{",
143 expected: undefined,
144 warns: true,
145 },
146 { name: "when there is no file", env: {}, file: undefined, text: "", expected: undefined },
147 ])("follows the Cursor CLI's HTTP/1.1 setting $name", ({ env, file, text, expected, warns }) =>
148 Effect.gen(function* () {
149 const fileSystem = yield* FileSystem.FileSystem;
150 const path = yield* Path.Path;
151 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-cli-config-" });
152 if (file !== undefined) {
153 yield* fileSystem.makeDirectory(path.dirname(path.join(root, file)), { recursive: true });
154 yield* fileSystem.writeFileString(path.join(root, file), text);
155 }
156 const environment = Object.fromEntries(
157 Object.entries(env).map(([key, directory]) => [key, path.join(root, directory)]),
158 );
159 const warnings: Array<unknown> = [];
160 const captureWarnings = Logger.make(({ logLevel, message }) => {
161 if (logLevel === "Warn") warnings.push(message);
162 });
163 yield* Effect.gen(function* () {
164 const sdk = Context.get(yield* Layer.build(CursorSdk.layer), CursorSdk.CursorSdk);
165 assert.isUndefined(sdkDefaults.useHttp1ForAgent);
166 sdk.load();
167 assert.strictEqual(sdkDefaults.useHttp1ForAgent, expected);
168 }).pipe(
169 Effect.scoped,
170 withHost(environment, path.join(root, "home")),
171 Effect.provide(Logger.layer([captureWarnings], { mergeWithExisting: false })),
172 );
173 assert.isUndefined(sdkDefaults.useHttp1ForAgent);
174 assert.lengthOf(warnings, warns ? 1 : 0);
175 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
176 );
177});