auq-mcp-server 2.4.0 → 2.6.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 (32) hide show
  1. package/README.md +40 -0
  2. package/dist/bin/auq.js +40 -0
  3. package/dist/bin/tui-app.js +115 -1
  4. package/dist/package.json +1 -1
  5. package/dist/src/cli/commands/sessions.js +144 -2
  6. package/dist/src/cli/commands/update.js +124 -0
  7. package/dist/src/config/__tests__/updateCheck.test.js +34 -0
  8. package/dist/src/config/defaults.js +2 -0
  9. package/dist/src/config/types.js +2 -0
  10. package/dist/src/tui/components/Footer.js +4 -1
  11. package/dist/src/tui/components/Header.js +3 -1
  12. package/dist/src/tui/components/UpdateBadge.js +29 -0
  13. package/dist/src/tui/components/UpdateOverlay.js +199 -0
  14. package/dist/src/tui/constants/keybindings.js +3 -0
  15. package/dist/src/update/__tests__/cache.test.js +136 -0
  16. package/dist/src/update/__tests__/changelog.test.js +86 -0
  17. package/dist/src/update/__tests__/checker.test.js +148 -0
  18. package/dist/src/update/__tests__/index.test.js +37 -0
  19. package/dist/src/update/__tests__/installer.test.js +117 -0
  20. package/dist/src/update/__tests__/package-manager.test.js +73 -0
  21. package/dist/src/update/__tests__/version.test.js +74 -0
  22. package/dist/src/update/cache.js +74 -0
  23. package/dist/src/update/changelog.js +63 -0
  24. package/dist/src/update/checker.js +121 -0
  25. package/dist/src/update/index.js +15 -0
  26. package/dist/src/update/installer.js +51 -0
  27. package/dist/src/update/package-manager.js +49 -0
  28. package/dist/src/update/types.js +7 -0
  29. package/dist/src/update/version.js +114 -0
  30. package/package.json +1 -1
  31. package/dist/src/tui/components/Spinner.js +0 -19
  32. package/dist/src/tui/utils/__tests__/detectTheme.test.js +0 -78
@@ -8,7 +8,7 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
8
8
  * Footer component - displays context-aware keybindings
9
9
  * Shows different shortcuts based on current focus context and question type
10
10
  */
11
- export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, showSessionSwitching = false, customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, }) => {
11
+ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, showSessionSwitching = false, customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, hasUpdate = false, }) => {
12
12
  const { theme } = useTheme();
13
13
  const [spinnerFrame, setSpinnerFrame] = useState(0);
14
14
  // Animate spinner when submitting
@@ -75,6 +75,9 @@ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, show
75
75
  bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
76
76
  }
77
77
  bindings.push({ key: KEY_LABELS.THEME, action: t("footer.theme") });
78
+ if (hasUpdate) {
79
+ bindings.push({ key: KEY_LABELS.UPDATE, action: "Update" });
80
+ }
78
81
  bindings.push({ key: KEY_LABELS.REJECT, action: t("footer.reject") });
79
82
  return bindings;
80
83
  }
@@ -3,11 +3,12 @@ import React, { useEffect, useState } from "react";
3
3
  import { t } from "../../i18n/index.js";
4
4
  import { useTheme } from "../ThemeContext.js";
5
5
  import packageJson from "../../../package.json" with { type: "json" };
6
+ import { UpdateBadge } from "./UpdateBadge.js";
6
7
  /**
7
8
  * Header component - displays app logo and status
8
9
  * Shows at the top of the TUI with gradient branding and live-updating pending queue count
9
10
  */
10
- export const Header = ({ pendingCount }) => {
11
+ export const Header = ({ pendingCount, updateInfo, onUpdateBadgeActivate }) => {
11
12
  const { theme } = useTheme();
12
13
  const [flash, setFlash] = useState(false);
13
14
  const [prevCount, setPrevCount] = useState(pendingCount);
@@ -35,6 +36,7 @@ export const Header = ({ pendingCount }) => {
35
36
  React.createElement(Text, { dimColor: true },
36
37
  "v",
37
38
  version),
39
+ updateInfo && (React.createElement(UpdateBadge, { updateType: updateInfo.updateType, latestVersion: updateInfo.latestVersion })),
38
40
  React.createElement(Text, { dimColor: true }, " "),
39
41
  React.createElement(Text, { backgroundColor: theme.components.header.pillBg, bold: flash, color: flash
40
42
  ? theme.components.header.queueFlash
@@ -0,0 +1,29 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { useTheme } from "../ThemeContext.js";
4
+ /**
5
+ * UpdateBadge — compact header indicator shown when a new version is available.
6
+ *
7
+ * Color-coding by severity:
8
+ * patch → success (green) — minor fix, can auto-install
9
+ * minor → warning (yellow) — new features
10
+ * major → error (red) — possible breaking changes
11
+ *
12
+ * Rendered inside Header next to the version text.
13
+ */
14
+ export const UpdateBadge = ({ updateType, latestVersion, }) => {
15
+ const { theme } = useTheme();
16
+ // Map update severity to theme colors
17
+ const colorMap = {
18
+ patch: theme.colors.success,
19
+ minor: theme.colors.warning,
20
+ major: theme.colors.error,
21
+ };
22
+ const color = colorMap[updateType] ?? theme.colors.info;
23
+ // Patch updates are concise (they auto-install); minor/major show the target version
24
+ const label = updateType === "patch"
25
+ ? "↑ Update"
26
+ : `↑ v${latestVersion}`;
27
+ return (React.createElement(Box, { marginLeft: 1 },
28
+ React.createElement(Text, { backgroundColor: color, bold: true, color: "#000000" }, ` ${label} `)));
29
+ };
@@ -0,0 +1,199 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import React, { useState } from "react";
3
+ import Markdown from "ink-markdown-es";
4
+ import { lexer } from "marked";
5
+ import { useTheme } from "../ThemeContext.js";
6
+ import { detectPackageManager } from "../../update/package-manager.js";
7
+ import { getManualCommand } from "../../update/installer.js";
8
+ /**
9
+ * UpdateOverlay — fullscreen modal for minor/major update prompts.
10
+ *
11
+ * Displays version information, changelog (rendered as Markdown), and
12
+ * action buttons. Major updates show a breaking-change warning badge.
13
+ *
14
+ * Navigation:
15
+ * Tab / → : next button
16
+ * Shift+Tab / ← : previous button
17
+ * Enter : trigger focused action
18
+ * Esc : same as "Remind me later"
19
+ */
20
+ export const UpdateOverlay = ({ isOpen, currentVersion, latestVersion, updateType, changelog, changelogUrl, isInstalling, installError, onInstall, onSkipVersion, onRemindLater, }) => {
21
+ const { theme } = useTheme();
22
+ const { stdout } = useStdout();
23
+ const termWidth = stdout?.columns ?? 80;
24
+ const [focusedButton, setFocusedButton] = useState(0);
25
+ // ── Actions bound to button indices ──────────────────────────────
26
+ const actions = [onInstall, onSkipVersion, onRemindLater];
27
+ const buttonLabels = ["Yes, update", "Skip this version", "Remind me later"];
28
+ const buttonCount = buttonLabels.length;
29
+ // ── Keyboard handling (only active when overlay is open) ────────
30
+ useInput((input, key) => {
31
+ // Installing or error state — only Esc closes
32
+ if (isInstalling)
33
+ return;
34
+ if (installError) {
35
+ if (key.return || key.escape) {
36
+ onRemindLater();
37
+ }
38
+ return;
39
+ }
40
+ // Tab / Right arrow: next button
41
+ if (key.tab && !key.shift) {
42
+ setFocusedButton((prev) => (prev + 1) % buttonCount);
43
+ return;
44
+ }
45
+ if (key.rightArrow) {
46
+ setFocusedButton((prev) => (prev + 1) % buttonCount);
47
+ return;
48
+ }
49
+ // Shift+Tab / Left arrow: previous button
50
+ if (key.tab && key.shift) {
51
+ setFocusedButton((prev) => (prev - 1 + buttonCount) % buttonCount);
52
+ return;
53
+ }
54
+ if (key.leftArrow) {
55
+ setFocusedButton((prev) => (prev - 1 + buttonCount) % buttonCount);
56
+ return;
57
+ }
58
+ // Enter: trigger focused button
59
+ if (key.return) {
60
+ actions[focusedButton]();
61
+ return;
62
+ }
63
+ // Escape: remind me later
64
+ if (key.escape) {
65
+ onRemindLater();
66
+ return;
67
+ }
68
+ }, { isActive: isOpen });
69
+ // ── Render nothing when closed ──────────────────────────────────
70
+ if (!isOpen)
71
+ return null;
72
+ // ── Color mapping for update type badges ────────────────────────
73
+ const typeColorMap = {
74
+ patch: theme.colors.success,
75
+ minor: theme.colors.warning,
76
+ major: theme.colors.error,
77
+ };
78
+ const typeColor = typeColorMap[updateType] ?? theme.colors.info;
79
+ const overlayWidth = Math.min(64, termWidth - 4);
80
+ const innerWidth = overlayWidth - 6; // account for border + paddingX
81
+ // ── Package manager info for error display ──────────────────────
82
+ const packageManager = detectPackageManager();
83
+ const manualCommand = getManualCommand(packageManager);
84
+ // ── Markdown styles matching MarkdownPrompt ─────────────────────
85
+ const markdownStyles = {
86
+ code: {
87
+ backgroundColor: theme.components.markdown.codeBlockBg,
88
+ color: theme.components.markdown.codeBlockText,
89
+ borderColor: theme.components.markdown.codeBlockBorder,
90
+ borderStyle: "round",
91
+ paddingX: 1,
92
+ },
93
+ codespan: {
94
+ backgroundColor: theme.components.markdown.codeBlockBg,
95
+ color: theme.components.markdown.codeBlockText,
96
+ },
97
+ };
98
+ // ── Render changelog section ────────────────────────────────────
99
+ const renderChangelog = () => {
100
+ if (changelog) {
101
+ // Determine if changelog has block elements (like MarkdownPrompt)
102
+ let hasBlockElements = false;
103
+ try {
104
+ const tokens = lexer(changelog);
105
+ hasBlockElements = tokens.some((token) => ["code", "list", "blockquote", "heading", "hr", "table"].includes(token.type));
106
+ }
107
+ catch {
108
+ // fall through, treat as inline
109
+ }
110
+ const renderers = {
111
+ link: (linkText, href) => (React.createElement(Text, null,
112
+ linkText,
113
+ " (",
114
+ href,
115
+ ")")),
116
+ ...(!hasBlockElements
117
+ ? {
118
+ paragraph: (content) => (React.createElement(Text, null, content)),
119
+ }
120
+ : {}),
121
+ };
122
+ return (React.createElement(Box, { borderStyle: "round", borderColor: theme.borders.neutral, flexDirection: "column", paddingX: 1, paddingY: 1, width: innerWidth },
123
+ React.createElement(Markdown, { styles: markdownStyles, renderers: renderers, highlight: true }, changelog)));
124
+ }
125
+ // No changelog — show fallback link
126
+ return (React.createElement(Box, { borderStyle: "round", borderColor: theme.borders.neutral, paddingX: 1, paddingY: 1, width: innerWidth },
127
+ React.createElement(Text, { dimColor: true },
128
+ "View changelog: ",
129
+ changelogUrl)));
130
+ };
131
+ // ── Installing state ────────────────────────────────────────────
132
+ if (isInstalling) {
133
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
134
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borders.primary, paddingX: 2, paddingY: 1, width: overlayWidth, alignItems: "center" },
135
+ React.createElement(Box, { marginBottom: 1 },
136
+ React.createElement(Text, { bold: true, color: theme.colors.primary }, "Installing Update")),
137
+ React.createElement(Box, { marginBottom: 1 },
138
+ React.createElement(Text, null,
139
+ React.createElement(Text, { color: theme.colors.info }, "\u280B "),
140
+ "Installing v",
141
+ latestVersion,
142
+ "\u2026")),
143
+ React.createElement(Box, null,
144
+ React.createElement(Text, { dimColor: true },
145
+ "Running: ",
146
+ manualCommand)))));
147
+ }
148
+ // ── Error state ─────────────────────────────────────────────────
149
+ if (installError) {
150
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
151
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borders.error, paddingX: 2, paddingY: 1, width: overlayWidth },
152
+ React.createElement(Box, { marginBottom: 1 },
153
+ React.createElement(Text, { bold: true, color: theme.colors.error }, "Update Failed")),
154
+ React.createElement(Box, { marginBottom: 1 },
155
+ React.createElement(Text, { color: theme.colors.error }, installError)),
156
+ React.createElement(Box, { marginBottom: 1, flexDirection: "column" },
157
+ React.createElement(Text, { dimColor: true }, "Try running manually:"),
158
+ React.createElement(Box, { marginTop: 0 },
159
+ React.createElement(Text, { bold: true, color: theme.colors.info },
160
+ " ",
161
+ manualCommand))),
162
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
163
+ React.createElement(Text, { bold: true, backgroundColor: theme.components.options.focusedBg, color: theme.colors.focused }, " Close ")),
164
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
165
+ React.createElement(Text, { dimColor: true }, "Enter or Esc to close")))));
166
+ }
167
+ // ── Default state: update prompt with buttons ───────────────────
168
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
169
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borders.primary, paddingX: 2, paddingY: 1, width: overlayWidth },
170
+ React.createElement(Box, { justifyContent: "center", marginBottom: 1 },
171
+ React.createElement(Text, { bold: true, color: theme.colors.primary }, "Update Available")),
172
+ React.createElement(Box, { justifyContent: "center", marginBottom: 1 },
173
+ React.createElement(Text, null,
174
+ React.createElement(Text, { dimColor: true }, "Current: "),
175
+ React.createElement(Text, null, currentVersion),
176
+ React.createElement(Text, { dimColor: true }, " \u2192 "),
177
+ React.createElement(Text, { dimColor: true }, "Latest: "),
178
+ React.createElement(Text, { bold: true, color: typeColor }, latestVersion),
179
+ React.createElement(Text, null, " "),
180
+ React.createElement(Text, { backgroundColor: typeColor, color: "#000000", bold: true },
181
+ " ",
182
+ updateType.toUpperCase(),
183
+ " "))),
184
+ updateType === "major" && (React.createElement(Box, { borderStyle: "round", borderColor: theme.borders.warning, paddingX: 1, marginBottom: 1, width: innerWidth },
185
+ React.createElement(Text, { color: theme.colors.warning, bold: true }, "\u26A0 Breaking changes may be included"))),
186
+ React.createElement(Box, { marginBottom: 1 }, renderChangelog()),
187
+ React.createElement(Box, { justifyContent: "center", gap: 1 }, buttonLabels.map((label, index) => {
188
+ const isFocused = index === focusedButton;
189
+ return (React.createElement(Box, { key: label },
190
+ React.createElement(Text, { bold: isFocused, backgroundColor: isFocused
191
+ ? theme.components.options.focusedBg
192
+ : undefined, color: isFocused ? theme.colors.focused : theme.colors.text },
193
+ isFocused ? " ▸ " : " ",
194
+ label,
195
+ isFocused ? " " : " ")));
196
+ })),
197
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
198
+ React.createElement(Text, { dimColor: true }, "\u2190\u2192/Tab navigate \u00B7 Enter select \u00B7 Esc dismiss")))));
199
+ };
@@ -11,6 +11,8 @@ export const KEYS = {
11
11
  QUICK_SUBMIT: "r", // used with key.ctrl
12
12
  // Theme
13
13
  THEME_CYCLE: "t", // used with key.ctrl
14
+ // Update overlay
15
+ UPDATE: "u",
14
16
  // Confirmation shortcuts
15
17
  CONFIRM_YES: /^[yY]$/,
16
18
  CONFIRM_NO: /^[nN]$/,
@@ -37,4 +39,5 @@ export const KEY_LABELS = {
37
39
  REJECT: "Esc",
38
40
  BACK: "n",
39
41
  SUBMIT: "Enter",
42
+ UPDATE: "U",
40
43
  };
@@ -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
+ });