@sage-protocol/sage-plugin 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test-utils.js ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Shared test utilities for sage-plugin E2E tests.
3
+ *
4
+ * Provides helpers for spawning an isolated sage daemon + MCP process,
5
+ * communicating via JSON-RPC, and injecting captures.
6
+ */
7
+
8
+ import { mkdtempSync, existsSync } from "node:fs";
9
+
10
+ /** Resolve the sage binary path. */
11
+ export function resolveSageBin() {
12
+ return process.env.SAGE_BIN || new URL("../target/debug/sage", import.meta.url).pathname;
13
+ }
14
+
15
+ /**
16
+ * Create an MCP JSON-RPC client over a Bun subprocess stdio.
17
+ */
18
+ export function createMcpClient(proc) {
19
+ const decoder = new TextDecoder();
20
+ const encoder = new TextEncoder();
21
+ const pending = new Map();
22
+
23
+ let closed = false;
24
+ let closeErr;
25
+ let buf = "";
26
+
27
+ (async () => {
28
+ try {
29
+ for await (const chunk of proc.stdout) {
30
+ buf += decoder.decode(chunk);
31
+ const lines = buf.split("\n");
32
+ buf = lines.pop() ?? "";
33
+
34
+ for (const line of lines) {
35
+ if (!line.trim()) continue;
36
+
37
+ let msg;
38
+ try {
39
+ msg = JSON.parse(line);
40
+ } catch {
41
+ continue;
42
+ }
43
+
44
+ if (msg && msg.id != null) {
45
+ const key = String(msg.id);
46
+ const waiter = pending.get(key);
47
+ if (waiter) {
48
+ pending.delete(key);
49
+ if (msg.error) waiter.reject(new Error(msg.error.message || "MCP error"));
50
+ else waiter.resolve(msg.result);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ closed = true;
56
+ } catch (e) {
57
+ closed = true;
58
+ closeErr = e;
59
+ } finally {
60
+ const stderr = await new Response(proc.stderr).text().catch(() => "");
61
+ for (const { reject } of pending.values()) {
62
+ reject(
63
+ new Error(
64
+ `MCP process ended before response. stderr:\n${stderr || "<empty>"}${closeErr ? `\nstdout reader error: ${closeErr}` : ""}`,
65
+ ),
66
+ );
67
+ }
68
+ pending.clear();
69
+ }
70
+ })();
71
+
72
+ return {
73
+ request(method, params) {
74
+ if (closed) {
75
+ throw new Error("MCP client is closed");
76
+ }
77
+ const id = `${Date.now()}-${Math.random()}`;
78
+ proc.stdin.write(
79
+ encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`),
80
+ );
81
+ return new Promise((resolve, reject) => {
82
+ pending.set(String(id), { resolve, reject });
83
+ });
84
+ },
85
+ notify(method, params) {
86
+ if (closed) return;
87
+ proc.stdin.write(encoder.encode(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`));
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Create a temporary isolated HOME directory for test isolation.
94
+ *
95
+ * Uses a short path under /tmp to avoid exceeding Unix socket path limits
96
+ * (SUN_LEN is typically 104 bytes on macOS, 108 on Linux).
97
+ * The sage daemon socket goes under $HOME/.sage/run/ or XDG_RUNTIME_DIR.
98
+ */
99
+ export function createIsolatedHome() {
100
+ // Short prefix to keep socket paths under SUN_LEN
101
+ return mkdtempSync("/tmp/se-");
102
+ }
103
+
104
+ /**
105
+ * Build an env object that isolates sage state to a temp directory.
106
+ */
107
+ export function isolatedEnv(tmpHome) {
108
+ return {
109
+ ...process.env,
110
+ HOME: tmpHome,
111
+ XDG_CONFIG_HOME: `${tmpHome}/c`,
112
+ XDG_DATA_HOME: `${tmpHome}/d`,
113
+ XDG_RUNTIME_DIR: `${tmpHome}/r`,
114
+ SAGE_HOME: `${tmpHome}/.sage`,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Start the sage daemon in foreground mode.
120
+ * Returns the daemon process. The daemon is ready when IPC socket appears.
121
+ */
122
+ export async function startDaemon(sageBin, tmpHome) {
123
+ const env = isolatedEnv(tmpHome);
124
+
125
+ const daemonProc = Bun.spawn([sageBin, "daemon", "start", "-f"], {
126
+ stdin: "ignore",
127
+ stdout: "pipe",
128
+ stderr: "pipe",
129
+ env,
130
+ });
131
+
132
+ // Wait for daemon to be ready (socket file appears)
133
+ // The daemon creates a socket at ~/.sage/run/sage.sock or under XDG_RUNTIME_DIR
134
+ const maxWait = 15_000;
135
+ const start = Date.now();
136
+ while (Date.now() - start < maxWait) {
137
+ // Check if daemon is still alive
138
+ if (daemonProc.exitCode !== null) {
139
+ const stderr = await new Response(daemonProc.stderr).text().catch(() => "");
140
+ throw new Error(`Daemon exited early (code ${daemonProc.exitCode}): ${stderr}`);
141
+ }
142
+
143
+ // Try to detect readiness via socket file
144
+ const candidates = [
145
+ `${tmpHome}/r/sage/sage.sock`,
146
+ `${tmpHome}/r/sage.sock`,
147
+ `${tmpHome}/.sage/run/sage.sock`,
148
+ `${tmpHome}/.sage/sage.sock`,
149
+ ];
150
+ if (candidates.some((p) => existsSync(p))) {
151
+ break;
152
+ }
153
+
154
+ await new Promise((r) => setTimeout(r, 200));
155
+ }
156
+
157
+ // Give daemon a moment to finish initializing after socket appears
158
+ await new Promise((r) => setTimeout(r, 500));
159
+
160
+ return daemonProc;
161
+ }
162
+
163
+ /**
164
+ * Spawn `sage mcp start` with isolated HOME and return { proc, client }.
165
+ * Performs MCP handshake (initialize + initialized notification).
166
+ *
167
+ * NOTE: A daemon must already be running for RLM tools to work.
168
+ */
169
+ export async function spawnSageMcp(sageBin, tmpHome) {
170
+ const env = isolatedEnv(tmpHome);
171
+
172
+ const proc = Bun.spawn([sageBin, "mcp", "start"], {
173
+ stdin: "pipe",
174
+ stdout: "pipe",
175
+ stderr: "pipe",
176
+ env,
177
+ });
178
+
179
+ const client = createMcpClient(proc);
180
+
181
+ // MCP handshake
182
+ const init = await client.request("initialize", {
183
+ protocolVersion: "2024-11-05",
184
+ capabilities: {},
185
+ clientInfo: { name: "sage-e2e-test", version: "0.0.0" },
186
+ });
187
+ client.notify("notifications/initialized", {});
188
+
189
+ return { proc, client, env, init };
190
+ }
191
+
192
+ /**
193
+ * Call an MCP tool by name and return the result.
194
+ *
195
+ * Returns { raw, text, json, isError } where:
196
+ * - raw: the full MCP result object
197
+ * - text: concatenated text content
198
+ * - json: parsed JSON if text is valid JSON, otherwise null
199
+ * - isError: whether the MCP response flagged an error
200
+ */
201
+ export async function callTool(client, name, args = {}) {
202
+ const result = await client.request("tools/call", {
203
+ name,
204
+ arguments: args,
205
+ });
206
+
207
+ const text =
208
+ result?.content
209
+ ?.filter((c) => c.type === "text")
210
+ .map((c) => c.text)
211
+ .join("\n") ?? "";
212
+
213
+ let json = null;
214
+ try {
215
+ json = JSON.parse(text);
216
+ } catch {
217
+ // not JSON
218
+ }
219
+
220
+ return { raw: result, text, json, isError: result?.isError ?? false };
221
+ }
222
+
223
+ /**
224
+ * Inject a capture (prompt + response) via the sage CLI.
225
+ * Uses `sage capture hook prompt` and `sage capture hook response` subcommands.
226
+ */
227
+ export async function injectCapture(
228
+ sageBin,
229
+ tmpHome,
230
+ {
231
+ prompt = "test prompt",
232
+ response = "test response",
233
+ sessionId = "e2e-session",
234
+ model = "test-model",
235
+ source = "e2e-test",
236
+ tokensInput = "100",
237
+ tokensOutput = "50",
238
+ } = {},
239
+ ) {
240
+ const env = isolatedEnv(tmpHome);
241
+
242
+ // Phase 1: capture prompt
243
+ const promptProc = Bun.spawn([sageBin, "capture", "hook", "prompt"], {
244
+ env: {
245
+ ...env,
246
+ SAGE_SOURCE: source,
247
+ PROMPT: prompt,
248
+ SAGE_SESSION_ID: sessionId,
249
+ SAGE_MODEL: model,
250
+ },
251
+ stdout: "pipe",
252
+ stderr: "pipe",
253
+ });
254
+ await promptProc.exited;
255
+
256
+ // Phase 2: capture response
257
+ const responseProc = Bun.spawn([sageBin, "capture", "hook", "response"], {
258
+ env: {
259
+ ...env,
260
+ SAGE_SOURCE: source,
261
+ LAST_RESPONSE: response,
262
+ TOKENS_INPUT: tokensInput,
263
+ TOKENS_OUTPUT: tokensOutput,
264
+ SAGE_SESSION_ID: sessionId,
265
+ SAGE_MODEL: model,
266
+ },
267
+ stdout: "pipe",
268
+ stderr: "pipe",
269
+ });
270
+ await responseProc.exited;
271
+
272
+ return {
273
+ promptExit: promptProc.exitCode,
274
+ responseExit: responseProc.exitCode,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Kill a process safely.
280
+ */
281
+ export function killProc(proc) {
282
+ try {
283
+ proc.kill("SIGTERM");
284
+ } catch {
285
+ // already dead
286
+ }
287
+ }