auq-mcp-server 2.3.0 → 2.5.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.
Files changed (71) hide show
  1. package/README.md +122 -0
  2. package/dist/bin/auq.js +87 -93
  3. package/dist/bin/tui-app.js +183 -7
  4. package/dist/package.json +1 -1
  5. package/dist/src/__tests__/server.abort.test.js +214 -0
  6. package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
  7. package/dist/src/cli/commands/__tests__/config.test.js +218 -0
  8. package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
  9. package/dist/src/cli/commands/answer.js +128 -0
  10. package/dist/src/cli/commands/config.js +263 -0
  11. package/dist/src/cli/commands/sessions.js +300 -0
  12. package/dist/src/cli/commands/update.js +124 -0
  13. package/dist/src/cli/utils.js +95 -0
  14. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  15. package/dist/src/config/__tests__/updateCheck.test.js +34 -0
  16. package/dist/src/config/defaults.js +5 -0
  17. package/dist/src/config/types.js +6 -0
  18. package/dist/src/core/ask-user-questions.js +3 -2
  19. package/dist/src/i18n/locales/en.js +7 -0
  20. package/dist/src/i18n/locales/ko.js +7 -0
  21. package/dist/src/server.js +64 -11
  22. package/dist/src/session/SessionManager.js +69 -4
  23. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  24. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  25. package/dist/src/tui/components/Footer.js +4 -1
  26. package/dist/src/tui/components/Header.js +3 -1
  27. package/dist/src/tui/components/SessionDots.js +33 -4
  28. package/dist/src/tui/components/SessionPicker.js +25 -17
  29. package/dist/src/tui/components/StepperView.js +68 -5
  30. package/dist/src/tui/components/UpdateBadge.js +29 -0
  31. package/dist/src/tui/components/UpdateOverlay.js +199 -0
  32. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  33. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  34. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  35. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  36. package/dist/src/tui/constants/keybindings.js +3 -0
  37. package/dist/src/tui/session-watcher.js +50 -0
  38. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  39. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  40. package/dist/src/tui/themes/dark.js +7 -0
  41. package/dist/src/tui/themes/dracula.js +7 -0
  42. package/dist/src/tui/themes/github-dark.js +7 -0
  43. package/dist/src/tui/themes/github-light.js +7 -0
  44. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  45. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  46. package/dist/src/tui/themes/light.js +7 -0
  47. package/dist/src/tui/themes/monokai.js +7 -0
  48. package/dist/src/tui/themes/nord.js +7 -0
  49. package/dist/src/tui/themes/one-dark.js +7 -0
  50. package/dist/src/tui/themes/rose-pine.js +7 -0
  51. package/dist/src/tui/themes/solarized-dark.js +7 -0
  52. package/dist/src/tui/themes/solarized-light.js +7 -0
  53. package/dist/src/tui/themes/tokyo-night.js +7 -0
  54. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  55. package/dist/src/tui/utils/staleDetection.js +51 -0
  56. package/dist/src/update/__tests__/cache.test.js +136 -0
  57. package/dist/src/update/__tests__/changelog.test.js +86 -0
  58. package/dist/src/update/__tests__/checker.test.js +148 -0
  59. package/dist/src/update/__tests__/index.test.js +37 -0
  60. package/dist/src/update/__tests__/installer.test.js +117 -0
  61. package/dist/src/update/__tests__/package-manager.test.js +73 -0
  62. package/dist/src/update/__tests__/version.test.js +74 -0
  63. package/dist/src/update/cache.js +74 -0
  64. package/dist/src/update/changelog.js +63 -0
  65. package/dist/src/update/checker.js +121 -0
  66. package/dist/src/update/index.js +15 -0
  67. package/dist/src/update/installer.js +51 -0
  68. package/dist/src/update/package-manager.js +49 -0
  69. package/dist/src/update/types.js +7 -0
  70. package/dist/src/update/version.js +114 -0
  71. package/package.json +1 -1
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Tests for AbortSignal and disconnect handling in SessionManager
3
+ */
4
+ import { promises as fs } from "fs";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { SessionManager } from "../session/index.js";
7
+ const testQuestions = [
8
+ {
9
+ options: [
10
+ { description: "Dynamic language", label: "JavaScript" },
11
+ { description: "Static typing", label: "TypeScript" },
12
+ ],
13
+ prompt: "Which programming language do you prefer?",
14
+ title: "Language",
15
+ },
16
+ ];
17
+ describe("AbortSignal and Disconnect Handling", () => {
18
+ let sessionManager;
19
+ const testBaseDir = "/tmp/auq-test-abort";
20
+ beforeEach(async () => {
21
+ await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
22
+ sessionManager = new SessionManager({
23
+ baseDir: testBaseDir,
24
+ maxSessions: 10,
25
+ sessionTimeout: 5000,
26
+ });
27
+ await sessionManager.initialize();
28
+ });
29
+ afterEach(async () => {
30
+ await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
31
+ });
32
+ describe("waitForAnswers with AbortSignal", () => {
33
+ it("should throw ABORTED when signal fires during polling", async () => {
34
+ const sessionId = await sessionManager.createSession(testQuestions);
35
+ const controller = new AbortController();
36
+ // Abort after a short delay
37
+ setTimeout(() => controller.abort(), 100);
38
+ await expect(sessionManager.waitForAnswers(sessionId, 0, undefined, controller.signal)).rejects.toThrow("ABORTED");
39
+ });
40
+ it("should throw ABORTED immediately when signal is already aborted", async () => {
41
+ const sessionId = await sessionManager.createSession(testQuestions);
42
+ const controller = new AbortController();
43
+ controller.abort(); // Pre-abort
44
+ await expect(sessionManager.waitForAnswers(sessionId, 0, undefined, controller.signal)).rejects.toThrow("ABORTED");
45
+ });
46
+ it("should work normally when signal is never aborted", async () => {
47
+ const sessionId = await sessionManager.createSession(testQuestions);
48
+ const controller = new AbortController();
49
+ // Write answers after a short delay
50
+ setTimeout(async () => {
51
+ await sessionManager.saveSessionAnswers(sessionId, {
52
+ answers: [{ questionIndex: 0, selectedOption: "JavaScript", timestamp: new Date().toISOString() }],
53
+ timestamp: new Date().toISOString(),
54
+ sessionId,
55
+ });
56
+ }, 100);
57
+ const result = await sessionManager.waitForAnswers(sessionId, 5000, undefined, controller.signal);
58
+ expect(result).toBe(sessionId);
59
+ });
60
+ });
61
+ describe("startSession with AbortSignal", () => {
62
+ it("should throw ABORTED when pre-aborted signal is passed", async () => {
63
+ const controller = new AbortController();
64
+ controller.abort(); // Pre-abort
65
+ await expect(sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
66
+ });
67
+ it("should mark session as abandoned when pre-aborted signal is passed", async () => {
68
+ const controller = new AbortController();
69
+ controller.abort(); // Pre-abort
70
+ try {
71
+ await sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal);
72
+ }
73
+ catch {
74
+ // Expected error
75
+ }
76
+ // Get session IDs and check the last one's status
77
+ const sessionIds = await sessionManager.getAllSessionIds();
78
+ expect(sessionIds.length).toBeGreaterThan(0);
79
+ const lastSessionId = sessionIds[sessionIds.length - 1];
80
+ const status = await sessionManager.getSessionStatus(lastSessionId);
81
+ expect(status?.status).toBe("abandoned");
82
+ });
83
+ it("should mark session as abandoned when signal aborts during wait", async () => {
84
+ const controller = new AbortController();
85
+ // Abort after session is created but before answers arrive
86
+ setTimeout(() => controller.abort(), 200);
87
+ await expect(sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
88
+ // Verify session was marked abandoned
89
+ const sessionIds = await sessionManager.getAllSessionIds();
90
+ expect(sessionIds.length).toBeGreaterThan(0);
91
+ const lastSessionId = sessionIds[sessionIds.length - 1];
92
+ // Give abort handler time to update status
93
+ await new Promise((resolve) => setTimeout(resolve, 100));
94
+ const status = await sessionManager.getSessionStatus(lastSessionId);
95
+ expect(status?.status).toBe("abandoned");
96
+ });
97
+ it("should clean up abort handler after successful completion", async () => {
98
+ const controller = new AbortController();
99
+ const signal = controller.signal;
100
+ // Create a session and immediately provide answers
101
+ const sessionPromise = sessionManager.startSession(testQuestions, "test-call", undefined, signal);
102
+ // Write answers quickly
103
+ // First we need to get the session ID, but startSession creates it internally
104
+ // We'll poll for any pending session
105
+ await new Promise((resolve) => setTimeout(resolve, 100));
106
+ const sessionIds = await sessionManager.getAllSessionIds();
107
+ if (sessionIds.length > 0) {
108
+ const lastSessionId = sessionIds[sessionIds.length - 1];
109
+ await sessionManager.saveSessionAnswers(lastSessionId, {
110
+ answers: [{ questionIndex: 0, selectedOption: "JavaScript", timestamp: new Date().toISOString() }],
111
+ timestamp: new Date().toISOString(),
112
+ sessionId: lastSessionId,
113
+ });
114
+ }
115
+ const result = await sessionPromise;
116
+ expect(result.formattedResponse).toBeDefined();
117
+ expect(result.sessionId).toBeDefined();
118
+ // After successful completion, aborting should have no effect
119
+ // (handler was cleaned up)
120
+ controller.abort();
121
+ // Session should still be completed, not abandoned
122
+ const status = await sessionManager.getSessionStatus(result.sessionId);
123
+ expect(status?.status).toBe("completed");
124
+ });
125
+ });
126
+ describe("createAskUserQuestionsCore with abort support", () => {
127
+ it("should expose markAbandoned method", async () => {
128
+ const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
129
+ const core = createAskUserQuestionsCore({
130
+ baseDir: testBaseDir,
131
+ sessionManager,
132
+ });
133
+ expect(core.markAbandoned).toBeDefined();
134
+ expect(typeof core.markAbandoned).toBe("function");
135
+ });
136
+ it("should mark session as abandoned via markAbandoned", async () => {
137
+ const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
138
+ const core = createAskUserQuestionsCore({
139
+ baseDir: testBaseDir,
140
+ sessionManager,
141
+ });
142
+ const sessionId = await sessionManager.createSession(testQuestions);
143
+ await core.markAbandoned(sessionId);
144
+ const status = await sessionManager.getSessionStatus(sessionId);
145
+ expect(status?.status).toBe("abandoned");
146
+ });
147
+ it("should pass signal through ask to startSession", async () => {
148
+ const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
149
+ const core = createAskUserQuestionsCore({
150
+ baseDir: testBaseDir,
151
+ sessionManager,
152
+ });
153
+ await core.ensureInitialized();
154
+ const controller = new AbortController();
155
+ controller.abort(); // Pre-abort
156
+ await expect(core.ask([
157
+ {
158
+ options: [
159
+ { description: "Dynamic language", label: "JavaScript" },
160
+ { description: "Static typing", label: "TypeScript" },
161
+ ],
162
+ prompt: "Which programming language do you prefer?",
163
+ title: "Language",
164
+ multiSelect: false,
165
+ },
166
+ ], "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
167
+ });
168
+ });
169
+ describe("activeRequests tracking (server integration)", () => {
170
+ it("should track and clean up active requests via Map", () => {
171
+ // Unit test for the activeRequests Map pattern used in server.ts
172
+ const activeRequests = new Map();
173
+ const controller = new AbortController();
174
+ const callId = "test-call-id";
175
+ // Track request
176
+ activeRequests.set(callId, { controller });
177
+ expect(activeRequests.size).toBe(1);
178
+ // Update with sessionId
179
+ const entry = activeRequests.get(callId);
180
+ expect(entry).toBeDefined();
181
+ entry.sessionId = "test-session-id";
182
+ // Verify update
183
+ expect(activeRequests.get(callId)?.sessionId).toBe("test-session-id");
184
+ // Simulate disconnect - abort and clean up
185
+ for (const [id, e] of activeRequests.entries()) {
186
+ e.controller.abort();
187
+ activeRequests.delete(id);
188
+ }
189
+ expect(activeRequests.size).toBe(0);
190
+ expect(controller.signal.aborted).toBe(true);
191
+ });
192
+ it("should handle multiple concurrent requests on disconnect", () => {
193
+ const activeRequests = new Map();
194
+ // Track multiple requests
195
+ const controllers = [];
196
+ for (let i = 0; i < 3; i++) {
197
+ const controller = new AbortController();
198
+ controllers.push(controller);
199
+ activeRequests.set(`call-${i}`, {
200
+ controller,
201
+ sessionId: `session-${i}`,
202
+ });
203
+ }
204
+ expect(activeRequests.size).toBe(3);
205
+ // Simulate disconnect
206
+ for (const [callId, entry] of activeRequests.entries()) {
207
+ entry.controller.abort();
208
+ activeRequests.delete(callId);
209
+ }
210
+ expect(activeRequests.size).toBe(0);
211
+ controllers.forEach((c) => expect(c.signal.aborted).toBe(true));
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Integration tests for the `auq answer` CLI command.
3
+ */
4
+ import { promises as fs } from "fs";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { SessionManager } from "../../../session/SessionManager.js";
7
+ import { runAnswerCommand } from "../answer.js";
8
+ // ── Helpers ────────────────────────────────────────────────────────────────
9
+ const testBaseDir = "/tmp/auq-test-cli-answer";
10
+ const sampleQuestions = [
11
+ {
12
+ title: "Language",
13
+ prompt: "Which language do you prefer?",
14
+ options: [
15
+ { label: "TypeScript", description: "Typed JS" },
16
+ { label: "Python", description: "Scripting" },
17
+ ],
18
+ },
19
+ {
20
+ title: "Framework",
21
+ prompt: "Pick a framework",
22
+ options: [
23
+ { label: "React" },
24
+ { label: "Vue" },
25
+ ],
26
+ },
27
+ ];
28
+ // Stub getSessionDirectory so the answer command always targets our temp dir.
29
+ vi.mock("../../../session/utils.js", async (importOriginal) => {
30
+ const actual = (await importOriginal());
31
+ return {
32
+ ...actual,
33
+ getSessionDirectory: () => testBaseDir,
34
+ };
35
+ });
36
+ // ── Test Suite ─────────────────────────────────────────────────────────────
37
+ describe("answer command", () => {
38
+ let sessionManager;
39
+ let consoleLogSpy;
40
+ let consoleErrorSpy;
41
+ let consoleWarnSpy;
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ let exitSpy;
44
+ beforeEach(async () => {
45
+ await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
46
+ sessionManager = new SessionManager({
47
+ baseDir: testBaseDir,
48
+ maxSessions: 10,
49
+ sessionTimeout: 0,
50
+ });
51
+ await sessionManager.initialize();
52
+ consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => { });
53
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
54
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
55
+ exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }));
56
+ });
57
+ afterEach(async () => {
58
+ consoleLogSpy.mockRestore();
59
+ consoleErrorSpy.mockRestore();
60
+ consoleWarnSpy.mockRestore();
61
+ exitSpy.mockRestore();
62
+ await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
63
+ });
64
+ // ── Success: answer a pending session ─────────────────────────────────
65
+ it("should answer a pending session with valid answers JSON", async () => {
66
+ const sessionId = await sessionManager.createSession(sampleQuestions);
67
+ const answersJson = JSON.stringify({
68
+ "0": { selectedOption: "TypeScript" },
69
+ "1": { selectedOption: "React" },
70
+ });
71
+ await runAnswerCommand([sessionId, "--answers", answersJson]);
72
+ // Verify status is completed
73
+ const status = await sessionManager.getSessionStatus(sessionId);
74
+ expect(status?.status).toBe("completed");
75
+ // Verify answers were saved
76
+ const savedAnswers = await sessionManager.getSessionAnswers(sessionId);
77
+ expect(savedAnswers).not.toBeNull();
78
+ expect(savedAnswers.answers).toHaveLength(2);
79
+ expect(savedAnswers.answers[0].selectedOption).toBe("TypeScript");
80
+ expect(savedAnswers.answers[1].selectedOption).toBe("React");
81
+ });
82
+ it("should write answers.json to session directory", async () => {
83
+ const sessionId = await sessionManager.createSession(sampleQuestions);
84
+ const answersJson = JSON.stringify({
85
+ "0": { selectedOption: "Python" },
86
+ });
87
+ await runAnswerCommand([sessionId, "--answers", answersJson]);
88
+ // Directly verify the file exists
89
+ const answersPath = `${testBaseDir}/${sessionId}/answers.json`;
90
+ const stat = await fs.stat(answersPath);
91
+ expect(stat.isFile()).toBe(true);
92
+ });
93
+ // ── Reject path ──────────────────────────────────────────────────────
94
+ it("should reject session with reason", async () => {
95
+ const sessionId = await sessionManager.createSession(sampleQuestions);
96
+ await runAnswerCommand([
97
+ sessionId,
98
+ "--reject",
99
+ "--reason",
100
+ "not applicable",
101
+ ]);
102
+ const status = await sessionManager.getSessionStatus(sessionId);
103
+ expect(status?.status).toBe("rejected");
104
+ expect(status?.rejectionReason).toBe("not applicable");
105
+ });
106
+ it("should reject session without reason", async () => {
107
+ const sessionId = await sessionManager.createSession(sampleQuestions);
108
+ await runAnswerCommand([sessionId, "--reject"]);
109
+ const status = await sessionManager.getSessionStatus(sessionId);
110
+ expect(status?.status).toBe("rejected");
111
+ });
112
+ // ── Error cases ──────────────────────────────────────────────────────
113
+ it("should error when sessionId is missing", async () => {
114
+ await expect(runAnswerCommand([])).rejects.toThrow("process.exit");
115
+ expect(exitSpy).toHaveBeenCalledWith(1);
116
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
117
+ expect(errorOutput).toContain("Missing session ID");
118
+ });
119
+ it("should error when session does not exist", async () => {
120
+ const fakeId = "00000000-0000-4000-a000-000000000000";
121
+ await expect(runAnswerCommand([fakeId, "--answers", '{ "0": {} }'])).rejects.toThrow("process.exit");
122
+ expect(exitSpy).toHaveBeenCalledWith(1);
123
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
124
+ expect(errorOutput).toContain("Session not found");
125
+ });
126
+ it("should error when answers JSON is malformed", async () => {
127
+ const sessionId = await sessionManager.createSession(sampleQuestions);
128
+ await expect(runAnswerCommand([sessionId, "--answers", "not-json"])).rejects.toThrow("process.exit");
129
+ expect(exitSpy).toHaveBeenCalledWith(1);
130
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
131
+ expect(errorOutput).toContain("Invalid answers JSON");
132
+ });
133
+ it("should error when neither --answers nor --reject is provided", async () => {
134
+ const sessionId = await sessionManager.createSession(sampleQuestions);
135
+ await expect(runAnswerCommand([sessionId])).rejects.toThrow("process.exit");
136
+ expect(exitSpy).toHaveBeenCalledWith(1);
137
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
138
+ expect(errorOutput).toContain("Either --answers or --reject is required");
139
+ });
140
+ // ── Abandoned session handling ────────────────────────────────────────
141
+ it("should warn for abandoned session without --force", async () => {
142
+ const sessionId = await sessionManager.createSession(sampleQuestions);
143
+ // Mark as abandoned
144
+ await sessionManager.updateSessionStatus(sessionId, "abandoned");
145
+ await expect(runAnswerCommand([
146
+ sessionId,
147
+ "--answers",
148
+ JSON.stringify({ "0": { selectedOption: "TypeScript" } }),
149
+ ])).rejects.toThrow("process.exit");
150
+ expect(exitSpy).toHaveBeenCalledWith(1);
151
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
152
+ expect(errorOutput).toContain("AI disconnected");
153
+ expect(errorOutput).toContain("--force");
154
+ });
155
+ it("should succeed for abandoned session with --force", async () => {
156
+ const sessionId = await sessionManager.createSession(sampleQuestions);
157
+ await sessionManager.updateSessionStatus(sessionId, "abandoned");
158
+ const answersJson = JSON.stringify({
159
+ "0": { selectedOption: "TypeScript" },
160
+ "1": { selectedOption: "React" },
161
+ });
162
+ await runAnswerCommand([sessionId, "--answers", answersJson, "--force"]);
163
+ const status = await sessionManager.getSessionStatus(sessionId);
164
+ expect(status?.status).toBe("completed");
165
+ // Check the warning was emitted
166
+ const warnOutput = consoleWarnSpy.mock.calls.map((c) => c[0]).join("\n");
167
+ expect(warnOutput).toContain("Warning");
168
+ });
169
+ // ── JSON output ──────────────────────────────────────────────────────
170
+ it("should produce valid JSON output with --json flag on answer", async () => {
171
+ const sessionId = await sessionManager.createSession(sampleQuestions);
172
+ const answersJson = JSON.stringify({
173
+ "0": { selectedOption: "TypeScript" },
174
+ });
175
+ await runAnswerCommand([sessionId, "--answers", answersJson, "--json"]);
176
+ // console.log should have been called with valid JSON
177
+ const jsonOutput = consoleLogSpy.mock.calls[0][0];
178
+ const result = JSON.parse(jsonOutput);
179
+ expect(result.success).toBe(true);
180
+ expect(result.sessionId).toBe(sessionId);
181
+ expect(result.status).toBe("completed");
182
+ });
183
+ it("should produce valid JSON output with --json flag on reject", async () => {
184
+ const sessionId = await sessionManager.createSession(sampleQuestions);
185
+ await runAnswerCommand([sessionId, "--reject", "--json"]);
186
+ const jsonOutput = consoleLogSpy.mock.calls[0][0];
187
+ const result = JSON.parse(jsonOutput);
188
+ expect(result.success).toBe(true);
189
+ expect(result.sessionId).toBe(sessionId);
190
+ expect(result.status).toBe("rejected");
191
+ });
192
+ it("should produce valid JSON error output with --json flag", async () => {
193
+ await expect(runAnswerCommand(["--json"])).rejects.toThrow("process.exit");
194
+ const jsonOutput = consoleLogSpy.mock.calls[0][0];
195
+ const result = JSON.parse(jsonOutput);
196
+ expect(result.success).toBe(false);
197
+ expect(result.error).toBeDefined();
198
+ });
199
+ });
@@ -0,0 +1,218 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import * as fs from "fs";
3
+ import { runConfigCommand } from "../config.js";
4
+ import { DEFAULT_CONFIG } from "../../../config/defaults.js";
5
+ // Mock fs module
6
+ vi.mock("fs", () => ({
7
+ existsSync: vi.fn(),
8
+ readFileSync: vi.fn(),
9
+ writeFileSync: vi.fn(),
10
+ mkdirSync: vi.fn(),
11
+ }));
12
+ vi.mock("os", () => ({
13
+ homedir: vi.fn(() => "/home/testuser"),
14
+ }));
15
+ describe("config command", () => {
16
+ let consoleLogSpy;
17
+ let consoleErrorSpy;
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ process.exitCode = undefined;
21
+ delete process.env.XDG_CONFIG_HOME;
22
+ consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => { });
23
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
24
+ // Default: no config files exist
25
+ vi.mocked(fs.existsSync).mockReturnValue(false);
26
+ });
27
+ afterEach(() => {
28
+ consoleLogSpy.mockRestore();
29
+ consoleErrorSpy.mockRestore();
30
+ vi.restoreAllMocks();
31
+ process.exitCode = undefined;
32
+ });
33
+ // ── Config Get ──────────────────────────────────────────────────
34
+ describe("config get", () => {
35
+ it("should show all config values with defaults when no config files exist", async () => {
36
+ await runConfigCommand(["get"]);
37
+ // Should print key=value lines for all known keys
38
+ expect(consoleLogSpy).toHaveBeenCalled();
39
+ const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
40
+ expect(allOutput).toContain("maxOptions = 5");
41
+ expect(allOutput).toContain("sessionTimeout = 0");
42
+ expect(allOutput).toContain("staleThreshold = 7200000");
43
+ expect(allOutput).toContain("notifyOnStale = true");
44
+ expect(allOutput).toContain("staleAction = warn");
45
+ expect(allOutput).toContain("notifications.enabled = true");
46
+ expect(allOutput).toContain("notifications.sound = true");
47
+ });
48
+ it("should show specific key value", async () => {
49
+ await runConfigCommand(["get", "maxOptions"]);
50
+ expect(consoleLogSpy).toHaveBeenCalledWith("maxOptions = 5");
51
+ });
52
+ it("should show nested key value with dot notation", async () => {
53
+ await runConfigCommand(["get", "notifications.enabled"]);
54
+ expect(consoleLogSpy).toHaveBeenCalledWith("notifications.enabled = true");
55
+ });
56
+ it("should error on unknown key", async () => {
57
+ await runConfigCommand(["get", "unknownKey"]);
58
+ expect(process.exitCode).toBe(1);
59
+ expect(consoleErrorSpy).toHaveBeenCalled();
60
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
61
+ expect(errorOutput).toContain("Unknown config key");
62
+ expect(errorOutput).toContain("unknownKey");
63
+ });
64
+ it("should output valid JSON with --json flag for all config", async () => {
65
+ await runConfigCommand(["get", "--json"]);
66
+ expect(consoleLogSpy).toHaveBeenCalledTimes(1);
67
+ const output = consoleLogSpy.mock.calls[0][0];
68
+ const parsed = JSON.parse(output);
69
+ expect(parsed.success).toBe(true);
70
+ expect(parsed.config).toBeDefined();
71
+ expect(parsed.config.maxOptions).toBe(DEFAULT_CONFIG.maxOptions);
72
+ });
73
+ it("should output valid JSON with --json flag for specific key", async () => {
74
+ await runConfigCommand(["get", "staleThreshold", "--json"]);
75
+ const output = consoleLogSpy.mock.calls[0][0];
76
+ const parsed = JSON.parse(output);
77
+ expect(parsed.success).toBe(true);
78
+ expect(parsed.key).toBe("staleThreshold");
79
+ expect(parsed.value).toBe(7200000);
80
+ });
81
+ it("should merge local config over defaults", async () => {
82
+ const cwd = process.cwd();
83
+ const localPath = `${cwd}/.auqrc.json`;
84
+ vi.mocked(fs.existsSync).mockImplementation((path) => {
85
+ return String(path) === localPath;
86
+ });
87
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ maxOptions: 8 }));
88
+ await runConfigCommand(["get", "maxOptions"]);
89
+ expect(consoleLogSpy).toHaveBeenCalledWith("maxOptions = 8");
90
+ });
91
+ });
92
+ // ── Config Set ──────────────────────────────────────────────────
93
+ describe("config set", () => {
94
+ it("should write valid key to local config file", async () => {
95
+ const cwd = process.cwd();
96
+ const expectedPath = `${cwd}/.auqrc.json`;
97
+ // Simulate directory exists but file does not
98
+ vi.mocked(fs.existsSync).mockImplementation((path) => {
99
+ return String(path) === cwd;
100
+ });
101
+ await runConfigCommand(["set", "staleThreshold", "3600000"]);
102
+ expect(process.exitCode).toBeUndefined();
103
+ expect(fs.writeFileSync).toHaveBeenCalled();
104
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
105
+ expect(String(writeCall[0])).toBe(expectedPath);
106
+ const written = JSON.parse(writeCall[1]);
107
+ expect(written.staleThreshold).toBe(3600000);
108
+ });
109
+ it("should write to global config with --global flag", async () => {
110
+ vi.mocked(fs.existsSync).mockReturnValue(false);
111
+ await runConfigCommand(["set", "staleThreshold", "3600000", "--global"]);
112
+ expect(process.exitCode).toBeUndefined();
113
+ expect(fs.writeFileSync).toHaveBeenCalled();
114
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
115
+ expect(String(writeCall[0])).toContain(".config/auq/.auqrc.json");
116
+ });
117
+ it("should create directory if it doesn't exist", async () => {
118
+ vi.mocked(fs.existsSync).mockReturnValue(false);
119
+ await runConfigCommand(["set", "maxOptions", "8", "--global"]);
120
+ expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".config/auq"), { recursive: true });
121
+ });
122
+ it("should merge with existing config", async () => {
123
+ const cwd = process.cwd();
124
+ const localPath = `${cwd}/.auqrc.json`;
125
+ vi.mocked(fs.existsSync).mockImplementation((path) => {
126
+ return String(path) === localPath || String(path) === cwd;
127
+ });
128
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ maxOptions: 6 }));
129
+ await runConfigCommand(["set", "staleThreshold", "5000000"]);
130
+ expect(fs.writeFileSync).toHaveBeenCalled();
131
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
132
+ const written = JSON.parse(writeCall[1]);
133
+ // Existing key should be preserved
134
+ expect(written.maxOptions).toBe(6);
135
+ // New key should be added
136
+ expect(written.staleThreshold).toBe(5000000);
137
+ });
138
+ it("should error on unknown config key with valid keys list", async () => {
139
+ await runConfigCommand(["set", "badKey", "value"]);
140
+ expect(process.exitCode).toBe(1);
141
+ expect(consoleErrorSpy).toHaveBeenCalled();
142
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
143
+ expect(errorOutput).toContain("Unknown config key");
144
+ expect(errorOutput).toContain("badKey");
145
+ // Should list valid keys
146
+ expect(errorOutput).toContain("maxOptions");
147
+ expect(errorOutput).toContain("staleThreshold");
148
+ });
149
+ it("should error on invalid value type", async () => {
150
+ await runConfigCommand(["set", "maxOptions", "notanumber"]);
151
+ expect(process.exitCode).toBe(1);
152
+ expect(consoleErrorSpy).toHaveBeenCalled();
153
+ const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
154
+ expect(errorOutput).toContain("Invalid value");
155
+ });
156
+ it("should coerce boolean string values correctly", async () => {
157
+ vi.mocked(fs.existsSync).mockReturnValue(false);
158
+ await runConfigCommand(["set", "notifyOnStale", "false"]);
159
+ expect(process.exitCode).toBeUndefined();
160
+ expect(fs.writeFileSync).toHaveBeenCalled();
161
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
162
+ const written = JSON.parse(writeCall[1]);
163
+ expect(written.notifyOnStale).toBe(false);
164
+ });
165
+ it("should validate enum values", async () => {
166
+ vi.mocked(fs.existsSync).mockReturnValue(false);
167
+ await runConfigCommand(["set", "staleAction", "archive"]);
168
+ expect(process.exitCode).toBeUndefined();
169
+ expect(fs.writeFileSync).toHaveBeenCalled();
170
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
171
+ const written = JSON.parse(writeCall[1]);
172
+ expect(written.staleAction).toBe("archive");
173
+ });
174
+ it("should reject invalid enum values", async () => {
175
+ await runConfigCommand(["set", "staleAction", "invalid_action"]);
176
+ expect(process.exitCode).toBe(1);
177
+ expect(consoleErrorSpy).toHaveBeenCalled();
178
+ });
179
+ it("should handle nested key set with dot notation", async () => {
180
+ vi.mocked(fs.existsSync).mockReturnValue(false);
181
+ await runConfigCommand(["set", "notifications.enabled", "false"]);
182
+ expect(process.exitCode).toBeUndefined();
183
+ expect(fs.writeFileSync).toHaveBeenCalled();
184
+ const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0];
185
+ const written = JSON.parse(writeCall[1]);
186
+ expect(written.notifications.enabled).toBe(false);
187
+ });
188
+ it("should output JSON with --json flag", async () => {
189
+ vi.mocked(fs.existsSync).mockReturnValue(false);
190
+ await runConfigCommand(["set", "staleThreshold", "3600000", "--json"]);
191
+ expect(consoleLogSpy).toHaveBeenCalledTimes(1);
192
+ const output = consoleLogSpy.mock.calls[0][0];
193
+ const parsed = JSON.parse(output);
194
+ expect(parsed.success).toBe(true);
195
+ expect(parsed.key).toBe("staleThreshold");
196
+ expect(parsed.value).toBe(3600000);
197
+ expect(parsed.file).toBeDefined();
198
+ });
199
+ it("should error when key and value are missing", async () => {
200
+ await runConfigCommand(["set"]);
201
+ expect(process.exitCode).toBe(1);
202
+ expect(consoleErrorSpy).toHaveBeenCalled();
203
+ });
204
+ });
205
+ // ── Config help ─────────────────────────────────────────────────
206
+ describe("config help", () => {
207
+ it("should show usage help when no subcommand provided", async () => {
208
+ await runConfigCommand([]);
209
+ expect(consoleLogSpy).toHaveBeenCalled();
210
+ const output = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
211
+ expect(output).toContain("Usage");
212
+ });
213
+ it("should show usage and set exitCode for unknown subcommand", async () => {
214
+ await runConfigCommand(["unknown"]);
215
+ expect(process.exitCode).toBe(1);
216
+ });
217
+ });
218
+ });