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
@@ -91,6 +91,7 @@ export const tokyoNightTheme = {
91
91
  successPillBg: "#1a1b26",
92
92
  error: "#f7768e",
93
93
  info: "#7aa2f7",
94
+ warning: "#e0af68",
94
95
  border: "#24283b",
95
96
  },
96
97
  markdown: {
@@ -105,6 +106,8 @@ export const tokyoNightTheme = {
105
106
  untouched: "#7078A3",
106
107
  number: "#c0caf5",
107
108
  activeNumber: "#7aa2f7",
109
+ stale: "#e0af68",
110
+ abandoned: "#f7768e",
108
111
  },
109
112
  sessionPicker: {
110
113
  border: "#7aa2f7",
@@ -115,6 +118,10 @@ export const tokyoNightTheme = {
115
118
  highlightFg: "#9ece6a",
116
119
  activeMark: "#7aa2f7",
117
120
  progress: "#7dcfff",
121
+ staleIcon: "#e0af68",
122
+ staleText: "#e0af68",
123
+ staleAge: "#e0af68",
124
+ staleSubtitle: "#7078A3",
118
125
  },
119
126
  },
120
127
  };
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DEFAULT_GRACE_PERIOD, formatStaleToastMessage, isSessionAbandoned, isSessionStale, } from "../staleDetection.js";
3
+ describe("staleDetection", () => {
4
+ const now = new Date("2026-01-01T12:00:00.000Z");
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(now);
8
+ });
9
+ afterEach(() => {
10
+ vi.useRealTimers();
11
+ });
12
+ describe("isSessionStale", () => {
13
+ const TWO_HOURS = 7200000; // default staleThreshold from config
14
+ it("should return true when session is older than threshold", () => {
15
+ // Session created 3 hours ago, threshold is 2 hours
16
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
17
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS)).toBe(true);
18
+ });
19
+ it("should return false when session is younger than threshold", () => {
20
+ // Session created 1 hour ago, threshold is 2 hours
21
+ const oneHourAgo = now.getTime() - 1 * 3600000;
22
+ expect(isSessionStale(oneHourAgo, TWO_HOURS)).toBe(false);
23
+ });
24
+ it("should return false when session is exactly at threshold", () => {
25
+ // Edge case: exactly at threshold boundary (not greater than)
26
+ const exactlyAtThreshold = now.getTime() - TWO_HOURS;
27
+ expect(isSessionStale(exactlyAtThreshold, TWO_HOURS)).toBe(false);
28
+ });
29
+ it("should return false when stale session has recent interaction within grace period", () => {
30
+ // Session created 3 hours ago (stale), but user interacted 10 minutes ago
31
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
32
+ const tenMinutesAgo = now.getTime() - 10 * 60000;
33
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS, tenMinutesAgo)).toBe(false);
34
+ });
35
+ it("should return true when stale session has old interaction outside grace period", () => {
36
+ // Session created 3 hours ago, last interaction 45 minutes ago (outside 30min grace)
37
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
38
+ const fortyFiveMinutesAgo = now.getTime() - 45 * 60000;
39
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS, fortyFiveMinutesAgo)).toBe(true);
40
+ });
41
+ it("should respect custom grace period", () => {
42
+ // Session created 3 hours ago, last interaction 45 minutes ago
43
+ // With a 1-hour custom grace period, should NOT be stale
44
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
45
+ const fortyFiveMinutesAgo = now.getTime() - 45 * 60000;
46
+ const oneHourGrace = 3600000;
47
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS, fortyFiveMinutesAgo, oneHourGrace)).toBe(false);
48
+ });
49
+ it("should use DEFAULT_GRACE_PERIOD when no grace period provided", () => {
50
+ // Interaction 29 minutes ago (within default 30-minute grace)
51
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
52
+ const twentyNineMinutesAgo = now.getTime() - 29 * 60000;
53
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS, twentyNineMinutesAgo)).toBe(false);
54
+ // Interaction 31 minutes ago (outside default 30-minute grace)
55
+ const thirtyOneMinutesAgo = now.getTime() - 31 * 60000;
56
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS, thirtyOneMinutesAgo)).toBe(true);
57
+ });
58
+ it("should handle undefined lastInteraction", () => {
59
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
60
+ // No interaction => stale if age exceeds threshold
61
+ expect(isSessionStale(threeHoursAgo, TWO_HOURS, undefined)).toBe(true);
62
+ });
63
+ it("should handle zero threshold (always stale if age > 0)", () => {
64
+ const oneSecondAgo = now.getTime() - 1000;
65
+ expect(isSessionStale(oneSecondAgo, 0)).toBe(true);
66
+ });
67
+ });
68
+ describe("isSessionAbandoned", () => {
69
+ it("should return true for 'abandoned' status", () => {
70
+ expect(isSessionAbandoned("abandoned")).toBe(true);
71
+ });
72
+ it("should return false for 'pending' status", () => {
73
+ expect(isSessionAbandoned("pending")).toBe(false);
74
+ });
75
+ it("should return false for 'completed' status", () => {
76
+ expect(isSessionAbandoned("completed")).toBe(false);
77
+ });
78
+ it("should return false for 'in-progress' status", () => {
79
+ expect(isSessionAbandoned("in-progress")).toBe(false);
80
+ });
81
+ it("should return false for 'rejected' status", () => {
82
+ expect(isSessionAbandoned("rejected")).toBe(false);
83
+ });
84
+ it("should return false for 'timed_out' status", () => {
85
+ expect(isSessionAbandoned("timed_out")).toBe(false);
86
+ });
87
+ it("should return false for empty string", () => {
88
+ expect(isSessionAbandoned("")).toBe(false);
89
+ });
90
+ });
91
+ describe("formatStaleToastMessage", () => {
92
+ it("should format hours correctly for a 3-hour-old session", () => {
93
+ const threeHoursAgo = now.getTime() - 3 * 3600000;
94
+ const message = formatStaleToastMessage("Test Session", threeHoursAgo);
95
+ expect(message).toBe('Session "Test Session" may be orphaned (created 3h ago)');
96
+ });
97
+ it("should show 0h for very recent sessions", () => {
98
+ const thirtyMinutesAgo = now.getTime() - 30 * 60000;
99
+ const message = formatStaleToastMessage("New Session", thirtyMinutesAgo);
100
+ expect(message).toBe('Session "New Session" may be orphaned (created 0h ago)');
101
+ });
102
+ it("should show large hour counts for old sessions", () => {
103
+ const twoDaysAgo = now.getTime() - 48 * 3600000;
104
+ const message = formatStaleToastMessage("Old Session", twoDaysAgo);
105
+ expect(message).toBe('Session "Old Session" may be orphaned (created 48h ago)');
106
+ });
107
+ it("should handle session title with special characters", () => {
108
+ const twoHoursAgo = now.getTime() - 2 * 3600000;
109
+ const message = formatStaleToastMessage("Session \"quoted\"", twoHoursAgo);
110
+ expect(message).toBe('Session "Session "quoted"" may be orphaned (created 2h ago)');
111
+ });
112
+ });
113
+ describe("DEFAULT_GRACE_PERIOD", () => {
114
+ it("should be 30 minutes in milliseconds", () => {
115
+ expect(DEFAULT_GRACE_PERIOD).toBe(1800000);
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Stale Session Detection Utilities
3
+ *
4
+ * Pure functions for computing session staleness. No I/O or React dependencies.
5
+ * Design Decision 4: "Stale is computed flag, not persisted status"
6
+ * Design Decision 8: "Interaction resets stale check for that session"
7
+ */
8
+ /**
9
+ * Default grace period in milliseconds (30 minutes).
10
+ * If a user interacted with a session within this window, it is not considered stale.
11
+ */
12
+ export const DEFAULT_GRACE_PERIOD = 1800000; // 30 minutes
13
+ /**
14
+ * Determine whether a session should be considered stale based on its age,
15
+ * a configurable threshold, and an optional last-interaction timestamp.
16
+ *
17
+ * @param requestTimestamp - When the session was originally created (epoch ms)
18
+ * @param staleThreshold - Maximum allowed age before a session is stale (ms)
19
+ * @param lastInteraction - Last time the user interacted with this session (epoch ms)
20
+ * @param gracePeriod - Time after an interaction during which the session is not stale (ms)
21
+ * @returns true if the session is stale
22
+ */
23
+ export function isSessionStale(requestTimestamp, staleThreshold, lastInteraction, gracePeriod = DEFAULT_GRACE_PERIOD) {
24
+ const now = Date.now();
25
+ // If the user interacted recently (within grace period), session is not stale
26
+ if (lastInteraction && now - lastInteraction < gracePeriod) {
27
+ return false;
28
+ }
29
+ return now - requestTimestamp > staleThreshold;
30
+ }
31
+ /**
32
+ * Check whether a session has been explicitly marked as abandoned
33
+ * (e.g. AI disconnected).
34
+ *
35
+ * @param status - The session's current status string
36
+ * @returns true if status is "abandoned"
37
+ */
38
+ export function isSessionAbandoned(status) {
39
+ return status === "abandoned";
40
+ }
41
+ /**
42
+ * Build a human-readable toast message for a stale session.
43
+ *
44
+ * @param sessionTitle - Display name or ID of the session
45
+ * @param createdAtMs - Session creation time in epoch milliseconds
46
+ * @returns Formatted message string
47
+ */
48
+ export function formatStaleToastMessage(sessionTitle, createdAtMs) {
49
+ const hoursAgo = Math.floor((Date.now() - createdAtMs) / 3600000);
50
+ return `Session "${sessionTitle}" may be orphaned (created ${hoursAgo}h ago)`;
51
+ }
@@ -0,0 +1,136 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Mock node:fs/promises and node:os before importing the module under test
3
+ vi.mock("node:fs/promises", () => ({
4
+ readFile: vi.fn(),
5
+ writeFile: vi.fn(),
6
+ mkdir: vi.fn(),
7
+ unlink: vi.fn(),
8
+ }));
9
+ vi.mock("node:os", () => ({
10
+ homedir: vi.fn(() => "/home/testuser"),
11
+ }));
12
+ import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
13
+ import { getCachePath, readCache, writeCache, isCacheFresh, shouldSkipVersion, clearUpdateCache, CACHE_TTL, } from "../cache.js";
14
+ describe("cache management", () => {
15
+ const originalEnv = process.env;
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ process.env = { ...originalEnv };
19
+ delete process.env.XDG_CONFIG_HOME;
20
+ });
21
+ afterEach(() => {
22
+ process.env = originalEnv;
23
+ vi.restoreAllMocks();
24
+ });
25
+ describe("CACHE_TTL", () => {
26
+ it("should be 1 hour in milliseconds", () => {
27
+ expect(CACHE_TTL).toBe(3600000);
28
+ });
29
+ });
30
+ describe("getCachePath", () => {
31
+ it("should use homedir/.config when XDG_CONFIG_HOME is not set", () => {
32
+ const path = getCachePath();
33
+ expect(path).toContain("/home/testuser/.config/auq/update-check.json");
34
+ });
35
+ it("should use XDG_CONFIG_HOME when set", () => {
36
+ process.env.XDG_CONFIG_HOME = "/custom/config";
37
+ const path = getCachePath();
38
+ expect(path).toContain("/custom/config/auq/update-check.json");
39
+ });
40
+ });
41
+ describe("readCache", () => {
42
+ it("should return parsed cache when file contains valid JSON", async () => {
43
+ const cache = {
44
+ lastCheck: Date.now(),
45
+ latestVersion: "2.5.0",
46
+ };
47
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(cache));
48
+ const result = await readCache();
49
+ expect(result).toEqual(cache);
50
+ });
51
+ it("should return null when file is missing", async () => {
52
+ vi.mocked(readFile).mockRejectedValue(new Error("ENOENT"));
53
+ const result = await readCache();
54
+ expect(result).toBeNull();
55
+ });
56
+ it("should return null when file contains invalid JSON", async () => {
57
+ vi.mocked(readFile).mockResolvedValue("not valid json{{{");
58
+ const result = await readCache();
59
+ expect(result).toBeNull();
60
+ });
61
+ });
62
+ describe("writeCache", () => {
63
+ it("should create directory and write JSON", async () => {
64
+ vi.mocked(mkdir).mockResolvedValue(undefined);
65
+ vi.mocked(writeFile).mockResolvedValue(undefined);
66
+ const cache = {
67
+ lastCheck: Date.now(),
68
+ latestVersion: "2.5.0",
69
+ };
70
+ await writeCache(cache);
71
+ expect(mkdir).toHaveBeenCalledWith(expect.stringContaining("auq"), { recursive: true });
72
+ expect(writeFile).toHaveBeenCalledWith(expect.stringContaining("update-check.json"), JSON.stringify(cache, null, 2), "utf-8");
73
+ });
74
+ it("should silently handle write errors", async () => {
75
+ vi.mocked(mkdir).mockRejectedValue(new Error("EACCES"));
76
+ const cache = {
77
+ lastCheck: Date.now(),
78
+ latestVersion: "2.5.0",
79
+ };
80
+ // Should not throw
81
+ await expect(writeCache(cache)).resolves.toBeUndefined();
82
+ });
83
+ });
84
+ describe("isCacheFresh", () => {
85
+ it("should return true for a recent cache (30 min ago)", () => {
86
+ const cache = {
87
+ lastCheck: Date.now() - 30 * 60 * 1000, // 30 minutes ago
88
+ latestVersion: "2.5.0",
89
+ };
90
+ expect(isCacheFresh(cache)).toBe(true);
91
+ });
92
+ it("should return false for an old cache (2 hours ago)", () => {
93
+ const cache = {
94
+ lastCheck: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
95
+ latestVersion: "2.5.0",
96
+ };
97
+ expect(isCacheFresh(cache)).toBe(false);
98
+ });
99
+ });
100
+ describe("shouldSkipVersion", () => {
101
+ it("should return true when skippedVersion matches", () => {
102
+ const cache = {
103
+ lastCheck: Date.now(),
104
+ latestVersion: "2.5.0",
105
+ skippedVersion: "2.5.0",
106
+ };
107
+ expect(shouldSkipVersion(cache, "2.5.0")).toBe(true);
108
+ });
109
+ it("should return false when skippedVersion does not match", () => {
110
+ const cache = {
111
+ lastCheck: Date.now(),
112
+ latestVersion: "2.5.0",
113
+ skippedVersion: "2.4.0",
114
+ };
115
+ expect(shouldSkipVersion(cache, "2.5.0")).toBe(false);
116
+ });
117
+ it("should return false when skippedVersion is undefined", () => {
118
+ const cache = {
119
+ lastCheck: Date.now(),
120
+ latestVersion: "2.5.0",
121
+ };
122
+ expect(shouldSkipVersion(cache, "2.5.0")).toBe(false);
123
+ });
124
+ });
125
+ describe("clearUpdateCache", () => {
126
+ it("should call unlink on the cache file", async () => {
127
+ vi.mocked(unlink).mockResolvedValue(undefined);
128
+ await clearUpdateCache();
129
+ expect(unlink).toHaveBeenCalledWith(expect.stringContaining("update-check.json"));
130
+ });
131
+ it("should silently handle errors when file does not exist", async () => {
132
+ vi.mocked(unlink).mockRejectedValue(new Error("ENOENT"));
133
+ await expect(clearUpdateCache()).resolves.toBeUndefined();
134
+ });
135
+ });
136
+ });
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Mock the cache module before importing changelog
3
+ vi.mock("../cache.js", () => ({
4
+ readCache: vi.fn(),
5
+ writeCache: vi.fn(),
6
+ }));
7
+ import { readCache, writeCache } from "../cache.js";
8
+ import { fetchChangelog } from "../changelog.js";
9
+ describe("changelog fetcher", () => {
10
+ const originalFetch = globalThis.fetch;
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ // Default mock: no cache
14
+ vi.mocked(readCache).mockResolvedValue(null);
15
+ vi.mocked(writeCache).mockResolvedValue(undefined);
16
+ });
17
+ afterEach(() => {
18
+ globalThis.fetch = originalFetch;
19
+ vi.restoreAllMocks();
20
+ });
21
+ it("should return content and fallbackUrl on successful fetch", async () => {
22
+ globalThis.fetch = vi.fn().mockResolvedValue({
23
+ ok: true,
24
+ status: 200,
25
+ json: () => Promise.resolve({ body: "## Changes\n- Fix bug" }),
26
+ });
27
+ const result = await fetchChangelog("2.5.0");
28
+ expect(result.content).toBe("## Changes\n- Fix bug");
29
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v2.5.0");
30
+ });
31
+ it("should return cached changelog without fetching", async () => {
32
+ vi.mocked(readCache).mockResolvedValue({
33
+ lastCheck: Date.now(),
34
+ latestVersion: "2.5.0",
35
+ changelog: "## Cached changelog",
36
+ changelogFetchedAt: Date.now(),
37
+ });
38
+ globalThis.fetch = vi.fn();
39
+ const result = await fetchChangelog("2.5.0");
40
+ expect(result.content).toBe("## Cached changelog");
41
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v2.5.0");
42
+ expect(globalThis.fetch).not.toHaveBeenCalled();
43
+ });
44
+ it("should return null content on 403 rate limit", async () => {
45
+ globalThis.fetch = vi.fn().mockResolvedValue({
46
+ ok: false,
47
+ status: 403,
48
+ });
49
+ const result = await fetchChangelog("2.5.0");
50
+ expect(result.content).toBeNull();
51
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v2.5.0");
52
+ });
53
+ it("should return null content on 429 rate limit", async () => {
54
+ globalThis.fetch = vi.fn().mockResolvedValue({
55
+ ok: false,
56
+ status: 429,
57
+ });
58
+ const result = await fetchChangelog("2.5.0");
59
+ expect(result.content).toBeNull();
60
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v2.5.0");
61
+ });
62
+ it("should return null content on network error", async () => {
63
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("network error"));
64
+ const result = await fetchChangelog("2.5.0");
65
+ expect(result.content).toBeNull();
66
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v2.5.0");
67
+ });
68
+ it("should return null content on non-OK response", async () => {
69
+ globalThis.fetch = vi.fn().mockResolvedValue({
70
+ ok: false,
71
+ status: 500,
72
+ });
73
+ const result = await fetchChangelog("2.5.0");
74
+ expect(result.content).toBeNull();
75
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v2.5.0");
76
+ });
77
+ it("should always have correct fallbackUrl format", async () => {
78
+ globalThis.fetch = vi.fn().mockResolvedValue({
79
+ ok: true,
80
+ status: 200,
81
+ json: () => Promise.resolve({ body: "content" }),
82
+ });
83
+ const result = await fetchChangelog("3.1.0");
84
+ expect(result.fallbackUrl).toBe("https://github.com/AlpacaLOS/auq/releases/tag/v3.1.0");
85
+ });
86
+ });
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ // Mock dependencies before importing the module under test
3
+ vi.mock("../cache.js", () => ({
4
+ readCache: vi.fn(),
5
+ writeCache: vi.fn(),
6
+ isCacheFresh: vi.fn(),
7
+ shouldSkipVersion: vi.fn(),
8
+ clearUpdateCache: vi.fn(),
9
+ }));
10
+ vi.mock("../version.js", () => ({
11
+ isNewer: vi.fn(),
12
+ getUpdateType: vi.fn(),
13
+ getCurrentVersion: vi.fn(() => "1.0.0"),
14
+ }));
15
+ import { readCache, writeCache, isCacheFresh, shouldSkipVersion, clearUpdateCache, } from "../cache.js";
16
+ import { isNewer, getUpdateType, getCurrentVersion } from "../version.js";
17
+ import { UpdateChecker } from "../checker.js";
18
+ describe("UpdateChecker", () => {
19
+ const originalEnv = { ...process.env };
20
+ const originalFetch = globalThis.fetch;
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ // Clear env vars that trigger shouldSkipCheck
24
+ delete process.env.CI;
25
+ delete process.env.NO_UPDATE_NOTIFIER;
26
+ delete process.env.NODE_ENV;
27
+ // Default mocks
28
+ vi.mocked(readCache).mockResolvedValue(null);
29
+ vi.mocked(writeCache).mockResolvedValue(undefined);
30
+ vi.mocked(clearUpdateCache).mockResolvedValue(undefined);
31
+ vi.mocked(isCacheFresh).mockReturnValue(false);
32
+ vi.mocked(shouldSkipVersion).mockReturnValue(false);
33
+ vi.mocked(isNewer).mockReturnValue(false);
34
+ vi.mocked(getUpdateType).mockReturnValue("patch");
35
+ vi.mocked(getCurrentVersion).mockReturnValue("1.0.0");
36
+ });
37
+ afterEach(() => {
38
+ process.env = { ...originalEnv };
39
+ globalThis.fetch = originalFetch;
40
+ vi.restoreAllMocks();
41
+ });
42
+ describe("shouldSkipCheck", () => {
43
+ it("should return true when CI=true", () => {
44
+ process.env.CI = "true";
45
+ const checker = new UpdateChecker("1.0.0");
46
+ expect(checker.shouldSkipCheck()).toBe(true);
47
+ });
48
+ it("should return true when NO_UPDATE_NOTIFIER=1", () => {
49
+ process.env.NO_UPDATE_NOTIFIER = "1";
50
+ const checker = new UpdateChecker("1.0.0");
51
+ expect(checker.shouldSkipCheck()).toBe(true);
52
+ });
53
+ it("should return true when NODE_ENV=test", () => {
54
+ process.env.NODE_ENV = "test";
55
+ const checker = new UpdateChecker("1.0.0");
56
+ expect(checker.shouldSkipCheck()).toBe(true);
57
+ });
58
+ it("should return false when none of the skip conditions are set", () => {
59
+ const checker = new UpdateChecker("1.0.0");
60
+ expect(checker.shouldSkipCheck()).toBe(false);
61
+ });
62
+ });
63
+ describe("check", () => {
64
+ it("should return null when shouldSkipCheck is true", async () => {
65
+ process.env.CI = "true";
66
+ const checker = new UpdateChecker("1.0.0");
67
+ const result = await checker.check();
68
+ expect(result).toBeNull();
69
+ });
70
+ it("should return UpdateInfo when a newer version is available", async () => {
71
+ // Ensure shouldSkipCheck returns false
72
+ process.env.NODE_ENV = "development";
73
+ vi.mocked(isNewer).mockReturnValue(true);
74
+ vi.mocked(getUpdateType).mockReturnValue("minor");
75
+ globalThis.fetch = vi.fn().mockResolvedValue({
76
+ ok: true,
77
+ json: () => Promise.resolve({ "dist-tags": { latest: "1.1.0" } }),
78
+ });
79
+ const checker = new UpdateChecker("1.0.0");
80
+ const result = await checker.check();
81
+ expect(result).not.toBeNull();
82
+ expect(result?.currentVersion).toBe("1.0.0");
83
+ expect(result?.latestVersion).toBe("1.1.0");
84
+ expect(result?.updateType).toBe("minor");
85
+ expect(result?.changelogUrl).toContain("v1.1.0");
86
+ });
87
+ it("should return null when versions are the same", async () => {
88
+ // Ensure shouldSkipCheck returns false
89
+ process.env.NODE_ENV = "development";
90
+ vi.mocked(isNewer).mockReturnValue(false);
91
+ globalThis.fetch = vi.fn().mockResolvedValue({
92
+ ok: true,
93
+ json: () => Promise.resolve({ "dist-tags": { latest: "1.0.0" } }),
94
+ });
95
+ const checker = new UpdateChecker("1.0.0");
96
+ const result = await checker.check();
97
+ expect(result).toBeNull();
98
+ });
99
+ it("should return null on fetch error", async () => {
100
+ // Ensure shouldSkipCheck returns false
101
+ process.env.NODE_ENV = "development";
102
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("network error"));
103
+ const checker = new UpdateChecker("1.0.0");
104
+ const result = await checker.check();
105
+ expect(result).toBeNull();
106
+ });
107
+ it("should memoize: two check() calls only fetch once", async () => {
108
+ // Ensure shouldSkipCheck returns false
109
+ process.env.NODE_ENV = "development";
110
+ vi.mocked(isNewer).mockReturnValue(true);
111
+ vi.mocked(getUpdateType).mockReturnValue("patch");
112
+ globalThis.fetch = vi.fn().mockResolvedValue({
113
+ ok: true,
114
+ json: () => Promise.resolve({ "dist-tags": { latest: "1.0.1" } }),
115
+ });
116
+ const checker = new UpdateChecker("1.0.0");
117
+ const [result1, result2] = await Promise.all([
118
+ checker.check(),
119
+ checker.check(),
120
+ ]);
121
+ // Both should resolve to the same value
122
+ expect(result1).toStrictEqual(result2);
123
+ // Fetch should have been called only once (memoized)
124
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
125
+ });
126
+ });
127
+ describe("clearCache", () => {
128
+ it("should reset the memoized promise", async () => {
129
+ // Ensure shouldSkipCheck returns false
130
+ process.env.NODE_ENV = "development";
131
+ vi.mocked(isNewer).mockReturnValue(true);
132
+ vi.mocked(getUpdateType).mockReturnValue("patch");
133
+ globalThis.fetch = vi.fn().mockResolvedValue({
134
+ ok: true,
135
+ json: () => Promise.resolve({ "dist-tags": { latest: "1.0.1" } }),
136
+ });
137
+ const checker = new UpdateChecker("1.0.0");
138
+ // First check
139
+ const result1 = await checker.check();
140
+ expect(result1).not.toBeNull();
141
+ // Clear cache
142
+ checker.clearCache();
143
+ // Second check should create a new promise (calls fetch again)
144
+ const result2 = await checker.check();
145
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as updateModule from "../index.js";
3
+ describe("update module exports", () => {
4
+ it("exports UpdateChecker class", () => {
5
+ expect(typeof updateModule.UpdateChecker).toBe("function");
6
+ });
7
+ it("exports fetchChangelog function", () => {
8
+ expect(typeof updateModule.fetchChangelog).toBe("function");
9
+ });
10
+ it("exports cache functions", () => {
11
+ expect(typeof updateModule.readCache).toBe("function");
12
+ expect(typeof updateModule.writeCache).toBe("function");
13
+ expect(typeof updateModule.clearUpdateCache).toBe("function");
14
+ expect(typeof updateModule.isCacheFresh).toBe("function");
15
+ expect(typeof updateModule.shouldSkipVersion).toBe("function");
16
+ expect(typeof updateModule.getCachePath).toBe("function");
17
+ });
18
+ it("exports CACHE_TTL as 3600000", () => {
19
+ expect(updateModule.CACHE_TTL).toBe(3600000);
20
+ });
21
+ it("exports installer functions", () => {
22
+ expect(typeof updateModule.installUpdate).toBe("function");
23
+ expect(typeof updateModule.getManualCommand).toBe("function");
24
+ });
25
+ it("exports package manager functions", () => {
26
+ expect(typeof updateModule.detectPackageManager).toBe("function");
27
+ });
28
+ it("exports PACKAGE_NAME as auq-mcp-server", () => {
29
+ expect(updateModule.PACKAGE_NAME).toBe("auq-mcp-server");
30
+ });
31
+ it("exports version utilities", () => {
32
+ expect(typeof updateModule.parseVersion).toBe("function");
33
+ expect(typeof updateModule.isNewer).toBe("function");
34
+ expect(typeof updateModule.getUpdateType).toBe("function");
35
+ expect(typeof updateModule.getCurrentVersion).toBe("function");
36
+ });
37
+ });