@arii/boomtick-mcp 0.3.6 → 0.4.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/.mcp/schemas/github.create_issue.json +13 -0
- package/.mcp/schemas/github.create_pull_request.json +13 -0
- package/CHANGELOG.md +16 -0
- package/actions/ai-review/action.yml +1 -1
- package/actions/impact-analysis/action.yml +98 -35
- package/dist/config.test.js +115 -0
- package/dist/lib/error_utils.test.js +23 -0
- package/dist/lib/result.test.js +45 -0
- package/dist/mcp/server.test.js +217 -0
- package/dist/tools/contract.js +28 -2
- package/dist/tools/github.create_issue.js +2 -0
- package/dist/tools/github.create_issue.test.js +39 -3
- package/dist/tools/github.create_pull_request.js +11 -13
- package/dist/tools/github.create_pull_request.test.js +93 -0
- package/dist/tools/jules/shared.test.js +58 -0
- package/dist/tools/repo.get_package_scripts.test.js +4 -0
- package/package.json +1 -1
- package/src/config.test.ts +146 -0
- package/src/lib/error_utils.test.ts +27 -0
- package/src/lib/result.test.ts +48 -0
- package/src/mcp/server.test.ts +273 -0
- package/src/tools/contract.ts +28 -2
- package/src/tools/github.create_issue.test.ts +43 -3
- package/src/tools/github.create_issue.ts +2 -1
- package/src/tools/github.create_pull_request.test.ts +105 -0
- package/src/tools/github.create_pull_request.ts +14 -13
- package/src/tools/jules/shared.test.ts +69 -0
- package/src/tools/repo.get_package_scripts.test.ts +6 -0
|
@@ -30,6 +30,19 @@
|
|
|
30
30
|
],
|
|
31
31
|
"default": null,
|
|
32
32
|
"title": "File"
|
|
33
|
+
},
|
|
34
|
+
"repo": {
|
|
35
|
+
"anyOf": [
|
|
36
|
+
{
|
|
37
|
+
"type": "string"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"type": "null"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"default": null,
|
|
44
|
+
"description": "The target repository override (e.g. org/repo).",
|
|
45
|
+
"title": "Repo"
|
|
33
46
|
}
|
|
34
47
|
},
|
|
35
48
|
"required": [
|
|
@@ -26,6 +26,19 @@
|
|
|
26
26
|
"description": "Whether to create the PR as a draft.",
|
|
27
27
|
"title": "Draft",
|
|
28
28
|
"type": "boolean"
|
|
29
|
+
},
|
|
30
|
+
"repo": {
|
|
31
|
+
"anyOf": [
|
|
32
|
+
{
|
|
33
|
+
"type": "string"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"type": "null"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"default": null,
|
|
40
|
+
"description": "The target repository override (e.g. org/repo).",
|
|
41
|
+
"title": "Repo"
|
|
29
42
|
}
|
|
30
43
|
},
|
|
31
44
|
"required": [
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0](https://github.com/arii/boomtick/compare/mcp-v0.3.7...mcp-v0.4.0) (2026-09-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **impact:** add targeted route and viewport options for visual diff… ([#775](https://github.com/arii/boomtick/issues/775)) ([c767fd4](https://github.com/arii/boomtick/commit/c767fd47063ce6a1078a728c3af79bcf18e2c5c5))
|
|
9
|
+
|
|
10
|
+
## [0.3.7](https://github.com/arii/boomtick/compare/mcp-v0.3.6...mcp-v0.3.7) (2026-08-17)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **ci:** remove submodule-pinned action refs from impact-analysis ([#723](https://github.com/arii/boomtick/issues/723)) ([d6e0494](https://github.com/arii/boomtick/commit/d6e0494fe8ac781777fb1546bb3c0f0102074a5c))
|
|
16
|
+
* ensure impact analysis posts PR comments and sends Jules messages ([#724](https://github.com/arii/boomtick/issues/724)) ([a52cc8e](https://github.com/arii/boomtick/commit/a52cc8e220def8bf2057b4da1b596e80fc0eed1f))
|
|
17
|
+
* resolve script fallback paths in mcp impact-analysis action ([#716](https://github.com/arii/boomtick/issues/716)) ([b29dd06](https://github.com/arii/boomtick/commit/b29dd067da2b45fc8410ca26e34a8c74626fe937))
|
|
18
|
+
|
|
3
19
|
## [0.3.6](https://github.com/arii/boomtick/compare/mcp-v0.3.5...mcp-v0.3.6) (2026-08-13)
|
|
4
20
|
|
|
5
21
|
|
|
@@ -16,10 +16,8 @@ inputs:
|
|
|
16
16
|
runs:
|
|
17
17
|
using: composite
|
|
18
18
|
steps:
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
- name: pnpm install
|
|
22
|
-
run: pnpm install --frozen-lockfile --prefer-offline
|
|
19
|
+
- name: Configure Git Safe Directory
|
|
20
|
+
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
|
23
21
|
shell: bash
|
|
24
22
|
|
|
25
23
|
- name: Validate PR Number
|
|
@@ -173,8 +171,13 @@ runs:
|
|
|
173
171
|
echo "Executing check-visual-changes.ts at: $RESOLVED_PATH"
|
|
174
172
|
pnpm exec tsx "$RESOLVED_PATH" >> "$GITHUB_OUTPUT"
|
|
175
173
|
else
|
|
176
|
-
|
|
177
|
-
|
|
174
|
+
if [ -f "scripts/check-visual-changes.ts" ]; then
|
|
175
|
+
echo "Executing check-visual-changes.ts from current workspace."
|
|
176
|
+
pnpm exec tsx scripts/check-visual-changes.ts >> "$GITHUB_OUTPUT"
|
|
177
|
+
else
|
|
178
|
+
echo "⚠️ Skipping: check-visual-changes.ts script not found. Defaulting changed_routes=0"
|
|
179
|
+
echo "changed_routes=0" >> "$GITHUB_OUTPUT"
|
|
180
|
+
fi
|
|
178
181
|
fi
|
|
179
182
|
shell: bash
|
|
180
183
|
|
|
@@ -194,21 +197,6 @@ runs:
|
|
|
194
197
|
fi
|
|
195
198
|
shell: bash
|
|
196
199
|
|
|
197
|
-
- name: Visual review github models agent
|
|
198
|
-
if: always() && steps.check_visuals.outputs.changed_routes != '0' && steps.check_visuals.outputs.changed_routes != ''
|
|
199
|
-
env:
|
|
200
|
-
GITHUB_TOKEN: ${{ inputs.github_token }}
|
|
201
|
-
PR_NUMBER: ${{ inputs.pr_number }}
|
|
202
|
-
JULES_API_KEY: ${{ inputs.jules_api_key }}
|
|
203
|
-
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
204
|
-
run: |
|
|
205
|
-
if node -e "const pkg = require('./package.json'); if (!pkg.scripts || !pkg.scripts['impact:github-models-review']) process.exit(1)" 2>/dev/null; then
|
|
206
|
-
pnpm run impact:github-models-review
|
|
207
|
-
else
|
|
208
|
-
echo "⚠️ Skipping: impact:github-models-review script not found"
|
|
209
|
-
fi
|
|
210
|
-
shell: bash
|
|
211
|
-
|
|
212
200
|
- name: Report Summary
|
|
213
201
|
if: always()
|
|
214
202
|
run: |
|
|
@@ -241,26 +229,20 @@ runs:
|
|
|
241
229
|
if [ -f artifacts/gemini-review.md ]; then
|
|
242
230
|
{ echo ""; cat artifacts/gemini-review.md; } >> "$GITHUB_STEP_SUMMARY"
|
|
243
231
|
fi
|
|
244
|
-
if [ -f artifacts/github-models-review.md ]; then
|
|
245
|
-
{ echo ""; cat artifacts/github-models-review.md; } >> "$GITHUB_STEP_SUMMARY"
|
|
246
|
-
fi
|
|
247
232
|
if [ -f artifacts/gemini-code-review.md ]; then
|
|
248
233
|
{ echo ""; cat artifacts/gemini-code-review.md; } >> "$GITHUB_STEP_SUMMARY"
|
|
249
234
|
fi
|
|
250
|
-
if [ -f artifacts/github-models-code-review.md ]; then
|
|
251
|
-
{ echo ""; cat artifacts/github-models-code-review.md; } >> "$GITHUB_STEP_SUMMARY"
|
|
252
|
-
fi
|
|
253
235
|
|
|
254
236
|
FAILED=0
|
|
255
|
-
# Expected verdicts: gemini-code-review
|
|
237
|
+
# Expected verdicts: gemini-code-review
|
|
256
238
|
# Visual reviews are expected only if visual changes exist
|
|
257
239
|
EXPECTED_REVIEWS=""
|
|
258
240
|
if [ "${{ steps.impact_analysis.outputs.has_impact }}" == "true" ]; then
|
|
259
241
|
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
|
260
|
-
EXPECTED_REVIEWS="gemini-code-review
|
|
242
|
+
EXPECTED_REVIEWS="gemini-code-review"
|
|
261
243
|
fi
|
|
262
244
|
if [ "${{ steps.check_visuals.outputs.changed_routes }}" != "0" ] && [ "${{ steps.check_visuals.outputs.changed_routes }}" != "" ]; then
|
|
263
|
-
EXPECTED_REVIEWS="$EXPECTED_REVIEWS gemini-review
|
|
245
|
+
EXPECTED_REVIEWS="$EXPECTED_REVIEWS gemini-review"
|
|
264
246
|
fi
|
|
265
247
|
fi
|
|
266
248
|
|
|
@@ -328,19 +310,100 @@ runs:
|
|
|
328
310
|
if [ -f "$TARGET_PATH" ]; then
|
|
329
311
|
RESOLVED_PATH=$(realpath "$TARGET_PATH")
|
|
330
312
|
echo "Executing send-jules-impact.py at: $RESOLVED_PATH"
|
|
313
|
+
export PYTHONPATH="$(realpath "${{ github.action_path }}/../../../cli"):$PYTHONPATH"
|
|
331
314
|
python3 "$RESOLVED_PATH"
|
|
332
315
|
else
|
|
333
|
-
|
|
316
|
+
# Fallback check for docker run execution or direct python script in checkout workspace
|
|
317
|
+
# Inside container execution, scripts/send-jules-impact.py should exist in workspace
|
|
318
|
+
if [ -f "scripts/send-jules-impact.py" ]; then
|
|
319
|
+
echo "Executing send-jules-impact.py from current workspace."
|
|
320
|
+
export PYTHONPATH="cli:$PYTHONPATH"
|
|
321
|
+
python3 scripts/send-jules-impact.py
|
|
322
|
+
else
|
|
323
|
+
echo "⚠️ Skipping: send-jules-impact.py script not found."
|
|
324
|
+
fi
|
|
334
325
|
fi
|
|
335
326
|
shell: bash
|
|
336
327
|
|
|
337
328
|
- name: Update Impact Analysis Feedback (Final Result)
|
|
338
329
|
if: always()
|
|
339
|
-
uses:
|
|
330
|
+
uses: actions/github-script@v8 # nosemgrep: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
|
|
331
|
+
env:
|
|
332
|
+
INPUT_STATUS_LINE: '✅ **Impact Analysis Complete**'
|
|
333
|
+
INPUT_IMPACT_BODY: ${{ env.IMPACT_BODY }}
|
|
340
334
|
with:
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
335
|
+
github-token: ${{ inputs.github_token }}
|
|
336
|
+
script: |
|
|
337
|
+
const owner = context.repo.owner;
|
|
338
|
+
const repo = context.repo.repo;
|
|
339
|
+
const branchName = context.ref.replace('refs/heads/', '');
|
|
340
|
+
|
|
341
|
+
const pstTime = new Date().toLocaleString('en-US', {
|
|
342
|
+
timeZone: 'America/Los_Angeles',
|
|
343
|
+
dateStyle: 'medium',
|
|
344
|
+
timeStyle: 'short'
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (branchName === 'main') return;
|
|
348
|
+
|
|
349
|
+
const pullRequests = await github.paginate(github.rest.pulls.list, {
|
|
350
|
+
owner,
|
|
351
|
+
repo,
|
|
352
|
+
state: 'open'
|
|
353
|
+
});
|
|
354
|
+
const matchingPRs = pullRequests.filter(pr => pr.head.ref === branchName);
|
|
355
|
+
|
|
356
|
+
const statusLine = process.env.INPUT_STATUS_LINE;
|
|
357
|
+
let impactBodyText = (process.env.INPUT_IMPACT_BODY || '')
|
|
358
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '[REMOVED SCRIPT]');
|
|
359
|
+
|
|
360
|
+
for (const pr of matchingPRs) {
|
|
361
|
+
const commentTag = `<!-- impact-analysis-comment-${branchName} -->`;
|
|
362
|
+
let bodyParts = [
|
|
363
|
+
`🚀 **Impact Analysis Details** (Last updated: ${pstTime} PST)`,
|
|
364
|
+
'',
|
|
365
|
+
statusLine,
|
|
366
|
+
`- **Commit SHA:** \`${process.env.GITHUB_SHA.slice(0, 7)}\``,
|
|
367
|
+
`- [Link to artifacts](https://github.com/${owner}/${repo}/actions/runs/${context.runId})`,
|
|
368
|
+
''
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
if (impactBodyText) {
|
|
372
|
+
bodyParts.push(impactBodyText, '');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const MAX_LENGTH = 65536;
|
|
376
|
+
const truncationNotice = '\n\n... (truncated due to GitHub comment size limits)\n';
|
|
377
|
+
let currentContent = bodyParts.join('\n');
|
|
378
|
+
let projectedLength = currentContent.length + commentTag.length + 1;
|
|
379
|
+
|
|
380
|
+
if (projectedLength > MAX_LENGTH) {
|
|
381
|
+
const availableLength = MAX_LENGTH - commentTag.length - truncationNotice.length - 2;
|
|
382
|
+
let safeContent = currentContent.slice(0, availableLength);
|
|
383
|
+
let lastNewline = safeContent.lastIndexOf('\n');
|
|
384
|
+
if (lastNewline > availableLength * 0.9) {
|
|
385
|
+
safeContent = safeContent.slice(0, lastNewline);
|
|
386
|
+
}
|
|
387
|
+
bodyParts = [safeContent, truncationNotice];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
bodyParts.push(commentTag);
|
|
391
|
+
const body = bodyParts.join('\n');
|
|
392
|
+
|
|
393
|
+
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
394
|
+
owner,
|
|
395
|
+
repo,
|
|
396
|
+
issue_number: pr.number,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const existingComment = comments.find(c => c.body.includes(commentTag));
|
|
400
|
+
|
|
401
|
+
if (existingComment) {
|
|
402
|
+
await github.rest.issues.updateComment({ owner, repo, comment_id: existingComment.id, body });
|
|
403
|
+
} else {
|
|
404
|
+
await github.rest.issues.createComment({ issue_number: pr.number, owner, repo, body });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
344
407
|
|
|
345
408
|
- name: Merge AI Logs into Artifacts
|
|
346
409
|
if: always()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
vi.mock("child_process", () => ({
|
|
4
|
+
execSync: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
describe("config", () => {
|
|
7
|
+
const originalEnv = process.env;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
process.env = { ...originalEnv };
|
|
11
|
+
vi.mocked(execSync).mockReset();
|
|
12
|
+
vi.mocked(execSync).mockReturnValue("{}");
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
process.env = originalEnv;
|
|
16
|
+
});
|
|
17
|
+
describe("initializeConfig", () => {
|
|
18
|
+
it("should load dynamic config from td-cli config view", async () => {
|
|
19
|
+
const mockConfig = { github_repo: "owner/repo", base_branch: "origin/main", vite_base_path: "/app/" };
|
|
20
|
+
vi.mocked(execSync).mockReturnValue(JSON.stringify(mockConfig));
|
|
21
|
+
const { initializeConfig } = await import("./config.js");
|
|
22
|
+
const result = initializeConfig();
|
|
23
|
+
expect(execSync).toHaveBeenCalledWith("td-cli config view", expect.anything());
|
|
24
|
+
expect(result).toEqual(mockConfig);
|
|
25
|
+
});
|
|
26
|
+
it("should return cached dynamic config on subsequent calls", async () => {
|
|
27
|
+
const mockConfig = { github_repo: "owner/repo" };
|
|
28
|
+
vi.mocked(execSync).mockReturnValue(JSON.stringify(mockConfig));
|
|
29
|
+
const { initializeConfig } = await import("./config.js");
|
|
30
|
+
const result1 = initializeConfig();
|
|
31
|
+
const result2 = initializeConfig();
|
|
32
|
+
expect(result1).toBe(result2);
|
|
33
|
+
expect(execSync).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
it("should throw critical error when execSync fails in development or CI", async () => {
|
|
36
|
+
process.env.NODE_ENV = "development";
|
|
37
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
38
|
+
throw new Error("Command failed");
|
|
39
|
+
});
|
|
40
|
+
const { initializeConfig } = await import("./config.js");
|
|
41
|
+
expect(() => initializeConfig()).toThrow(/CRITICAL: Failed to load dynamic config/);
|
|
42
|
+
});
|
|
43
|
+
it("should log warning and set empty config when execSync fails in production", async () => {
|
|
44
|
+
process.env.NODE_ENV = "production";
|
|
45
|
+
process.env.CI = "false";
|
|
46
|
+
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
47
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
48
|
+
throw new Error("Command failed");
|
|
49
|
+
});
|
|
50
|
+
const { initializeConfig } = await import("./config.js");
|
|
51
|
+
const result = initializeConfig();
|
|
52
|
+
expect(result).toEqual({});
|
|
53
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("CRITICAL: Failed to load dynamic config"));
|
|
54
|
+
consoleWarnSpy.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("config object getters", () => {
|
|
58
|
+
it("should resolve githubToken from process.env or gh CLI fallback", async () => {
|
|
59
|
+
process.env.GITHUB_TOKEN = "env-token-123";
|
|
60
|
+
const { config } = await import("./config.js");
|
|
61
|
+
expect(config.githubToken).toBe("env-token-123");
|
|
62
|
+
delete process.env.GITHUB_TOKEN;
|
|
63
|
+
vi.mocked(execSync).mockReturnValue("gh-cli-token\n");
|
|
64
|
+
expect(config.githubToken).toBe("gh-cli-token");
|
|
65
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
66
|
+
throw new Error("gh not found");
|
|
67
|
+
});
|
|
68
|
+
expect(config.githubToken).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
it("should resolve githubOwner and githubRepo from env or cached config", async () => {
|
|
71
|
+
process.env.GITHUB_OWNER = "custom-owner";
|
|
72
|
+
process.env.GITHUB_REPO = "custom-repo";
|
|
73
|
+
const { config } = await import("./config.js");
|
|
74
|
+
expect(config.githubOwner).toBe("custom-owner");
|
|
75
|
+
expect(config.githubRepo).toBe("custom-repo");
|
|
76
|
+
});
|
|
77
|
+
it("should throw when githubOwner / githubRepo cannot be determined", async () => {
|
|
78
|
+
delete process.env.GITHUB_OWNER;
|
|
79
|
+
delete process.env.GITHUB_REPO;
|
|
80
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
81
|
+
throw new Error("td-cli failed");
|
|
82
|
+
});
|
|
83
|
+
process.env.NODE_ENV = "production";
|
|
84
|
+
process.env.CI = "false";
|
|
85
|
+
vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
86
|
+
const { config, initializeConfig } = await import("./config.js");
|
|
87
|
+
initializeConfig();
|
|
88
|
+
expect(() => config.githubOwner).toThrow("GITHUB_OWNER must be set via environment variable or project_config.json");
|
|
89
|
+
expect(() => config.githubRepo).toThrow("GITHUB_REPO must be set via environment variable or project_config.json");
|
|
90
|
+
});
|
|
91
|
+
it("should resolve repoPath from BOOMTICK_REPO_PATH or findRepoRoot fallback", async () => {
|
|
92
|
+
process.env.BOOMTICK_REPO_PATH = "/custom/repo/path";
|
|
93
|
+
const { config } = await import("./config.js");
|
|
94
|
+
expect(config.repoPath).toBe("/custom/repo/path");
|
|
95
|
+
});
|
|
96
|
+
it("should resolve defaultBaseBranch, viteBasePath, and ghPath", async () => {
|
|
97
|
+
process.env.DEFAULT_BASE_BRANCH = "develop";
|
|
98
|
+
process.env.VITE_BASE_PATH = "/base/";
|
|
99
|
+
process.env.GH_PATH = "/usr/bin/gh";
|
|
100
|
+
const { config } = await import("./config.js");
|
|
101
|
+
expect(config.defaultBaseBranch).toBe("develop");
|
|
102
|
+
expect(config.viteBasePath).toBe("/base/");
|
|
103
|
+
expect(config.ghPath).toBe("/usr/bin/gh");
|
|
104
|
+
});
|
|
105
|
+
it("should throw when viteBasePath is missing", async () => {
|
|
106
|
+
delete process.env.VITE_BASE_PATH;
|
|
107
|
+
process.env.NODE_ENV = "production";
|
|
108
|
+
process.env.CI = "false";
|
|
109
|
+
vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
110
|
+
const { config, initializeConfig } = await import("./config.js");
|
|
111
|
+
initializeConfig();
|
|
112
|
+
expect(() => config.viteBasePath).toThrow("VITE_BASE_PATH must be set via environment variable or project_config.json");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { sanitizeError } from "./error_utils.js";
|
|
3
|
+
describe("error_utils", () => {
|
|
4
|
+
describe("sanitizeError", () => {
|
|
5
|
+
it("should return 'Unknown error' when stderr is empty", () => {
|
|
6
|
+
expect(sanitizeError("")).toBe("Unknown error");
|
|
7
|
+
});
|
|
8
|
+
it("should return only the first line of stderr", () => {
|
|
9
|
+
const stderr = "Error: Something failed\n at StackTrace.line1\n at StackTrace.line2";
|
|
10
|
+
expect(sanitizeError(stderr)).toBe("Error: Something failed");
|
|
11
|
+
});
|
|
12
|
+
it("should truncate lines exceeding 200 characters", () => {
|
|
13
|
+
const longLine = "A".repeat(250);
|
|
14
|
+
const result = sanitizeError(longLine);
|
|
15
|
+
expect(result.length).toBe(200);
|
|
16
|
+
expect(result).toBe("A".repeat(200));
|
|
17
|
+
});
|
|
18
|
+
it("should preserve first line if under 200 characters", () => {
|
|
19
|
+
const line = "Fatal error: unexpected token near line 5";
|
|
20
|
+
expect(sanitizeError(line)).toBe(line);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createSuccessResult, createErrorResult } from "./result.js";
|
|
3
|
+
describe("result utils", () => {
|
|
4
|
+
describe("createSuccessResult", () => {
|
|
5
|
+
it("should format string data directly without JSON stringifying", () => {
|
|
6
|
+
const result = createSuccessResult("simple string message");
|
|
7
|
+
expect(result).toEqual({
|
|
8
|
+
content: [
|
|
9
|
+
{
|
|
10
|
+
type: "text",
|
|
11
|
+
text: "simple string message",
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
});
|
|
15
|
+
expect(result.isError).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it("should format object data as pretty-printed JSON string", () => {
|
|
18
|
+
const objData = { foo: "bar", count: 42 };
|
|
19
|
+
const result = createSuccessResult(objData);
|
|
20
|
+
expect(result).toEqual({
|
|
21
|
+
content: [
|
|
22
|
+
{
|
|
23
|
+
type: "text",
|
|
24
|
+
text: JSON.stringify(objData, null, 2),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
expect(result.isError).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("createErrorResult", () => {
|
|
32
|
+
it("should format error result with message and isError set to true", () => {
|
|
33
|
+
const result = createErrorResult("Something went wrong");
|
|
34
|
+
expect(result).toEqual({
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "text",
|
|
38
|
+
text: "Something went wrong",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
isError: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { BoomtickMCPServer } from "./server.js";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import * as healthModule from "./tools.js";
|
|
5
|
+
import * as routeMapModule from "../tools/repo.get_route_map.js";
|
|
6
|
+
import * as prDiffModule from "../tools/github.get_pr_diff.js";
|
|
7
|
+
import * as ciLogsModule from "../tools/repo.read_ci_logs.js";
|
|
8
|
+
import * as lighthouseModule from "../tools/repo.run_lighthouse.js";
|
|
9
|
+
import * as playwrightModule from "../tools/repo.run_playwright.js";
|
|
10
|
+
vi.mock("fs/promises");
|
|
11
|
+
vi.mock("../config.js", () => ({
|
|
12
|
+
config: {
|
|
13
|
+
repoPath: "/mock/repo",
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
describe("BoomtickMCPServer", () => {
|
|
17
|
+
let mcpServer;
|
|
18
|
+
let serverInstance;
|
|
19
|
+
let requestHandlers;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.resetAllMocks();
|
|
22
|
+
requestHandlers = new Map();
|
|
23
|
+
mcpServer = new BoomtickMCPServer();
|
|
24
|
+
serverInstance = mcpServer.server;
|
|
25
|
+
// Retrieve registered request handlers from SDK Server instance
|
|
26
|
+
// SDK Server stores request handlers in server._requestHandlers or server.requestHandlers
|
|
27
|
+
const handlers = serverInstance._requestHandlers || serverInstance.requestHandlers;
|
|
28
|
+
if (handlers) {
|
|
29
|
+
for (const [key, handler] of handlers.entries()) {
|
|
30
|
+
requestHandlers.set(key, handler);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
it("should instantiate server with correct name and capabilities", () => {
|
|
35
|
+
expect(serverInstance).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
describe("ListPrompts", () => {
|
|
38
|
+
it("should return prompts list", async () => {
|
|
39
|
+
const handler = requestHandlers.get("prompts/list");
|
|
40
|
+
expect(handler).toBeDefined();
|
|
41
|
+
const result = await handler({ method: "prompts/list" });
|
|
42
|
+
expect(result.prompts).toBeDefined();
|
|
43
|
+
expect(Array.isArray(result.prompts)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("GetPrompt", () => {
|
|
47
|
+
it("should read prompt file and return message", async () => {
|
|
48
|
+
const handler = requestHandlers.get("prompts/get");
|
|
49
|
+
expect(handler).toBeDefined();
|
|
50
|
+
vi.mocked(fs.readFile).mockResolvedValue("You are a helpful agent.");
|
|
51
|
+
const result = await handler({
|
|
52
|
+
method: "prompts/get",
|
|
53
|
+
params: { name: "test-agent" },
|
|
54
|
+
});
|
|
55
|
+
expect(fs.readFile).toHaveBeenCalledWith("/mock/repo/mcp/src/agents/test-agent.prompt.md", "utf-8");
|
|
56
|
+
expect(result).toEqual({
|
|
57
|
+
messages: [
|
|
58
|
+
{
|
|
59
|
+
role: "user",
|
|
60
|
+
content: { type: "text", text: "You are a helpful agent." },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
it("should prevent path traversal attacks", async () => {
|
|
66
|
+
const handler = requestHandlers.get("prompts/get");
|
|
67
|
+
await expect(handler({
|
|
68
|
+
method: "prompts/get",
|
|
69
|
+
params: { name: "../../../etc/passwd" },
|
|
70
|
+
})).rejects.toThrow("Path traversal detected");
|
|
71
|
+
});
|
|
72
|
+
it("should throw when prompt file does not exist", async () => {
|
|
73
|
+
const handler = requestHandlers.get("prompts/get");
|
|
74
|
+
vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found"));
|
|
75
|
+
await expect(handler({
|
|
76
|
+
method: "prompts/get",
|
|
77
|
+
params: { name: "nonexistent" },
|
|
78
|
+
})).rejects.toThrow("Prompt not found: nonexistent");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("ListResources", () => {
|
|
82
|
+
it("should return resources list", async () => {
|
|
83
|
+
const handler = requestHandlers.get("resources/list");
|
|
84
|
+
expect(handler).toBeDefined();
|
|
85
|
+
const result = await handler({ method: "resources/list" });
|
|
86
|
+
expect(result.resources).toBeDefined();
|
|
87
|
+
expect(Array.isArray(result.resources)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("ReadResource", () => {
|
|
91
|
+
it("should handle repo://package-json", async () => {
|
|
92
|
+
const handler = requestHandlers.get("resources/read");
|
|
93
|
+
vi.mocked(fs.readFile).mockResolvedValue('{"name": "test"}');
|
|
94
|
+
const result = await handler({
|
|
95
|
+
method: "resources/read",
|
|
96
|
+
params: { uri: "repo://package-json" },
|
|
97
|
+
});
|
|
98
|
+
expect(result).toEqual({
|
|
99
|
+
contents: [{ uri: "repo://package-json", mimeType: "application/json", text: '{"name": "test"}' }],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it("should handle repo://routes", async () => {
|
|
103
|
+
const handler = requestHandlers.get("resources/read");
|
|
104
|
+
vi.spyOn(routeMapModule, "getRouteMapHandler").mockResolvedValue({ routes: ["/"] });
|
|
105
|
+
const result = await handler({
|
|
106
|
+
method: "resources/read",
|
|
107
|
+
params: { uri: "repo://routes" },
|
|
108
|
+
});
|
|
109
|
+
expect(result.contents[0].uri).toBe("repo://routes");
|
|
110
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ routes: ["/"] });
|
|
111
|
+
});
|
|
112
|
+
it("should handle repo://design-tokens", async () => {
|
|
113
|
+
const handler = requestHandlers.get("resources/read");
|
|
114
|
+
vi.mocked(fs.readFile).mockResolvedValue("export const tokens = {};");
|
|
115
|
+
const result = await handler({
|
|
116
|
+
method: "resources/read",
|
|
117
|
+
params: { uri: "repo://design-tokens" },
|
|
118
|
+
});
|
|
119
|
+
expect(result).toEqual({
|
|
120
|
+
contents: [{ uri: "repo://design-tokens", mimeType: "text/typescript", text: "export const tokens = {};" }],
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
it("should handle repo://diff/:prNumber", async () => {
|
|
124
|
+
const handler = requestHandlers.get("resources/read");
|
|
125
|
+
vi.spyOn(prDiffModule, "getPrDiffHandler").mockResolvedValue({ diffText: "diff --git a b" });
|
|
126
|
+
const result = await handler({
|
|
127
|
+
method: "resources/read",
|
|
128
|
+
params: { uri: "repo://diff/42" },
|
|
129
|
+
});
|
|
130
|
+
expect(result).toEqual({
|
|
131
|
+
contents: [{ uri: "repo://diff/42", mimeType: "text/plain", text: "diff --git a b" }],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
it("should handle repo://ci/:prNumber", async () => {
|
|
135
|
+
const handler = requestHandlers.get("resources/read");
|
|
136
|
+
vi.spyOn(ciLogsModule, "readCiLogsHandler").mockResolvedValue({ status: "success" });
|
|
137
|
+
const result = await handler({
|
|
138
|
+
method: "resources/read",
|
|
139
|
+
params: { uri: "repo://ci/42" },
|
|
140
|
+
});
|
|
141
|
+
expect(result.contents[0].uri).toBe("repo://ci/42");
|
|
142
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ status: "success" });
|
|
143
|
+
});
|
|
144
|
+
it("should handle repo://lighthouse/:branch", async () => {
|
|
145
|
+
const handler = requestHandlers.get("resources/read");
|
|
146
|
+
vi.spyOn(lighthouseModule, "runLighthouseHandler").mockResolvedValue({ score: 100 });
|
|
147
|
+
const result = await handler({
|
|
148
|
+
method: "resources/read",
|
|
149
|
+
params: { uri: "repo://lighthouse/feature-x" },
|
|
150
|
+
});
|
|
151
|
+
expect(result.contents[0].uri).toBe("repo://lighthouse/feature-x");
|
|
152
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ score: 100 });
|
|
153
|
+
});
|
|
154
|
+
it("should handle repo://playwright/:branch", async () => {
|
|
155
|
+
const handler = requestHandlers.get("resources/read");
|
|
156
|
+
vi.spyOn(playwrightModule, "runPlaywrightHandler").mockResolvedValue({ passed: true });
|
|
157
|
+
const result = await handler({
|
|
158
|
+
method: "resources/read",
|
|
159
|
+
params: { uri: "repo://playwright/feature-x" },
|
|
160
|
+
});
|
|
161
|
+
expect(result.contents[0].uri).toBe("repo://playwright/feature-x");
|
|
162
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ passed: true });
|
|
163
|
+
});
|
|
164
|
+
it("should throw when resource is unknown", async () => {
|
|
165
|
+
const handler = requestHandlers.get("resources/read");
|
|
166
|
+
await expect(handler({
|
|
167
|
+
method: "resources/read",
|
|
168
|
+
params: { uri: "repo://unknown" },
|
|
169
|
+
})).rejects.toThrow("Resource not found: repo://unknown");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe("ListTools", () => {
|
|
173
|
+
it("should return tools list", async () => {
|
|
174
|
+
const handler = requestHandlers.get("tools/list");
|
|
175
|
+
expect(handler).toBeDefined();
|
|
176
|
+
const result = await handler({ method: "tools/list" });
|
|
177
|
+
expect(result.tools).toBeDefined();
|
|
178
|
+
expect(Array.isArray(result.tools)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe("CallTool", () => {
|
|
182
|
+
it("should execute registered tool handler successfully", async () => {
|
|
183
|
+
const handler = requestHandlers.get("tools/call");
|
|
184
|
+
vi.spyOn(healthModule, "healthHandler").mockResolvedValue({ status: "ok" });
|
|
185
|
+
const result = await handler({
|
|
186
|
+
method: "tools/call",
|
|
187
|
+
params: { name: "boomtick.health", arguments: {} },
|
|
188
|
+
});
|
|
189
|
+
expect(result).toEqual({
|
|
190
|
+
content: [{ type: "text", text: JSON.stringify({ status: "ok" }, null, 2) }],
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
it("should return error result for unknown tool", async () => {
|
|
194
|
+
const handler = requestHandlers.get("tools/call");
|
|
195
|
+
const result = await handler({
|
|
196
|
+
method: "tools/call",
|
|
197
|
+
params: { name: "nonexistent.tool", arguments: {} },
|
|
198
|
+
});
|
|
199
|
+
expect(result).toEqual({
|
|
200
|
+
content: [{ type: "text", text: "Tool not found: nonexistent.tool" }],
|
|
201
|
+
isError: true,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
it("should return error result when tool execution throws error", async () => {
|
|
205
|
+
const handler = requestHandlers.get("tools/call");
|
|
206
|
+
vi.spyOn(healthModule, "healthHandler").mockRejectedValue(new Error("Health check failed"));
|
|
207
|
+
const result = await handler({
|
|
208
|
+
method: "tools/call",
|
|
209
|
+
params: { name: "boomtick.health", arguments: {} },
|
|
210
|
+
});
|
|
211
|
+
expect(result).toEqual({
|
|
212
|
+
content: [{ type: "text", text: "Health check failed" }],
|
|
213
|
+
isError: true,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|