@sdsrs/code-graph 0.94.0 → 0.95.0
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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +69 -9
- package/package.json +8 -7
- package/claude-plugin/scripts/adopt.test.js +0 -679
- package/claude-plugin/scripts/auto-update.test.js +0 -515
- package/claude-plugin/scripts/cg-answer.test.js +0 -309
- package/claude-plugin/scripts/claude-config.test.js +0 -58
- package/claude-plugin/scripts/covering-tests.test.js +0 -78
- package/claude-plugin/scripts/doctor.test.js +0 -215
- package/claude-plugin/scripts/find-binary.test.js +0 -246
- package/claude-plugin/scripts/hook-fire.test.js +0 -117
- package/claude-plugin/scripts/hooks.test.js +0 -230
- package/claude-plugin/scripts/incremental-index.test.js +0 -102
- package/claude-plugin/scripts/lifecycle.e2e.test.js +0 -179
- package/claude-plugin/scripts/lifecycle.test.js +0 -786
- package/claude-plugin/scripts/mcp-launcher.test.js +0 -162
- package/claude-plugin/scripts/mcp-stub.test.js +0 -207
- package/claude-plugin/scripts/post-grep-inject.test.js +0 -531
- package/claude-plugin/scripts/pr-impact-comment.test.js +0 -110
- package/claude-plugin/scripts/pre-edit-guide.test.js +0 -218
- package/claude-plugin/scripts/pre-grep-guide.test.js +0 -1682
- package/claude-plugin/scripts/pre-read-guide.test.js +0 -363
- package/claude-plugin/scripts/project-detect.test.js +0 -95
- package/claude-plugin/scripts/recommendation-log.test.js +0 -79
- package/claude-plugin/scripts/session-init.test.js +0 -479
- package/claude-plugin/scripts/statusline-composite.test.js +0 -65
- package/claude-plugin/scripts/statusline.test.js +0 -235
- package/claude-plugin/scripts/tmp-dir.test.js +0 -50
- package/claude-plugin/scripts/user-prompt-context.test.js +0 -743
- package/claude-plugin/scripts/version-utils.test.js +0 -141
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
/**
|
|
4
|
-
* Tests for claude-plugin/scripts/mcp-launcher.js — the .mcp.json entry point
|
|
5
|
-
* that resolves the binary (with auto-install fallbacks) and stdio-forwards
|
|
6
|
-
* MCP JSON-RPC. install-e2e.test.js §4.3 covers find-binary in dev mode but
|
|
7
|
-
* doesn't exercise the launcher's full chain (find → spawn → forward).
|
|
8
|
-
*
|
|
9
|
-
* The negative paths (no binary anywhere → npm install + GitHub fallback +
|
|
10
|
-
* exit 1) are intentionally NOT covered here — the network-bound fallbacks
|
|
11
|
-
* have ~150s timeouts and aren't deterministic in CI sandboxes. End-to-end
|
|
12
|
-
* dev-mode coverage is the highest-leverage gap.
|
|
13
|
-
*
|
|
14
|
-
* Run: node --test claude-plugin/scripts/mcp-launcher.test.js
|
|
15
|
-
*/
|
|
16
|
-
const test = require('node:test');
|
|
17
|
-
const assert = require('node:assert/strict');
|
|
18
|
-
const fs = require('fs');
|
|
19
|
-
const path = require('path');
|
|
20
|
-
const { spawn } = require('child_process');
|
|
21
|
-
|
|
22
|
-
const PLUGIN_ROOT = path.resolve(__dirname, '..');
|
|
23
|
-
const REPO_ROOT = path.resolve(PLUGIN_ROOT, '..');
|
|
24
|
-
const LAUNCHER = path.join(__dirname, 'mcp-launcher.js');
|
|
25
|
-
const BINARY_NAME = process.platform === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
|
|
26
|
-
const REL_BINARY = path.join(REPO_ROOT, 'target', 'release', BINARY_NAME);
|
|
27
|
-
|
|
28
|
-
function hasBuiltBinary() {
|
|
29
|
-
return fs.existsSync(REL_BINARY);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Run the launcher, send one MCP message on stdin, collect stdout/stderr,
|
|
34
|
-
* resolve once we either see a JSON-RPC response on stdout or hit timeout.
|
|
35
|
-
*/
|
|
36
|
-
function runLauncherInitialize(timeoutMs = 15000, extraEnv = {}, cwd = REPO_ROOT) {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
const child = spawn(process.execPath, [LAUNCHER], {
|
|
39
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
-
env: { ...process.env, ...extraEnv },
|
|
41
|
-
cwd,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
let stdout = '';
|
|
45
|
-
let stderr = '';
|
|
46
|
-
const timer = setTimeout(() => {
|
|
47
|
-
child.kill('SIGTERM');
|
|
48
|
-
reject(new Error(`launcher timed out after ${timeoutMs}ms; stdout=${stdout.slice(0, 400)} stderr=${stderr.slice(0, 400)}`));
|
|
49
|
-
}, timeoutMs);
|
|
50
|
-
|
|
51
|
-
child.stdout.on('data', (d) => {
|
|
52
|
-
stdout += d.toString();
|
|
53
|
-
if (stdout.includes('"result"') || stdout.includes('"error"')) {
|
|
54
|
-
clearTimeout(timer);
|
|
55
|
-
child.kill('SIGTERM');
|
|
56
|
-
// Wait for the child to actually exit so the test doesn't leave an
|
|
57
|
-
// orphan mid-write (matters on macOS / Windows where SIGTERM
|
|
58
|
-
// delivery is less synchronous than on Linux).
|
|
59
|
-
child.once('exit', () => resolve({ stdout, stderr }));
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
63
|
-
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
64
|
-
|
|
65
|
-
const initMsg = JSON.stringify({
|
|
66
|
-
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
67
|
-
params: {
|
|
68
|
-
protocolVersion: '2024-11-05',
|
|
69
|
-
capabilities: {},
|
|
70
|
-
clientInfo: { name: 'launcher-test', version: '1.0.0' },
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
child.stdin.write(initMsg + '\n');
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
test('mcp-launcher resolves dev binary and forwards MCP JSON-RPC stdin/stdout', async (t) => {
|
|
78
|
-
if (!hasBuiltBinary()) {
|
|
79
|
-
t.skip(`release binary missing at ${REL_BINARY} — run \`cargo build --release\` first`);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// REPO_ROOT has its own .mcp.json registering code-graph-dev (v0.31.2
|
|
84
|
-
// landed that to capture dev session metrics), which trips the launcher's
|
|
85
|
-
// dedup gate. Force the original launch path so this test still covers
|
|
86
|
-
// it. The dedup behavior gets its own test below.
|
|
87
|
-
const { stdout, stderr } = await runLauncherInitialize(15000, { CODE_GRAPH_FORCE_PLUGIN_MCP: '1' });
|
|
88
|
-
|
|
89
|
-
// Find the JSON-RPC line in the bytes the launcher forwarded from the binary.
|
|
90
|
-
// Stderr may contain "[code-graph] ..." breadcrumbs from the launcher; those
|
|
91
|
-
// are diagnostic and shouldn't break the contract that stdout carries protocol.
|
|
92
|
-
const respLine = stdout.trim().split('\n').find((l) => l.includes('"result"'));
|
|
93
|
-
assert.ok(respLine,
|
|
94
|
-
`expected a JSON-RPC result line on launcher stdout, got: ${stdout.slice(0, 400)} | stderr: ${stderr.slice(0, 400)}`);
|
|
95
|
-
const resp = JSON.parse(respLine);
|
|
96
|
-
assert.equal(resp.jsonrpc, '2.0');
|
|
97
|
-
assert.equal(resp.id, 1);
|
|
98
|
-
assert.ok(resp.result.serverInfo, 'response must carry serverInfo from the binary');
|
|
99
|
-
assert.equal(resp.result.serverInfo.name, 'code-graph-mcp');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('mcp-launcher enters dedup stub when project .mcp.json registers a code-graph server', async () => {
|
|
103
|
-
// REPO_ROOT/.mcp.json registers code-graph-dev → dedup gate fires →
|
|
104
|
-
// launcher serves a 0-tools stub with a distinctive serverInfo.name.
|
|
105
|
-
// No need for the release binary; the stub is implemented in the
|
|
106
|
-
// launcher script itself.
|
|
107
|
-
const { stdout, stderr } = await runLauncherInitialize();
|
|
108
|
-
const respLine = stdout.trim().split('\n').find((l) => l.includes('"result"'));
|
|
109
|
-
assert.ok(respLine,
|
|
110
|
-
`expected stub JSON-RPC result on stdout, got: ${stdout.slice(0, 400)} | stderr: ${stderr.slice(0, 400)}`);
|
|
111
|
-
const resp = JSON.parse(respLine);
|
|
112
|
-
assert.match(resp.result.serverInfo.name, /stub|dedup/i,
|
|
113
|
-
`serverInfo.name should indicate stub mode, got ${JSON.stringify(resp.result.serverInfo)}`);
|
|
114
|
-
assert.match(stderr, /plugin MCP serving 0 tools/,
|
|
115
|
-
`stderr should explain the dedup, got: ${stderr.slice(0, 400)}`);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('mcp-launcher serves 0-tool stub in a non-project cwd (no binary spawn, no index created)', async (t) => {
|
|
119
|
-
const os = require('os');
|
|
120
|
-
// A bare temp dir with no .git/manifest → isNonProjectCwd → the launcher
|
|
121
|
-
// serves the 0-tool stub WITHOUT spawning the binary, so no .code-graph is
|
|
122
|
-
// created and no `instructions` block is injected. This is the fix for the
|
|
123
|
-
// ~2035 headless /tmp mem-lite calls that half-activated code-graph.
|
|
124
|
-
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-launcher-nonproj-'));
|
|
125
|
-
t.after(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
126
|
-
|
|
127
|
-
const { stdout, stderr } = await runLauncherInitialize(15000, {}, cwd);
|
|
128
|
-
const respLine = stdout.trim().split('\n').find((l) => l.includes('"result"'));
|
|
129
|
-
assert.ok(respLine,
|
|
130
|
-
`expected stub JSON-RPC result on stdout, got: ${stdout.slice(0, 400)} | stderr: ${stderr.slice(0, 400)}`);
|
|
131
|
-
const resp = JSON.parse(respLine);
|
|
132
|
-
assert.match(resp.result.serverInfo.name, /stub/i,
|
|
133
|
-
`serverInfo.name should indicate stub mode, got ${JSON.stringify(resp.result.serverInfo)}`);
|
|
134
|
-
assert.equal(resp.result.instructions, undefined,
|
|
135
|
-
'stub initialize must NOT carry an instructions block (the ~780B NOISY tax)');
|
|
136
|
-
assert.match(stderr, /non-project cwd/,
|
|
137
|
-
`stderr should explain the non-project gate, got: ${stderr.slice(0, 400)}`);
|
|
138
|
-
assert.ok(!fs.existsSync(path.join(cwd, '.code-graph')),
|
|
139
|
-
'must NOT create .code-graph in a non-project cwd');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('mcp-launcher sets _FIND_BINARY_ROOT from __dirname (does not trust CLAUDE_PLUGIN_ROOT)', () => {
|
|
143
|
-
// Static check: the source must derive _FIND_BINARY_ROOT from __dirname so a
|
|
144
|
-
// sibling plugin's CLAUDE_PLUGIN_ROOT can't redirect us to the wrong binary.
|
|
145
|
-
// Memory: feedback_plugin_env_isolation.md.
|
|
146
|
-
const src = fs.readFileSync(LAUNCHER, 'utf8');
|
|
147
|
-
assert.match(src, /_FIND_BINARY_ROOT\s*=\s*path\.resolve\(__dirname/,
|
|
148
|
-
'launcher must derive _FIND_BINARY_ROOT from __dirname, not CLAUDE_PLUGIN_ROOT');
|
|
149
|
-
// And must NOT read CLAUDE_PLUGIN_ROOT from env.
|
|
150
|
-
assert.doesNotMatch(src, /process\.env\.CLAUDE_PLUGIN_ROOT/,
|
|
151
|
-
'launcher must not trust CLAUDE_PLUGIN_ROOT — it can leak from sibling plugins');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test('mcp-launcher rejects executable-permission failure with platform-specific hint', () => {
|
|
155
|
-
// Static check: the macOS quarantine guard must surface xattr/chmod fix
|
|
156
|
-
// commands rather than silently failing on the spawn.
|
|
157
|
-
const src = fs.readFileSync(LAUNCHER, 'utf8');
|
|
158
|
-
assert.match(src, /accessSync\s*\(\s*binary\s*,\s*fs\.constants\.X_OK\s*\)/,
|
|
159
|
-
'launcher must pre-check binary X_OK before spawn');
|
|
160
|
-
assert.match(src, /xattr -d com\.apple\.quarantine/,
|
|
161
|
-
'macOS guard must surface the xattr removal command in stderr');
|
|
162
|
-
});
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const test = require('node:test');
|
|
3
|
-
const assert = require('node:assert/strict');
|
|
4
|
-
const { PassThrough } = require('stream');
|
|
5
|
-
const { EventEmitter } = require('events');
|
|
6
|
-
const { serveEmptyMcpStub, SENTINEL_ID } = require('./mcp-stub');
|
|
7
|
-
|
|
8
|
-
const tick = () => new Promise((r) => setImmediate(r));
|
|
9
|
-
|
|
10
|
-
// A fake child_process: collect what the launcher writes to child.stdin, and
|
|
11
|
-
// let the test push child.stdout lines + emit exit/error.
|
|
12
|
-
function makeFakeChild() {
|
|
13
|
-
const stdin = new PassThrough();
|
|
14
|
-
const stdout = new PassThrough();
|
|
15
|
-
const ee = new EventEmitter();
|
|
16
|
-
const linesToChild = [];
|
|
17
|
-
let sbuf = '';
|
|
18
|
-
stdin.setEncoding('utf8');
|
|
19
|
-
stdin.on('data', (c) => {
|
|
20
|
-
sbuf += c;
|
|
21
|
-
let nl;
|
|
22
|
-
while ((nl = sbuf.indexOf('\n')) >= 0) { linesToChild.push(sbuf.slice(0, nl)); sbuf = sbuf.slice(nl + 1); }
|
|
23
|
-
});
|
|
24
|
-
return {
|
|
25
|
-
stdin, stdout,
|
|
26
|
-
on: (e, cb) => ee.on(e, cb),
|
|
27
|
-
emit: (e, ...a) => ee.emit(e, ...a),
|
|
28
|
-
linesToChild,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function makeRig(upgrade) {
|
|
33
|
-
const input = new PassThrough();
|
|
34
|
-
const out = [];
|
|
35
|
-
const output = { write: (s) => { out.push(s); return true; } };
|
|
36
|
-
let exitCode = null;
|
|
37
|
-
const handle = serveEmptyMcpStub({
|
|
38
|
-
input, output,
|
|
39
|
-
setInterval: () => 0, clearInterval: () => {}, // no real timer; drive attemptUpgrade() manually
|
|
40
|
-
exit: (c) => { exitCode = c; },
|
|
41
|
-
upgrade,
|
|
42
|
-
});
|
|
43
|
-
const send = (obj) => input.write(JSON.stringify(obj) + '\n');
|
|
44
|
-
return { input, out, handle, send, getExit: () => exitCode };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const INIT = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'cc', version: '1' } } };
|
|
48
|
-
|
|
49
|
-
test('permanent stub: 0 tools, listChanged:false, unknown method → -32601', async () => {
|
|
50
|
-
const { out, send } = makeRig(null);
|
|
51
|
-
send(INIT);
|
|
52
|
-
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
53
|
-
send({ jsonrpc: '2.0', id: 3, method: 'nope/nope', params: {} });
|
|
54
|
-
await tick();
|
|
55
|
-
const initResp = JSON.parse(out[0]);
|
|
56
|
-
assert.equal(initResp.result.capabilities.tools.listChanged, false);
|
|
57
|
-
assert.match(initResp.result.serverInfo.name, /dedup/);
|
|
58
|
-
assert.deepEqual(JSON.parse(out[1]).result.tools, []);
|
|
59
|
-
assert.equal(JSON.parse(out[2]).error.code, -32601);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('upgradeable stub advertises tools.listChanged:true', async () => {
|
|
63
|
-
const { out, send } = makeRig({ shouldUpgrade: () => false, spawnReal: () => null });
|
|
64
|
-
send(INIT);
|
|
65
|
-
await tick();
|
|
66
|
-
assert.equal(JSON.parse(out[0]).result.capabilities.tools.listChanged, true);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('upgradeable stub does NOT spawn while shouldUpgrade() is false', async () => {
|
|
70
|
-
let spawnCalls = 0;
|
|
71
|
-
const { handle } = makeRig({ shouldUpgrade: () => false, spawnReal: () => { spawnCalls++; return null; } });
|
|
72
|
-
handle.attemptUpgrade();
|
|
73
|
-
await tick();
|
|
74
|
-
assert.equal(spawnCalls, 0);
|
|
75
|
-
assert.equal(handle._state().hasChild, false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('upgrade handoff: proxies to real binary + emits tools/list_changed + forwards real tools', async () => {
|
|
79
|
-
const child = makeFakeChild();
|
|
80
|
-
let allow = false;
|
|
81
|
-
const { out, send, handle } = makeRig({ shouldUpgrade: () => allow, spawnReal: () => child });
|
|
82
|
-
|
|
83
|
-
// 1. client initialize + tools/list while still a non-project stub
|
|
84
|
-
send(INIT);
|
|
85
|
-
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
86
|
-
await tick();
|
|
87
|
-
assert.equal(JSON.parse(out[0]).result.capabilities.tools.listChanged, true);
|
|
88
|
-
assert.deepEqual(JSON.parse(out[1]).result.tools, []); // stub → empty
|
|
89
|
-
const outLenBeforeUpgrade = out.length;
|
|
90
|
-
|
|
91
|
-
// 2. cwd becomes a project → upgrade fires
|
|
92
|
-
allow = true;
|
|
93
|
-
handle.attemptUpgrade();
|
|
94
|
-
await tick();
|
|
95
|
-
assert.equal(handle._state().hasChild, true);
|
|
96
|
-
// launcher replayed initialize to the child under the sentinel id
|
|
97
|
-
const replay = JSON.parse(child.linesToChild[0]);
|
|
98
|
-
assert.equal(replay.method, 'initialize');
|
|
99
|
-
assert.equal(replay.id, SENTINEL_ID);
|
|
100
|
-
|
|
101
|
-
// 3. child answers the sentinel initialize
|
|
102
|
-
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: { tools: { listChanged: false } }, instructions: 'real server' } }) + '\n');
|
|
103
|
-
await tick();
|
|
104
|
-
// launcher must: send initialized to child, and tools/list_changed to client
|
|
105
|
-
assert.ok(child.linesToChild.some((l) => JSON.parse(l).method === 'notifications/initialized'));
|
|
106
|
-
const newClientMsgs = out.slice(outLenBeforeUpgrade).map((s) => JSON.parse(s));
|
|
107
|
-
assert.ok(newClientMsgs.some((m) => m.method === 'notifications/tools/list_changed'),
|
|
108
|
-
'client must be told tools changed');
|
|
109
|
-
// the sentinel init reply must NOT leak to the client
|
|
110
|
-
assert.ok(!newClientMsgs.some((m) => m.id === SENTINEL_ID), 'sentinel reply must be swallowed');
|
|
111
|
-
|
|
112
|
-
// 4. client re-requests tools/list → forwarded to child (not answered empty)
|
|
113
|
-
const childLinesBefore = child.linesToChild.length;
|
|
114
|
-
send({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} });
|
|
115
|
-
await tick();
|
|
116
|
-
const forwarded = child.linesToChild.slice(childLinesBefore).map((l) => JSON.parse(l));
|
|
117
|
-
assert.ok(forwarded.some((m) => m.id === 3 && m.method === 'tools/list'), 'tools/list forwarded to real binary');
|
|
118
|
-
|
|
119
|
-
// 5. child returns real tools → forwarded verbatim to client
|
|
120
|
-
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 3, result: { tools: [{ name: 'get_call_graph' }, { name: 'semantic_code_search' }] } }) + '\n');
|
|
121
|
-
await tick();
|
|
122
|
-
const toolResp = out.map((s) => JSON.parse(s)).find((m) => m.id === 3);
|
|
123
|
-
assert.ok(toolResp, 'client received a tools/list response');
|
|
124
|
-
assert.deepEqual(toolResp.result.tools.map((t) => t.name), ['get_call_graph', 'semantic_code_search']);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('upgrade with unresolvable binary stays a stub and keeps answering', async () => {
|
|
128
|
-
let allow = true;
|
|
129
|
-
const { out, send, handle } = makeRig({ shouldUpgrade: () => allow, spawnReal: () => null });
|
|
130
|
-
send(INIT);
|
|
131
|
-
await tick();
|
|
132
|
-
handle.attemptUpgrade(); // spawnReal returns null → no child
|
|
133
|
-
await tick();
|
|
134
|
-
assert.equal(handle._state().hasChild, false);
|
|
135
|
-
send({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: {} });
|
|
136
|
-
await tick();
|
|
137
|
-
const resp = out.map((s) => JSON.parse(s)).find((m) => m.id === 9);
|
|
138
|
-
assert.deepEqual(resp.result.tools, []); // still served by the stub
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test('upgrade: a request in the handoff window is queued then flushed to the child in order', async () => {
|
|
142
|
-
const child = makeFakeChild();
|
|
143
|
-
const { send, handle } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
|
|
144
|
-
send(INIT);
|
|
145
|
-
await tick();
|
|
146
|
-
handle.attemptUpgrade(); // child spawned; sentinel initialize sent; NOT ready yet
|
|
147
|
-
await tick();
|
|
148
|
-
// client sends a call while the child is mid-handshake → must be queued, not forwarded
|
|
149
|
-
send({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'x' } });
|
|
150
|
-
await tick();
|
|
151
|
-
assert.equal(child.linesToChild.length, 1);
|
|
152
|
-
assert.equal(JSON.parse(child.linesToChild[0]).id, SENTINEL_ID);
|
|
153
|
-
// child completes its handshake → queued request flushes AFTER initialized
|
|
154
|
-
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: {} } }) + '\n');
|
|
155
|
-
await tick();
|
|
156
|
-
const methods = child.linesToChild.map((l) => { const m = JSON.parse(l); return m.method || `id:${m.id}`; });
|
|
157
|
-
assert.deepEqual(methods, ['initialize', 'notifications/initialized', 'tools/call']);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test('upgrade: child that exits before ready falls back to stub and answers queued requests', async () => {
|
|
161
|
-
const child = makeFakeChild();
|
|
162
|
-
const { out, send, handle } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
|
|
163
|
-
send(INIT);
|
|
164
|
-
await tick();
|
|
165
|
-
handle.attemptUpgrade(); // child spawned, not ready
|
|
166
|
-
await tick();
|
|
167
|
-
send({ jsonrpc: '2.0', id: 8, method: 'tools/list', params: {} }); // queued during handoff
|
|
168
|
-
await tick();
|
|
169
|
-
child.emit('exit', 1, null); // child dies BEFORE the sentinel reply
|
|
170
|
-
await tick();
|
|
171
|
-
assert.equal(handle._state().hasChild, false); // reverted to stub
|
|
172
|
-
const resp = out.map((s) => JSON.parse(s)).find((m) => m.id === 8);
|
|
173
|
-
assert.ok(resp, 'queued request got a stub answer (client not left hanging)');
|
|
174
|
-
assert.deepEqual(resp.result.tools, []);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test('upgrade: error on a READY child exits instead of reverting to a flapping stub', async () => {
|
|
178
|
-
const child = makeFakeChild();
|
|
179
|
-
const { send, handle, getExit } = makeRig({ shouldUpgrade: () => true, spawnReal: () => child });
|
|
180
|
-
send(INIT);
|
|
181
|
-
await tick();
|
|
182
|
-
handle.attemptUpgrade();
|
|
183
|
-
await tick();
|
|
184
|
-
child.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: SENTINEL_ID, result: { capabilities: {} } }) + '\n');
|
|
185
|
-
await tick();
|
|
186
|
-
assert.equal(handle._state().childReady, true);
|
|
187
|
-
child.emit('error', new Error('boom')); // a READY child errors
|
|
188
|
-
await tick();
|
|
189
|
-
assert.equal(handle._state().hasChild, true, 'must NOT revert to stub (that would flap)');
|
|
190
|
-
assert.equal(getExit(), 1, 'ready-child error exits the launcher like the binary died');
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test('poller is wired to attemptUpgrade at the default interval', () => {
|
|
194
|
-
const input = new PassThrough();
|
|
195
|
-
const out = [];
|
|
196
|
-
let captured = null, spawnCalls = 0;
|
|
197
|
-
serveEmptyMcpStub({
|
|
198
|
-
input, output: { write: (s) => out.push(s) },
|
|
199
|
-
setInterval: (fn, ms) => { captured = { fn, ms }; return 1; },
|
|
200
|
-
clearInterval: () => {}, exit: () => {},
|
|
201
|
-
upgrade: { shouldUpgrade: () => true, spawnReal: () => { spawnCalls++; return null; } },
|
|
202
|
-
});
|
|
203
|
-
assert.ok(captured, 'poller started via setInterval');
|
|
204
|
-
assert.equal(captured.ms, 4000); // DEFAULT_POLL_MS
|
|
205
|
-
captured.fn(); // simulate one poll tick
|
|
206
|
-
assert.equal(spawnCalls, 1); // tick attempted the upgrade
|
|
207
|
-
});
|