bluera-knowledge 0.11.11 → 0.11.13
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [0.11.13](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.13) (2026-01-10)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **scripts:** add post-release npm validation script ([e4c29a0](https://github.com/blueraai/bluera-knowledge/commit/e4c29a0c83907de4bc293a69a58412629457fb22))
|
|
11
|
+
* **scripts:** add suggest, sync, serve, mcp tests to npm validation ([49d85da](https://github.com/blueraai/bluera-knowledge/commit/49d85dad1a89691060c12f152d644844baf6e6e6))
|
|
12
|
+
* **scripts:** log expected vs installed version in validation script ([c77d039](https://github.com/blueraai/bluera-knowledge/commit/c77d039b27a3ccf54d50006af161ac4dcfea7b21))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **cli:** plugin-api commands now respect global options ([d3cca02](https://github.com/blueraai/bluera-knowledge/commit/d3cca02ffc679ffc187b76c7682f3cc177eabdea))
|
|
18
|
+
* **plugin:** properly close services after command execution ([eeaf743](https://github.com/blueraai/bluera-knowledge/commit/eeaf743750be73fd9c7a9e72440b2fd0fb5a53fa))
|
|
19
|
+
* **scripts:** show real-time output in validation script ([8a4bdec](https://github.com/blueraai/bluera-knowledge/commit/8a4bdec8b63c504d34ba35bfe19da795f7f7fd07))
|
|
20
|
+
* **scripts:** use mktemp for temp directories in validation script ([3107861](https://github.com/blueraai/bluera-knowledge/commit/3107861bd7a966016fde2a121469dd84756f39be))
|
|
21
|
+
* **search:** add defaults for env vars so CLI works standalone ([b2d2ce5](https://github.com/blueraai/bluera-knowledge/commit/b2d2ce534e8cd2ba0fc0abdac505c912a1a76035))
|
|
22
|
+
|
|
23
|
+
## [0.11.12](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.12) (2026-01-10)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* **scripts:** add post-release npm validation script ([e4c29a0](https://github.com/blueraai/bluera-knowledge/commit/e4c29a0c83907de4bc293a69a58412629457fb22))
|
|
29
|
+
* **scripts:** add suggest, sync, serve, mcp tests to npm validation ([49d85da](https://github.com/blueraai/bluera-knowledge/commit/49d85dad1a89691060c12f152d644844baf6e6e6))
|
|
30
|
+
* **scripts:** log expected vs installed version in validation script ([c77d039](https://github.com/blueraai/bluera-knowledge/commit/c77d039b27a3ccf54d50006af161ac4dcfea7b21))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Bug Fixes
|
|
34
|
+
|
|
35
|
+
* **cli:** plugin-api commands now respect global options ([d3cca02](https://github.com/blueraai/bluera-knowledge/commit/d3cca02ffc679ffc187b76c7682f3cc177eabdea))
|
|
36
|
+
* **plugin:** properly close services after command execution ([eeaf743](https://github.com/blueraai/bluera-knowledge/commit/eeaf743750be73fd9c7a9e72440b2fd0fb5a53fa))
|
|
37
|
+
* **scripts:** show real-time output in validation script ([8a4bdec](https://github.com/blueraai/bluera-knowledge/commit/8a4bdec8b63c504d34ba35bfe19da795f7f7fd07))
|
|
38
|
+
* **scripts:** use mktemp for temp directories in validation script ([3107861](https://github.com/blueraai/bluera-knowledge/commit/3107861bd7a966016fde2a121469dd84756f39be))
|
|
39
|
+
* **search:** add defaults for env vars so CLI works standalone ([b2d2ce5](https://github.com/blueraai/bluera-knowledge/commit/b2d2ce534e8cd2ba0fc0abdac505c912a1a76035))
|
|
40
|
+
|
|
5
41
|
## [0.11.11](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.11) (2026-01-10)
|
|
6
42
|
|
|
7
43
|
|
package/package.json
CHANGED
|
@@ -213,6 +213,90 @@ else
|
|
|
213
213
|
pass "Store successfully deleted"
|
|
214
214
|
fi
|
|
215
215
|
|
|
216
|
+
# Test suggest command
|
|
217
|
+
log_header "Testing suggest"
|
|
218
|
+
|
|
219
|
+
# Create a minimal package.json for suggest to find
|
|
220
|
+
echo '{"dependencies": {"lodash": "^4.0.0"}}' > "$TEST_FOLDER/package.json"
|
|
221
|
+
run_test "bluera-knowledge suggest" "bluera-knowledge suggest -p '$TEST_FOLDER' -d '$DATA_DIR'"
|
|
222
|
+
|
|
223
|
+
# Test sync command (should work with no definitions - returns empty result)
|
|
224
|
+
log_header "Testing sync"
|
|
225
|
+
|
|
226
|
+
run_test_contains "bluera-knowledge sync (no definitions)" "Sync completed" "bluera-knowledge sync -p '$TEST_FOLDER' -d '$DATA_DIR'"
|
|
227
|
+
|
|
228
|
+
# Test sync with a definitions file
|
|
229
|
+
mkdir -p "$TEST_FOLDER/.bluera/bluera-knowledge"
|
|
230
|
+
cat > "$TEST_FOLDER/.bluera/bluera-knowledge/stores.config.json" << EOF
|
|
231
|
+
{
|
|
232
|
+
"version": 1,
|
|
233
|
+
"stores": [
|
|
234
|
+
{
|
|
235
|
+
"name": "sync-test-store",
|
|
236
|
+
"type": "file",
|
|
237
|
+
"path": "."
|
|
238
|
+
}
|
|
239
|
+
]
|
|
240
|
+
}
|
|
241
|
+
EOF
|
|
242
|
+
run_test "bluera-knowledge sync (with definitions)" "bluera-knowledge sync -p '$TEST_FOLDER' -d '$DATA_DIR'"
|
|
243
|
+
|
|
244
|
+
# Verify sync created the store
|
|
245
|
+
run_test_contains "Sync created store" "sync-test-store" "bluera-knowledge stores -d '$DATA_DIR'"
|
|
246
|
+
|
|
247
|
+
# Clean up sync test store
|
|
248
|
+
bluera-knowledge store delete "sync-test-store" --force -d "$DATA_DIR" 2>/dev/null || true
|
|
249
|
+
|
|
250
|
+
# Test serve command (start, test, stop)
|
|
251
|
+
log_header "Testing serve"
|
|
252
|
+
|
|
253
|
+
SERVE_PORT=19876
|
|
254
|
+
SERVE_PID=""
|
|
255
|
+
|
|
256
|
+
# Start server in background
|
|
257
|
+
log "Starting serve on port $SERVE_PORT..."
|
|
258
|
+
bluera-knowledge serve --port $SERVE_PORT -d "$DATA_DIR" &
|
|
259
|
+
SERVE_PID=$!
|
|
260
|
+
|
|
261
|
+
# Give it time to start
|
|
262
|
+
sleep 2
|
|
263
|
+
|
|
264
|
+
# Check if server is running
|
|
265
|
+
if kill -0 $SERVE_PID 2>/dev/null; then
|
|
266
|
+
log "Server started with PID $SERVE_PID"
|
|
267
|
+
|
|
268
|
+
# Test health endpoint
|
|
269
|
+
if curl -s "http://localhost:$SERVE_PORT/health" | grep -q "ok"; then
|
|
270
|
+
pass "bluera-knowledge serve (health endpoint)"
|
|
271
|
+
else
|
|
272
|
+
fail "bluera-knowledge serve (health endpoint not responding)"
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
# Stop server
|
|
276
|
+
kill $SERVE_PID 2>/dev/null || true
|
|
277
|
+
wait $SERVE_PID 2>/dev/null || true
|
|
278
|
+
log "Server stopped"
|
|
279
|
+
else
|
|
280
|
+
fail "bluera-knowledge serve (failed to start)"
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
# Test mcp command (just verify it starts and outputs JSON-RPC)
|
|
284
|
+
log_header "Testing mcp"
|
|
285
|
+
|
|
286
|
+
MCP_OUTPUT=$(timeout 2 bluera-knowledge mcp -d "$DATA_DIR" 2>&1 || true)
|
|
287
|
+
if echo "$MCP_OUTPUT" | grep -qE "(jsonrpc|ready|listening|MCP)" 2>/dev/null || [ $? -eq 124 ]; then
|
|
288
|
+
# Timeout (124) is expected - MCP keeps running until killed
|
|
289
|
+
pass "bluera-knowledge mcp (starts without error)"
|
|
290
|
+
else
|
|
291
|
+
# Even if it times out or produces no output, as long as it didn't crash
|
|
292
|
+
if [ -z "$MCP_OUTPUT" ]; then
|
|
293
|
+
pass "bluera-knowledge mcp (starts without error)"
|
|
294
|
+
else
|
|
295
|
+
log "MCP output: $MCP_OUTPUT"
|
|
296
|
+
fail "bluera-knowledge mcp (unexpected output)"
|
|
297
|
+
fi
|
|
298
|
+
fi
|
|
299
|
+
|
|
216
300
|
# Summary
|
|
217
301
|
log_header "Validation Summary"
|
|
218
302
|
log "Tests run: $TESTS_RUN"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { rm, mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import * as readline from 'node:readline';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* MCP Server Integration Tests
|
|
10
|
+
*
|
|
11
|
+
* Tests that the MCP server actually starts and accepts JSON-RPC messages.
|
|
12
|
+
* Unlike unit tests that mock runMCPServer, these tests spawn a real
|
|
13
|
+
* MCP server process and communicate via stdin/stdout.
|
|
14
|
+
*/
|
|
15
|
+
describe('MCP Integration', () => {
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
let testFilesDir: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
tempDir = await mkdtemp(join(tmpdir(), 'mcp-test-'));
|
|
21
|
+
testFilesDir = join(tempDir, 'files');
|
|
22
|
+
await mkdir(testFilesDir, { recursive: true });
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(testFilesDir, 'test.md'),
|
|
25
|
+
'# Test Document\n\nContent for MCP integration testing.'
|
|
26
|
+
);
|
|
27
|
+
}, 30000);
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
interface JSONRPCResponse {
|
|
34
|
+
jsonrpc: string;
|
|
35
|
+
id: number;
|
|
36
|
+
result?: unknown;
|
|
37
|
+
error?: { code: number; message: string };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface MCPClient {
|
|
41
|
+
proc: ChildProcess;
|
|
42
|
+
sendRequest: (method: string, params?: Record<string, unknown>) => Promise<JSONRPCResponse>;
|
|
43
|
+
close: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start the MCP server and return a client with message helpers
|
|
48
|
+
*/
|
|
49
|
+
function startMCPServer(): MCPClient {
|
|
50
|
+
const proc = spawn('node', ['dist/mcp/server.js'], {
|
|
51
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
PROJECT_ROOT: tempDir,
|
|
55
|
+
DATA_DIR: join(tempDir, 'data'),
|
|
56
|
+
CONFIG_PATH: join(tempDir, 'config.json'),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Set up a single readline interface for the entire process lifetime
|
|
61
|
+
const rl = readline.createInterface({ input: proc.stdout! });
|
|
62
|
+
const pendingRequests = new Map<
|
|
63
|
+
number,
|
|
64
|
+
{ resolve: (r: JSONRPCResponse) => void; reject: (e: Error) => void }
|
|
65
|
+
>();
|
|
66
|
+
let requestId = 0;
|
|
67
|
+
|
|
68
|
+
rl.on('line', (line: string) => {
|
|
69
|
+
try {
|
|
70
|
+
const response = JSON.parse(line) as JSONRPCResponse;
|
|
71
|
+
const pending = pendingRequests.get(response.id);
|
|
72
|
+
if (pending !== undefined) {
|
|
73
|
+
pendingRequests.delete(response.id);
|
|
74
|
+
pending.resolve(response);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore non-JSON lines (like log output)
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
proc,
|
|
83
|
+
sendRequest: (
|
|
84
|
+
method: string,
|
|
85
|
+
params: Record<string, unknown> = {}
|
|
86
|
+
): Promise<JSONRPCResponse> => {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
if (proc.stdin === null) {
|
|
89
|
+
reject(new Error('Process stdin not available'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const id = ++requestId;
|
|
94
|
+
const timeout = setTimeout(() => {
|
|
95
|
+
pendingRequests.delete(id);
|
|
96
|
+
reject(new Error(`Timeout waiting for response to ${method}`));
|
|
97
|
+
}, 30000);
|
|
98
|
+
|
|
99
|
+
pendingRequests.set(id, {
|
|
100
|
+
resolve: (r) => {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
resolve(r);
|
|
103
|
+
},
|
|
104
|
+
reject: (e) => {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
reject(e);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const request = JSON.stringify({
|
|
111
|
+
jsonrpc: '2.0',
|
|
112
|
+
id,
|
|
113
|
+
method,
|
|
114
|
+
params,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
proc.stdin.write(request + '\n');
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
close: (): void => {
|
|
121
|
+
rl.close();
|
|
122
|
+
proc.kill('SIGTERM');
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wait for process to be ready
|
|
129
|
+
*/
|
|
130
|
+
async function waitForReady(client: MCPClient, timeoutMs = 10000): Promise<void> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const timeout = setTimeout(() => {
|
|
133
|
+
reject(new Error('Timeout waiting for MCP server to start'));
|
|
134
|
+
}, timeoutMs);
|
|
135
|
+
|
|
136
|
+
// MCP servers typically become ready quickly
|
|
137
|
+
// Give it a moment to initialize
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
resolve();
|
|
141
|
+
}, 1000);
|
|
142
|
+
|
|
143
|
+
client.proc.on('error', (err) => {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
client.proc.on('exit', (code) => {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
if (code !== 0 && code !== null) {
|
|
151
|
+
reject(new Error(`MCP server exited with code ${code}`));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let mcpClient: MCPClient | null = null;
|
|
158
|
+
|
|
159
|
+
afterEach(async () => {
|
|
160
|
+
// Clean up MCP client if still running
|
|
161
|
+
if (mcpClient !== null) {
|
|
162
|
+
mcpClient.close();
|
|
163
|
+
await new Promise<void>((resolve) => {
|
|
164
|
+
if (mcpClient !== null) {
|
|
165
|
+
mcpClient.proc.on('exit', () => resolve());
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
if (mcpClient !== null) {
|
|
168
|
+
mcpClient.proc.kill('SIGKILL');
|
|
169
|
+
}
|
|
170
|
+
resolve();
|
|
171
|
+
}, 2000);
|
|
172
|
+
} else {
|
|
173
|
+
resolve();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
mcpClient = null;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('starts, lists tools, and handles tool execution', async () => {
|
|
181
|
+
mcpClient = startMCPServer();
|
|
182
|
+
await waitForReady(mcpClient);
|
|
183
|
+
|
|
184
|
+
// 1. Initialize
|
|
185
|
+
const initResponse = await mcpClient.sendRequest('initialize', {
|
|
186
|
+
protocolVersion: '2024-11-05',
|
|
187
|
+
capabilities: {},
|
|
188
|
+
clientInfo: { name: 'test-client', version: '1.0.0' },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(initResponse.jsonrpc).toBe('2.0');
|
|
192
|
+
expect(initResponse.error).toBeUndefined();
|
|
193
|
+
expect(initResponse.result).toBeDefined();
|
|
194
|
+
|
|
195
|
+
const initResult = initResponse.result as {
|
|
196
|
+
protocolVersion: string;
|
|
197
|
+
serverInfo: { name: string; version: string };
|
|
198
|
+
capabilities: { tools: Record<string, unknown> };
|
|
199
|
+
};
|
|
200
|
+
expect(initResult.serverInfo.name).toBe('bluera-knowledge');
|
|
201
|
+
expect(initResult.capabilities.tools).toBeDefined();
|
|
202
|
+
|
|
203
|
+
// 2. List tools
|
|
204
|
+
const toolsResponse = await mcpClient.sendRequest('tools/list', {});
|
|
205
|
+
|
|
206
|
+
expect(toolsResponse.error).toBeUndefined();
|
|
207
|
+
expect(toolsResponse.result).toBeDefined();
|
|
208
|
+
|
|
209
|
+
const toolsResult = toolsResponse.result as {
|
|
210
|
+
tools: Array<{ name: string; description: string }>;
|
|
211
|
+
};
|
|
212
|
+
expect(Array.isArray(toolsResult.tools)).toBe(true);
|
|
213
|
+
|
|
214
|
+
const toolNames = toolsResult.tools.map((t) => t.name);
|
|
215
|
+
expect(toolNames).toContain('search');
|
|
216
|
+
expect(toolNames).toContain('get_full_context');
|
|
217
|
+
expect(toolNames).toContain('execute');
|
|
218
|
+
|
|
219
|
+
// 3. Call search tool
|
|
220
|
+
const searchResponse = await mcpClient.sendRequest('tools/call', {
|
|
221
|
+
name: 'search',
|
|
222
|
+
arguments: {
|
|
223
|
+
query: 'test query',
|
|
224
|
+
limit: 5,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(searchResponse.error).toBeUndefined();
|
|
229
|
+
expect(searchResponse.result).toBeDefined();
|
|
230
|
+
|
|
231
|
+
const searchResult = searchResponse.result as {
|
|
232
|
+
content: Array<{ type: string; text: string }>;
|
|
233
|
+
};
|
|
234
|
+
expect(Array.isArray(searchResult.content)).toBe(true);
|
|
235
|
+
|
|
236
|
+
// 4. Call execute with help command
|
|
237
|
+
const helpResponse = await mcpClient.sendRequest('tools/call', {
|
|
238
|
+
name: 'execute',
|
|
239
|
+
arguments: {
|
|
240
|
+
command: 'help',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(helpResponse.error).toBeUndefined();
|
|
245
|
+
expect(helpResponse.result).toBeDefined();
|
|
246
|
+
|
|
247
|
+
const helpResult = helpResponse.result as { content: Array<{ type: string; text: string }> };
|
|
248
|
+
expect(helpResult.content[0]?.text).toContain('Available commands');
|
|
249
|
+
}, 120000);
|
|
250
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { rm, mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Serve Command Integration Tests
|
|
9
|
+
*
|
|
10
|
+
* Tests that the HTTP server actually starts, responds to requests,
|
|
11
|
+
* and shuts down cleanly. Unlike unit tests that mock the serve function,
|
|
12
|
+
* these tests spawn a real server process and make actual HTTP requests.
|
|
13
|
+
*/
|
|
14
|
+
describe('Serve Integration', () => {
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
let testFilesDir: string;
|
|
17
|
+
let serverProcess: ChildProcess | null = null;
|
|
18
|
+
const TEST_PORT = 19877; // Use a unique port to avoid conflicts
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
tempDir = await mkdtemp(join(tmpdir(), 'serve-test-'));
|
|
22
|
+
testFilesDir = join(tempDir, 'files');
|
|
23
|
+
await mkdir(testFilesDir, { recursive: true });
|
|
24
|
+
await writeFile(
|
|
25
|
+
join(testFilesDir, 'test.md'),
|
|
26
|
+
'# Test Document\n\nContent for serve integration testing.'
|
|
27
|
+
);
|
|
28
|
+
}, 30000);
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
// Clean up server process if still running
|
|
36
|
+
if (serverProcess !== null) {
|
|
37
|
+
serverProcess.kill('SIGTERM');
|
|
38
|
+
await new Promise<void>((resolve) => {
|
|
39
|
+
if (serverProcess !== null) {
|
|
40
|
+
serverProcess.on('exit', () => resolve());
|
|
41
|
+
// Force kill after timeout
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
if (serverProcess !== null) {
|
|
44
|
+
serverProcess.kill('SIGKILL');
|
|
45
|
+
}
|
|
46
|
+
resolve();
|
|
47
|
+
}, 2000);
|
|
48
|
+
} else {
|
|
49
|
+
resolve();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
serverProcess = null;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start the serve command and wait for it to be ready
|
|
58
|
+
*/
|
|
59
|
+
async function startServer(port: number): Promise<ChildProcess> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const proc = spawn(
|
|
62
|
+
'node',
|
|
63
|
+
['dist/index.js', 'serve', '--port', String(port), '--data-dir', tempDir],
|
|
64
|
+
{
|
|
65
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
let resolved = false;
|
|
70
|
+
const timeout = setTimeout(() => {
|
|
71
|
+
if (!resolved) {
|
|
72
|
+
reject(new Error('Server startup timeout'));
|
|
73
|
+
proc.kill();
|
|
74
|
+
}
|
|
75
|
+
}, 30000);
|
|
76
|
+
|
|
77
|
+
proc.stdout?.on('data', (data: Buffer) => {
|
|
78
|
+
const output = data.toString();
|
|
79
|
+
if (output.includes('Starting server')) {
|
|
80
|
+
resolved = true;
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
// Give it a moment to actually start listening
|
|
83
|
+
setTimeout(() => resolve(proc), 500);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
proc.stderr?.on('data', (data: Buffer) => {
|
|
88
|
+
// Log stderr but don't fail - some warnings are expected
|
|
89
|
+
console.error('Server stderr:', data.toString());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
proc.on('error', (err) => {
|
|
93
|
+
if (!resolved) {
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
reject(err);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
proc.on('exit', (code) => {
|
|
100
|
+
if (!resolved) {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
reject(new Error(`Server exited with code ${String(code)}`));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Make an HTTP request with timeout
|
|
110
|
+
*/
|
|
111
|
+
async function fetchWithTimeout(
|
|
112
|
+
url: string,
|
|
113
|
+
options: RequestInit = {},
|
|
114
|
+
timeoutMs = 5000
|
|
115
|
+
): Promise<Response> {
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
118
|
+
try {
|
|
119
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timeoutId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('Server Startup and Health', () => {
|
|
126
|
+
it('starts server and responds to /health', async () => {
|
|
127
|
+
serverProcess = await startServer(TEST_PORT);
|
|
128
|
+
|
|
129
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT}/health`);
|
|
130
|
+
expect(response.ok).toBe(true);
|
|
131
|
+
|
|
132
|
+
const body = (await response.json()) as { status: string };
|
|
133
|
+
expect(body.status).toBe('ok');
|
|
134
|
+
}, 60000);
|
|
135
|
+
|
|
136
|
+
it('responds with CORS headers', async () => {
|
|
137
|
+
serverProcess = await startServer(TEST_PORT + 1);
|
|
138
|
+
|
|
139
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 1}/health`, {
|
|
140
|
+
method: 'OPTIONS',
|
|
141
|
+
headers: {
|
|
142
|
+
Origin: 'http://localhost:3000',
|
|
143
|
+
'Access-Control-Request-Method': 'GET',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// CORS preflight should succeed
|
|
148
|
+
expect(response.ok).toBe(true);
|
|
149
|
+
}, 60000);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('Store API', () => {
|
|
153
|
+
beforeEach(async () => {
|
|
154
|
+
serverProcess = await startServer(TEST_PORT + 2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('lists stores via GET /api/stores', async () => {
|
|
158
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`);
|
|
159
|
+
expect(response.ok).toBe(true);
|
|
160
|
+
|
|
161
|
+
const body = (await response.json()) as unknown[];
|
|
162
|
+
expect(Array.isArray(body)).toBe(true);
|
|
163
|
+
}, 60000);
|
|
164
|
+
|
|
165
|
+
it('creates store via POST /api/stores', async () => {
|
|
166
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
name: 'serve-test-store',
|
|
171
|
+
type: 'file',
|
|
172
|
+
path: testFilesDir,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(response.status).toBe(201);
|
|
177
|
+
const body = (await response.json()) as { id: string; name: string };
|
|
178
|
+
expect(body.name).toBe('serve-test-store');
|
|
179
|
+
expect(body.id).toBeDefined();
|
|
180
|
+
}, 60000);
|
|
181
|
+
|
|
182
|
+
it('returns 400 for invalid store creation', async () => {
|
|
183
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
name: 'invalid-store',
|
|
188
|
+
type: 'file',
|
|
189
|
+
// Missing required 'path' for file type
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(response.status).toBe(400);
|
|
194
|
+
}, 60000);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('Search API', () => {
|
|
198
|
+
it('handles search request via POST /api/search', async () => {
|
|
199
|
+
serverProcess = await startServer(TEST_PORT + 3);
|
|
200
|
+
|
|
201
|
+
// Search with no stores indexed - should return empty results
|
|
202
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 3}/api/search`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
query: 'test query',
|
|
207
|
+
limit: 5,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(response.ok).toBe(true);
|
|
212
|
+
const body = (await response.json()) as { results: unknown[] };
|
|
213
|
+
expect(body.results).toBeDefined();
|
|
214
|
+
expect(Array.isArray(body.results)).toBe(true);
|
|
215
|
+
}, 60000);
|
|
216
|
+
|
|
217
|
+
it('returns 400 for invalid search request', async () => {
|
|
218
|
+
serverProcess = await startServer(TEST_PORT + 4);
|
|
219
|
+
|
|
220
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 4}/api/search`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
// Missing required 'query'
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(response.status).toBe(400);
|
|
229
|
+
}, 60000);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('Graceful Shutdown', () => {
|
|
233
|
+
it('shuts down cleanly on SIGTERM', async () => {
|
|
234
|
+
serverProcess = await startServer(TEST_PORT + 5);
|
|
235
|
+
|
|
236
|
+
// Verify server is running
|
|
237
|
+
const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 5}/health`);
|
|
238
|
+
expect(response.ok).toBe(true);
|
|
239
|
+
|
|
240
|
+
// Send SIGTERM
|
|
241
|
+
serverProcess.kill('SIGTERM');
|
|
242
|
+
|
|
243
|
+
// Wait for process to exit
|
|
244
|
+
const exitCode = await new Promise<number | null>((resolve) => {
|
|
245
|
+
if (serverProcess !== null) {
|
|
246
|
+
serverProcess.on('exit', (code) => resolve(code));
|
|
247
|
+
// Allow more time for graceful shutdown
|
|
248
|
+
setTimeout(() => resolve(null), 10000);
|
|
249
|
+
} else {
|
|
250
|
+
resolve(null);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Process should exit cleanly (code 0) or be killed (null)
|
|
255
|
+
// On some systems the process may not exit with 0 due to async cleanup
|
|
256
|
+
expect(exitCode === 0 || exitCode === null).toBe(true);
|
|
257
|
+
serverProcess = null; // Already exited
|
|
258
|
+
}, 60000);
|
|
259
|
+
});
|
|
260
|
+
});
|