claude-remote-cli 3.10.0 → 3.11.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.
@@ -1,59 +1,60 @@
1
- import { test, before, after } from 'node:test';
1
+ import { test, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import express from 'express';
4
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';
5
+ // ─── Mock stdout constants ────────────────────────────────────────────────────
6
+ const AUTH_STATUS_STDOUT = `✓ Authenticated
7
+ Site: fake-jira.atlassian.net
8
+ Email: test@example.com
9
+ Authentication Type: oauth
10
+ `;
9
11
  // ─── Globals ──────────────────────────────────────────────────────────────────
10
12
  let server;
11
13
  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
14
  // ─── Helpers ──────────────────────────────────────────────────────────────────
18
15
  /**
19
- * Builds a minimal Jira REST API issue shape for use in mock responses.
16
+ * Builds a minimal AcliWorkItem for use in mock stdout payloads.
20
17
  */
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;
18
+ function makeAcliWorkItem(overrides) {
19
+ const { key = 'TEST-1', summary = 'Test issue', statusId = '3', statusName = 'In Progress', priorityName = 'Medium', assigneeDisplayName = 'Jane Doe', } = overrides;
23
20
  return {
24
21
  key,
25
- self: `${FAKE_BASE_URL}/rest/api/3/issue/${key}`,
26
22
  fields: {
27
23
  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,
24
+ status: { id: statusId, name: statusName },
25
+ ...(priorityName !== null ? { priority: { name: priorityName } } : { priority: null }),
26
+ ...(assigneeDisplayName !== null ? { assignee: { displayName: assigneeDisplayName } } : { assignee: null }),
34
27
  },
35
28
  };
36
29
  }
37
30
  /**
38
- * Builds a real Response with a given status and JSON body.
31
+ * Creates a mock execAsync that routes calls based on the acli subcommand args:
32
+ * - `acli jira auth status` → returns AUTH_STATUS_STDOUT (or throws)
33
+ * - `acli jira workitem search` → returns the provided items as JSON stdout (or throws)
39
34
  */
40
- function makeJsonResponse(body, status = 200) {
41
- return new Response(JSON.stringify(body), {
42
- status,
43
- headers: { 'Content-Type': 'application/json' },
44
- });
35
+ function makeMockExec(opts) {
36
+ return async (_cmd, args) => {
37
+ const argv = args;
38
+ if (argv.includes('auth') && argv.includes('status')) {
39
+ if (opts.authError)
40
+ throw opts.authError;
41
+ return { stdout: AUTH_STATUS_STDOUT, stderr: '' };
42
+ }
43
+ if (argv.includes('workitem') && argv.includes('search')) {
44
+ if (opts.searchError)
45
+ throw opts.searchError;
46
+ return { stdout: JSON.stringify(opts.searchItems ?? []), stderr: '' };
47
+ }
48
+ throw new Error(`Unexpected exec call: acli ${argv.join(' ')}`);
49
+ };
45
50
  }
46
51
  // ─── 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() {
52
+ function startServer(execAsyncFn) {
53
53
  return new Promise((resolve) => {
54
54
  const app = express();
55
55
  app.use(express.json());
56
- app.use('/integration-jira', createIntegrationJiraRouter({ configPath: '' }));
56
+ const deps = { configPath: '', execAsync: execAsyncFn };
57
+ app.use('/integration-jira', createIntegrationJiraRouter(deps));
57
58
  server = app.listen(0, '127.0.0.1', () => {
58
59
  const addr = server.address();
59
60
  if (typeof addr === 'object' && addr) {
@@ -71,88 +72,34 @@ function stopServer() {
71
72
  resolve();
72
73
  });
73
74
  }
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
- });
75
+ // ─── Suite teardown ───────────────────────────────────────────────────────────
90
76
  after(async () => {
91
- globalThis.fetch = realFetch;
92
- clearEnvVars();
93
77
  await stopServer();
94
78
  });
95
79
  // ─── 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;
80
+ test('GET /issues returns mapped JiraIssue[] from mocked acli JSON output', async () => {
112
81
  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`);
82
+ const searchItems = [
83
+ makeAcliWorkItem({
84
+ key: 'PROJ-42',
85
+ summary: 'Fix the login bug',
86
+ statusId: '3',
87
+ statusName: 'In Progress',
88
+ priorityName: 'High',
89
+ assigneeDisplayName: 'Alice',
90
+ }),
91
+ makeAcliWorkItem({
92
+ key: 'PROJ-10',
93
+ summary: 'Update docs',
94
+ statusId: '1',
95
+ statusName: 'To Do',
96
+ priorityName: null,
97
+ assigneeDisplayName: null,
98
+ }),
99
+ ];
100
+ const exec = makeMockExec({ searchItems });
101
+ await startServer(exec);
102
+ const res = await fetch(`${baseUrl}/integration-jira/issues`);
156
103
  assert.equal(res.status, 200);
157
104
  const data = (await res.json());
158
105
  assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
@@ -162,91 +109,83 @@ test('GET /issues returns mapped JiraIssue[] from mocked Jira search response',
162
109
  assert.equal(issue42.title, 'Fix the login bug');
163
110
  assert.equal(issue42.status, 'In Progress');
164
111
  assert.equal(issue42.priority, 'High');
165
- assert.equal(issue42.storyPoints, 3);
166
- assert.equal(issue42.sprint, 'Sprint 5');
167
112
  assert.equal(issue42.assignee, 'Alice');
168
- assert.equal(issue42.url, `${FAKE_BASE_URL}/browse/PROJ-42`);
113
+ assert.equal(issue42.url, 'https://fake-jira.atlassian.net/browse/PROJ-42');
169
114
  assert.equal(issue42.projectKey, 'PROJ');
115
+ assert.equal(issue42.updatedAt, '');
116
+ assert.equal(issue42.sprint, null);
117
+ assert.equal(issue42.storyPoints, null);
170
118
  const issue10 = data.issues.find((i) => i.key === 'PROJ-10');
171
119
  assert.ok(issue10, 'Should contain PROJ-10');
172
120
  assert.equal(issue10.priority, null);
173
- assert.equal(issue10.storyPoints, null);
174
- assert.equal(issue10.sprint, null);
175
121
  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');
122
+ assert.equal(issue10.url, 'https://fake-jira.atlassian.net/browse/PROJ-10');
123
+ assert.equal(issue10.projectKey, 'PROJ');
179
124
  });
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);
125
+ test('GET /issues returns acli_not_in_path when exec throws ENOENT', async () => {
126
+ await stopServer();
127
+ const err = new Error('spawn acli ENOENT');
128
+ err.code = 'ENOENT';
129
+ const exec = makeMockExec({ authError: err });
130
+ await startServer(exec);
131
+ const res = await fetch(`${baseUrl}/integration-jira/issues`);
132
+ assert.equal(res.status, 200);
133
+ const data = (await res.json());
134
+ assert.equal(data.error, 'acli_not_in_path');
135
+ assert.equal(data.issues.length, 0);
136
+ });
137
+ test('GET /issues returns acli_not_authenticated when exec stderr contains auth message', async () => {
138
+ await stopServer();
139
+ const err = Object.assign(new Error('acli auth error'), {
140
+ stderr: "not logged in, use 'acli jira auth login' to authenticate",
141
+ });
142
+ const exec = makeMockExec({ authError: err });
143
+ await startServer(exec);
144
+ const res = await fetch(`${baseUrl}/integration-jira/issues`);
145
+ assert.equal(res.status, 200);
146
+ const data = (await res.json());
147
+ assert.equal(data.error, 'acli_not_authenticated');
148
+ assert.equal(data.issues.length, 0);
149
+ });
150
+ test('GET /issues caches results within TTL — exec called only once for two requests', async () => {
151
+ await stopServer();
152
+ let searchCallCount = 0;
153
+ const baseExec = makeMockExec({
154
+ searchItems: [makeAcliWorkItem({ key: 'CACHE-1', summary: 'Cached issue' })],
155
+ });
156
+ const countingExec = async (...args) => {
157
+ const argv = args[1];
158
+ if (argv.includes('workitem') && argv.includes('search')) {
159
+ searchCallCount++;
191
160
  }
192
- return makeJsonResponse({}, 500);
161
+ return baseExec(...args);
193
162
  };
194
- await stopServer();
195
- await startServer();
163
+ await startServer(countingExec);
196
164
  // First request — populates cache
197
- const firstRes = await realFetch(`${baseUrl}/integration-jira/issues`);
165
+ const firstRes = await fetch(`${baseUrl}/integration-jira/issues`);
198
166
  const first = (await firstRes.json());
199
167
  assert.equal(first.error, undefined, `Unexpected error: ${first.error}`);
200
168
  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`);
169
+ assert.equal(searchCallCount, 1, 'search should be called once on first request');
170
+ // Second request — should be served from cache, no additional exec call
171
+ const secondRes = await fetch(`${baseUrl}/integration-jira/issues`);
204
172
  const second = (await secondRes.json());
205
173
  assert.equal(second.error, undefined, `Unexpected error: ${second.error}`);
206
174
  assert.equal(second.issues.length, 1);
207
- assert.equal(fetchCallCount, 1, 'fetch should not be called again within TTL (cache hit)');
175
+ assert.equal(searchCallCount, 1, 'search should not be called again within TTL (cache hit)');
208
176
  });
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
- };
177
+ test('GET /statuses?projectKey=TEST returns deduplicated statuses', async () => {
215
178
  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
- },
179
+ // Items with overlapping status IDs — deduplication should collapse to 3 unique statuses
180
+ const searchItems = [
181
+ makeAcliWorkItem({ key: 'TEST-1', statusId: '1', statusName: 'To Do' }),
182
+ makeAcliWorkItem({ key: 'TEST-2', statusId: '2', statusName: 'In Progress' }),
183
+ makeAcliWorkItem({ key: 'TEST-3', statusId: '2', statusName: 'In Progress' }), // duplicate
184
+ makeAcliWorkItem({ key: 'TEST-4', statusId: '3', statusName: 'Done' }),
239
185
  ];
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`);
186
+ const exec = makeMockExec({ searchItems });
187
+ await startServer(exec);
188
+ const res = await fetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST`);
250
189
  assert.equal(res.status, 200);
251
190
  const data = (await res.json());
252
191
  assert.equal(data.error, undefined, `Unexpected error: ${data.error}`);
@@ -256,47 +195,27 @@ test('GET /statuses?projectKey=TEST returns deduplicated statuses from Jira proj
256
195
  const names = data.statuses.map((s) => s.name);
257
196
  assert.deepEqual(names, ['To Do', 'In Progress', 'Done']);
258
197
  });
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
198
  test('GET /statuses returns 400 when projectKey query param is missing', async () => {
294
- setEnvVars();
295
- globalThis.fetch = realFetch;
296
199
  await stopServer();
297
- await startServer();
298
- const res = await realFetch(`${baseUrl}/integration-jira/statuses`);
200
+ const exec = makeMockExec({ searchItems: [] });
201
+ await startServer(exec);
202
+ const res = await fetch(`${baseUrl}/integration-jira/statuses`);
299
203
  assert.equal(res.status, 400);
300
204
  const data = (await res.json());
301
205
  assert.equal(data.error, 'missing_project_key');
302
206
  });
207
+ test('GET /statuses returns 400 for invalid projectKey (lowercase or special chars)', async () => {
208
+ await stopServer();
209
+ const exec = makeMockExec({ searchItems: [] });
210
+ await startServer(exec);
211
+ // Lowercase key
212
+ const resLower = await fetch(`${baseUrl}/integration-jira/statuses?projectKey=test`);
213
+ assert.equal(resLower.status, 400);
214
+ const dataLower = (await resLower.json());
215
+ assert.equal(dataLower.error, 'invalid_project_key');
216
+ // Key with special characters
217
+ const resSpecial = await fetch(`${baseUrl}/integration-jira/statuses?projectKey=TEST%20PROJ`);
218
+ assert.equal(resSpecial.status, 400);
219
+ const dataSpecial = (await resSpecial.json());
220
+ assert.equal(dataSpecial.error, 'invalid_project_key');
221
+ });