auq-mcp-server 2.4.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.
- package/README.md +40 -0
- package/dist/bin/auq.js +40 -0
- package/dist/bin/tui-app.js +114 -1
- package/dist/package.json +1 -1
- package/dist/src/cli/commands/sessions.js +138 -2
- package/dist/src/cli/commands/update.js +124 -0
- package/dist/src/config/__tests__/updateCheck.test.js +34 -0
- package/dist/src/config/defaults.js +2 -0
- package/dist/src/config/types.js +2 -0
- package/dist/src/tui/components/Footer.js +4 -1
- package/dist/src/tui/components/Header.js +3 -1
- package/dist/src/tui/components/UpdateBadge.js +29 -0
- package/dist/src/tui/components/UpdateOverlay.js +199 -0
- package/dist/src/tui/constants/keybindings.js +3 -0
- package/dist/src/update/__tests__/cache.test.js +136 -0
- package/dist/src/update/__tests__/changelog.test.js +86 -0
- package/dist/src/update/__tests__/checker.test.js +148 -0
- package/dist/src/update/__tests__/index.test.js +37 -0
- package/dist/src/update/__tests__/installer.test.js +117 -0
- package/dist/src/update/__tests__/package-manager.test.js +73 -0
- package/dist/src/update/__tests__/version.test.js +74 -0
- package/dist/src/update/cache.js +74 -0
- package/dist/src/update/changelog.js +63 -0
- package/dist/src/update/checker.js +121 -0
- package/dist/src/update/index.js +15 -0
- package/dist/src/update/installer.js +51 -0
- package/dist/src/update/package-manager.js +49 -0
- package/dist/src/update/types.js +7 -0
- package/dist/src/update/version.js +114 -0
- package/package.json +1 -1
- package/dist/src/tui/components/Spinner.js +0 -19
- 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
|
+
});
|