claude-remote-cli 3.9.4 → 3.10.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.
@@ -0,0 +1,203 @@
1
+ import { test, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import express from 'express';
7
+ import { createIntegrationGitHubRouter } from '../server/integration-github.js';
8
+ import { saveConfig, DEFAULTS } from '../server/config.js';
9
+ let tmpDir;
10
+ let configPath;
11
+ let server;
12
+ let baseUrl;
13
+ const WORKSPACE_PATH_A = '/fake/workspace/repo-a';
14
+ const WORKSPACE_PATH_B = '/fake/workspace/repo-b';
15
+ /**
16
+ * Builds a minimal GhIssueItem for use in mock stdout payloads.
17
+ */
18
+ function makeIssueItem(overrides) {
19
+ const { number = 1, title = 'Test Issue', url = `https://github.com/fake/repo/issues/${overrides.number ?? 1}`, updatedAt = '2026-03-21T00:00:00Z', createdAt = '2026-03-20T00:00:00Z', } = overrides;
20
+ return {
21
+ number,
22
+ title,
23
+ url,
24
+ state: 'OPEN',
25
+ labels: [],
26
+ assignees: [],
27
+ createdAt,
28
+ updatedAt,
29
+ };
30
+ }
31
+ /**
32
+ * Creates a mock execAsync that routes calls based on the command and cwd.
33
+ * - `gh issue list` in a given cwd → returns configured issues list or throws
34
+ */
35
+ function makeMockExec(opts) {
36
+ return async (cmd, args, options) => {
37
+ const command = cmd;
38
+ const argv = args;
39
+ const cwd = options.cwd ?? '';
40
+ if (command === 'gh' && argv[0] === 'issue' && argv[1] === 'list') {
41
+ if (opts.globalError)
42
+ throw opts.globalError;
43
+ if (opts.errorByPath?.[cwd])
44
+ throw opts.errorByPath[cwd];
45
+ const items = opts.issuesByPath?.[cwd] ?? [];
46
+ return { stdout: JSON.stringify(items), stderr: '' };
47
+ }
48
+ throw new Error(`Unexpected exec call: ${command} ${argv.join(' ')}`);
49
+ };
50
+ }
51
+ function startServer(execAsyncFn) {
52
+ return new Promise((resolve) => {
53
+ const app = express();
54
+ app.use(express.json());
55
+ const deps = { configPath, execAsync: execAsyncFn };
56
+ app.use('/integration-github', createIntegrationGitHubRouter(deps));
57
+ server = app.listen(0, '127.0.0.1', () => {
58
+ const addr = server.address();
59
+ if (typeof addr === 'object' && addr) {
60
+ baseUrl = `http://127.0.0.1:${addr.port}`;
61
+ }
62
+ resolve();
63
+ });
64
+ });
65
+ }
66
+ function stopServer() {
67
+ return new Promise((resolve) => {
68
+ if (server)
69
+ server.close(() => resolve());
70
+ else
71
+ resolve();
72
+ });
73
+ }
74
+ async function getIssues() {
75
+ const res = await fetch(`${baseUrl}/integration-github/issues`);
76
+ return res.json();
77
+ }
78
+ before(() => {
79
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'integration-github-test-'));
80
+ configPath = path.join(tmpDir, 'config.json');
81
+ });
82
+ after(async () => {
83
+ await stopServer();
84
+ fs.rmSync(tmpDir, { recursive: true, force: true });
85
+ });
86
+ test('returns issues from all workspace repos merged and sorted', async () => {
87
+ await stopServer();
88
+ saveConfig(configPath, {
89
+ ...DEFAULTS,
90
+ workspaces: [WORKSPACE_PATH_A, WORKSPACE_PATH_B],
91
+ });
92
+ const exec = makeMockExec({
93
+ issuesByPath: {
94
+ [WORKSPACE_PATH_A]: [
95
+ makeIssueItem({ number: 10, title: 'Issue A', updatedAt: '2026-03-21T10:00:00Z' }),
96
+ makeIssueItem({ number: 11, title: 'Issue A2', updatedAt: '2026-03-19T10:00:00Z' }),
97
+ ],
98
+ [WORKSPACE_PATH_B]: [
99
+ makeIssueItem({ number: 20, title: 'Issue B', updatedAt: '2026-03-20T10:00:00Z' }),
100
+ ],
101
+ },
102
+ });
103
+ await startServer(exec);
104
+ const data = await getIssues();
105
+ assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
106
+ assert.equal(data.issues.length, 3, 'Should return all 3 issues from both repos');
107
+ // Verify sorted descending by updatedAt
108
+ const updatedAts = data.issues.map((i) => i.updatedAt);
109
+ assert.deepEqual(updatedAts, [
110
+ '2026-03-21T10:00:00Z',
111
+ '2026-03-20T10:00:00Z',
112
+ '2026-03-19T10:00:00Z',
113
+ ]);
114
+ // Verify repoPath and repoName are attached
115
+ const issue10 = data.issues.find((i) => i.number === 10);
116
+ assert.equal(issue10?.repoPath, WORKSPACE_PATH_A);
117
+ assert.equal(issue10?.repoName, 'repo-a');
118
+ const issue20 = data.issues.find((i) => i.number === 20);
119
+ assert.equal(issue20?.repoPath, WORKSPACE_PATH_B);
120
+ assert.equal(issue20?.repoName, 'repo-b');
121
+ });
122
+ test('returns no_workspaces error when empty', async () => {
123
+ await stopServer();
124
+ saveConfig(configPath, {
125
+ ...DEFAULTS,
126
+ workspaces: [],
127
+ });
128
+ // execAsync should never be called — pass a mock that always throws to verify early-return
129
+ const exec = makeMockExec({});
130
+ await startServer(exec);
131
+ const data = await getIssues();
132
+ assert.equal(data.error, 'no_workspaces');
133
+ assert.equal(data.issues.length, 0);
134
+ });
135
+ test('returns gh_not_in_path when gh not found', async () => {
136
+ await stopServer();
137
+ saveConfig(configPath, {
138
+ ...DEFAULTS,
139
+ workspaces: [WORKSPACE_PATH_A],
140
+ });
141
+ const err = new Error('spawn gh ENOENT');
142
+ err.code = 'ENOENT';
143
+ const exec = makeMockExec({ globalError: err });
144
+ await startServer(exec);
145
+ const data = await getIssues();
146
+ assert.equal(data.error, 'gh_not_in_path');
147
+ assert.equal(data.issues.length, 0);
148
+ });
149
+ test('caches per-repo within TTL — gh called once per repo for two requests', async () => {
150
+ await stopServer();
151
+ saveConfig(configPath, {
152
+ ...DEFAULTS,
153
+ workspaces: [WORKSPACE_PATH_A, WORKSPACE_PATH_B],
154
+ });
155
+ let ghCallCount = 0;
156
+ const baseExec = makeMockExec({
157
+ issuesByPath: {
158
+ [WORKSPACE_PATH_A]: [makeIssueItem({ number: 1 })],
159
+ [WORKSPACE_PATH_B]: [makeIssueItem({ number: 2 })],
160
+ },
161
+ });
162
+ const countingExec = async (...args) => {
163
+ const [cmd, argv] = args;
164
+ if (cmd === 'gh' && argv[0] === 'issue') {
165
+ ghCallCount++;
166
+ }
167
+ return baseExec(...args);
168
+ };
169
+ await startServer(countingExec);
170
+ // First request — populates cache for both repos
171
+ const first = await getIssues();
172
+ assert.equal(first.error, undefined);
173
+ assert.equal(first.issues.length, 2);
174
+ assert.equal(ghCallCount, 2, 'gh should be called once per repo on first request');
175
+ // Second request — should be served from per-repo cache, no additional calls
176
+ const second = await getIssues();
177
+ assert.equal(second.error, undefined);
178
+ assert.equal(second.issues.length, 2);
179
+ assert.equal(ghCallCount, 2, 'gh should not be called again within TTL (cache hit)');
180
+ });
181
+ test('partial failure: repo that throws still returns others', async () => {
182
+ await stopServer();
183
+ saveConfig(configPath, {
184
+ ...DEFAULTS,
185
+ workspaces: [WORKSPACE_PATH_A, WORKSPACE_PATH_B],
186
+ });
187
+ // repo-b will throw a generic error (not ENOENT, so non-fatal)
188
+ const exec = makeMockExec({
189
+ issuesByPath: {
190
+ [WORKSPACE_PATH_A]: [makeIssueItem({ number: 99, title: 'Surviving issue' })],
191
+ },
192
+ errorByPath: {
193
+ [WORKSPACE_PATH_B]: new Error('git command failed'),
194
+ },
195
+ });
196
+ await startServer(exec);
197
+ const data = await getIssues();
198
+ // No top-level error — partial failures are silent
199
+ assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
200
+ assert.equal(data.issues.length, 1, 'Should return the one issue from the succeeding repo');
201
+ assert.equal(data.issues[0]?.number, 99);
202
+ assert.equal(data.issues[0]?.repoPath, WORKSPACE_PATH_A);
203
+ });
@@ -0,0 +1,302 @@
1
+ import { test, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import express from 'express';
4
+ import { createIntegrationJiraRouter } from '../server/integration-jira.js';
5
+ // ─── Constants ────────────────────────────────────────────────────────────────
6
+ const FAKE_BASE_URL = 'https://fake-jira.atlassian.net';
7
+ const FAKE_TOKEN = 'test-api-token';
8
+ const FAKE_EMAIL = 'test@example.com';
9
+ // ─── Globals ──────────────────────────────────────────────────────────────────
10
+ let server;
11
+ let baseUrl;
12
+ /**
13
+ * The real Node.js fetch, saved before any test replaces globalThis.fetch.
14
+ * All HTTP calls from the test itself to the local Express server must use this.
15
+ */
16
+ let realFetch;
17
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
18
+ /**
19
+ * Builds a minimal Jira REST API issue shape for use in mock responses.
20
+ */
21
+ function makeJiraApiIssue(overrides) {
22
+ const { key = 'TEST-1', summary = 'Test issue', status = 'In Progress', priority = 'Medium', storyPoints = null, sprint = null, assignee = 'Jane Doe', updated = '2026-03-21T00:00:00.000+0000', } = overrides;
23
+ return {
24
+ key,
25
+ self: `${FAKE_BASE_URL}/rest/api/3/issue/${key}`,
26
+ fields: {
27
+ summary,
28
+ status: { name: status },
29
+ priority: priority !== null ? { name: priority } : null,
30
+ customfield_10016: storyPoints,
31
+ customfield_10020: sprint !== null ? [{ name: sprint }] : null,
32
+ assignee: assignee !== null ? { displayName: assignee } : null,
33
+ updated,
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * Builds a real Response with a given status and JSON body.
39
+ */
40
+ function makeJsonResponse(body, status = 200) {
41
+ return new Response(JSON.stringify(body), {
42
+ status,
43
+ headers: { 'Content-Type': 'application/json' },
44
+ });
45
+ }
46
+ // ─── Server lifecycle ─────────────────────────────────────────────────────────
47
+ /**
48
+ * Starts a fresh Express server with a new Jira router instance (and therefore
49
+ * a fresh in-memory cache). Call this AFTER setting globalThis.fetch so the
50
+ * router uses the mock when handling requests.
51
+ */
52
+ function startServer() {
53
+ return new Promise((resolve) => {
54
+ const app = express();
55
+ app.use(express.json());
56
+ app.use('/integration-jira', createIntegrationJiraRouter({ configPath: '' }));
57
+ server = app.listen(0, '127.0.0.1', () => {
58
+ const addr = server.address();
59
+ if (typeof addr === 'object' && addr) {
60
+ baseUrl = `http://127.0.0.1:${addr.port}`;
61
+ }
62
+ resolve();
63
+ });
64
+ });
65
+ }
66
+ function stopServer() {
67
+ return new Promise((resolve) => {
68
+ if (server)
69
+ server.close(() => resolve());
70
+ else
71
+ resolve();
72
+ });
73
+ }
74
+ // ─── Env var helpers ──────────────────────────────────────────────────────────
75
+ function setEnvVars() {
76
+ process.env.JIRA_API_TOKEN = FAKE_TOKEN;
77
+ process.env.JIRA_EMAIL = FAKE_EMAIL;
78
+ process.env.JIRA_BASE_URL = FAKE_BASE_URL;
79
+ }
80
+ function clearEnvVars() {
81
+ delete process.env.JIRA_API_TOKEN;
82
+ delete process.env.JIRA_EMAIL;
83
+ delete process.env.JIRA_BASE_URL;
84
+ }
85
+ // ─── Suite setup / teardown ───────────────────────────────────────────────────
86
+ before(() => {
87
+ realFetch = globalThis.fetch;
88
+ clearEnvVars();
89
+ });
90
+ after(async () => {
91
+ globalThis.fetch = realFetch;
92
+ clearEnvVars();
93
+ await stopServer();
94
+ });
95
+ // ─── Tests ────────────────────────────────────────────────────────────────────
96
+ test('GET /configured returns true when all env vars are set', async () => {
97
+ setEnvVars();
98
+ globalThis.fetch = realFetch;
99
+ await stopServer();
100
+ await startServer();
101
+ const res = await realFetch(`${baseUrl}/integration-jira/configured`);
102
+ assert.equal(res.status, 200);
103
+ const body = (await res.json());
104
+ assert.equal(body.configured, true);
105
+ });
106
+ test('GET /configured returns false when any env var is missing', async () => {
107
+ clearEnvVars();
108
+ // Set only two of the three required vars; JIRA_BASE_URL intentionally omitted
109
+ process.env.JIRA_API_TOKEN = FAKE_TOKEN;
110
+ process.env.JIRA_EMAIL = FAKE_EMAIL;
111
+ globalThis.fetch = realFetch;
112
+ await stopServer();
113
+ await startServer();
114
+ const res = await realFetch(`${baseUrl}/integration-jira/configured`);
115
+ assert.equal(res.status, 200);
116
+ const body = (await res.json());
117
+ assert.equal(body.configured, false);
118
+ clearEnvVars();
119
+ });
120
+ test('GET /issues returns mapped JiraIssue[] from mocked Jira search response', async () => {
121
+ setEnvVars();
122
+ const mockSearchPayload = {
123
+ issues: [
124
+ makeJiraApiIssue({
125
+ key: 'PROJ-42',
126
+ summary: 'Fix the login bug',
127
+ status: 'In Progress',
128
+ priority: 'High',
129
+ storyPoints: 3,
130
+ sprint: 'Sprint 5',
131
+ assignee: 'Alice',
132
+ updated: '2026-03-21T12:00:00.000+0000',
133
+ }),
134
+ makeJiraApiIssue({
135
+ key: 'PROJ-10',
136
+ summary: 'Update docs',
137
+ status: 'To Do',
138
+ priority: null,
139
+ storyPoints: null,
140
+ sprint: null,
141
+ assignee: null,
142
+ updated: '2026-03-20T08:00:00.000+0000',
143
+ }),
144
+ ],
145
+ };
146
+ globalThis.fetch = async (input) => {
147
+ const url = typeof input === 'string' ? input : input.toString();
148
+ if (url.includes('/rest/api/3/search')) {
149
+ return makeJsonResponse(mockSearchPayload);
150
+ }
151
+ return makeJsonResponse({ error: 'unexpected url' }, 500);
152
+ };
153
+ await stopServer();
154
+ await startServer();
155
+ const res = await realFetch(`${baseUrl}/integration-jira/issues`);
156
+ assert.equal(res.status, 200);
157
+ const data = (await res.json());
158
+ assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
159
+ assert.equal(data.issues.length, 2);
160
+ const issue42 = data.issues.find((i) => i.key === 'PROJ-42');
161
+ assert.ok(issue42, 'Should contain PROJ-42');
162
+ assert.equal(issue42.title, 'Fix the login bug');
163
+ assert.equal(issue42.status, 'In Progress');
164
+ assert.equal(issue42.priority, 'High');
165
+ assert.equal(issue42.storyPoints, 3);
166
+ assert.equal(issue42.sprint, 'Sprint 5');
167
+ assert.equal(issue42.assignee, 'Alice');
168
+ assert.equal(issue42.url, `${FAKE_BASE_URL}/browse/PROJ-42`);
169
+ assert.equal(issue42.projectKey, 'PROJ');
170
+ const issue10 = data.issues.find((i) => i.key === 'PROJ-10');
171
+ assert.ok(issue10, 'Should contain PROJ-10');
172
+ assert.equal(issue10.priority, null);
173
+ assert.equal(issue10.storyPoints, null);
174
+ assert.equal(issue10.sprint, null);
175
+ assert.equal(issue10.assignee, null);
176
+ // Verify sorted descending by updatedAt — PROJ-42 (newer) should come first
177
+ assert.equal(data.issues[0]?.key, 'PROJ-42');
178
+ assert.equal(data.issues[1]?.key, 'PROJ-10');
179
+ });
180
+ test('GET /issues caches results within TTL — fetch called only once for two requests', async () => {
181
+ setEnvVars();
182
+ let fetchCallCount = 0;
183
+ const mockPayload = {
184
+ issues: [makeJiraApiIssue({ key: 'CACHE-1', summary: 'Cached issue' })],
185
+ };
186
+ globalThis.fetch = async (input) => {
187
+ const url = typeof input === 'string' ? input : input.toString();
188
+ if (url.includes('/rest/api/3/search')) {
189
+ fetchCallCount++;
190
+ return makeJsonResponse(mockPayload);
191
+ }
192
+ return makeJsonResponse({}, 500);
193
+ };
194
+ await stopServer();
195
+ await startServer();
196
+ // First request — populates cache
197
+ const firstRes = await realFetch(`${baseUrl}/integration-jira/issues`);
198
+ const first = (await firstRes.json());
199
+ assert.equal(first.error, undefined, `Unexpected error: ${first.error}`);
200
+ assert.equal(first.issues.length, 1);
201
+ assert.equal(fetchCallCount, 1, 'fetch should be called once on first request');
202
+ // Second request — should be served from cache, no additional fetch
203
+ const secondRes = await realFetch(`${baseUrl}/integration-jira/issues`);
204
+ const second = (await secondRes.json());
205
+ assert.equal(second.error, undefined, `Unexpected error: ${second.error}`);
206
+ assert.equal(second.issues.length, 1);
207
+ assert.equal(fetchCallCount, 1, 'fetch should not be called again within TTL (cache hit)');
208
+ });
209
+ test('GET /issues returns jira_not_configured when env vars missing', async () => {
210
+ clearEnvVars();
211
+ // fetch should never be called — use a mock that throws to verify early return
212
+ globalThis.fetch = async () => {
213
+ throw new Error('fetch should not be called when not configured');
214
+ };
215
+ await stopServer();
216
+ await startServer();
217
+ const res = await realFetch(`${baseUrl}/integration-jira/issues`);
218
+ assert.equal(res.status, 200);
219
+ const data = (await res.json());
220
+ assert.equal(data.error, 'jira_not_configured');
221
+ assert.equal(data.issues.length, 0);
222
+ });
223
+ test('GET /statuses?projectKey=TEST returns deduplicated statuses from Jira project API', async () => {
224
+ setEnvVars();
225
+ // Jira returns one entry per issue type; statuses may overlap across types
226
+ const mockProjectStatuses = [
227
+ {
228
+ statuses: [
229
+ { id: '1', name: 'To Do' },
230
+ { id: '2', name: 'In Progress' },
231
+ ],
232
+ },
233
+ {
234
+ statuses: [
235
+ { id: '2', name: 'In Progress' }, // duplicate — should be filtered
236
+ { id: '3', name: 'Done' },
237
+ ],
238
+ },
239
+ ];
240
+ globalThis.fetch = async (input) => {
241
+ const url = typeof input === 'string' ? input : input.toString();
242
+ if (url.includes('/rest/api/3/project/TEST/statuses')) {
243
+ return makeJsonResponse(mockProjectStatuses);
244
+ }
245
+ return makeJsonResponse({}, 500);
246
+ };
247
+ await stopServer();
248
+ await startServer();
249
+ const res = await realFetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST`);
250
+ assert.equal(res.status, 200);
251
+ const data = (await res.json());
252
+ assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
253
+ assert.equal(data.statuses.length, 3, 'Should deduplicate statuses by id');
254
+ const ids = data.statuses.map((s) => s.id);
255
+ assert.deepEqual(ids, ['1', '2', '3']);
256
+ const names = data.statuses.map((s) => s.name);
257
+ assert.deepEqual(names, ['To Do', 'In Progress', 'Done']);
258
+ });
259
+ test('GET /issues returns jira_auth_failed on 401 from Jira API', async () => {
260
+ setEnvVars();
261
+ globalThis.fetch = async (input) => {
262
+ const url = typeof input === 'string' ? input : input.toString();
263
+ if (url.includes('/rest/api/3/search')) {
264
+ return makeJsonResponse({ message: 'Unauthorized' }, 401);
265
+ }
266
+ return makeJsonResponse({}, 500);
267
+ };
268
+ await stopServer();
269
+ await startServer();
270
+ const res = await realFetch(`${baseUrl}/integration-jira/issues`);
271
+ assert.equal(res.status, 200);
272
+ const data = (await res.json());
273
+ assert.equal(data.error, 'jira_auth_failed');
274
+ assert.equal(data.issues.length, 0);
275
+ });
276
+ test('GET /statuses returns jira_auth_failed on 401 from Jira project API', async () => {
277
+ setEnvVars();
278
+ globalThis.fetch = async (input) => {
279
+ const url = typeof input === 'string' ? input : input.toString();
280
+ if (url.includes('/rest/api/3/project')) {
281
+ return makeJsonResponse({ message: 'Unauthorized' }, 401);
282
+ }
283
+ return makeJsonResponse({}, 500);
284
+ };
285
+ await stopServer();
286
+ await startServer();
287
+ const res = await realFetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST`);
288
+ assert.equal(res.status, 200);
289
+ const data = (await res.json());
290
+ assert.equal(data.error, 'jira_auth_failed');
291
+ assert.equal(data.statuses.length, 0);
292
+ });
293
+ test('GET /statuses returns 400 when projectKey query param is missing', async () => {
294
+ setEnvVars();
295
+ globalThis.fetch = realFetch;
296
+ await stopServer();
297
+ await startServer();
298
+ const res = await realFetch(`${baseUrl}/integration-jira/statuses`);
299
+ assert.equal(res.status, 400);
300
+ const data = (await res.json());
301
+ assert.equal(data.error, 'missing_project_key');
302
+ });