Skip to content

Commit 6a31f26

Browse files
author
root
committed
fix(update): tighten package manager updater notices
1 parent 7b1577a commit 6a31f26

File tree

3 files changed

+325
-114
lines changed

3 files changed

+325
-114
lines changed

src/components/AutoUpdater.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export function AutoUpdater({
4949
if (isUpdatingRef.current) {
5050
return;
5151
}
52-
if ("production" === 'test' || "production" === 'development') {
52+
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') {
5353
logForDebugging('AutoUpdater: Skipping update check in test/dev environment');
5454
return;
5555
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { PassThrough } from 'node:stream'
2+
3+
import { afterEach, expect, mock, test } from 'bun:test'
4+
import React from 'react'
5+
import stripAnsi from 'strip-ansi'
6+
7+
import { createRoot } from '../ink.js'
8+
import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js'
9+
10+
const SYNC_START = '\x1B[?2026h'
11+
const SYNC_END = '\x1B[?2026l'
12+
13+
function extractLastFrame(output: string): string {
14+
let lastFrame: string | null = null
15+
let cursor = 0
16+
17+
while (cursor < output.length) {
18+
const start = output.indexOf(SYNC_START, cursor)
19+
if (start === -1) break
20+
21+
const contentStart = start + SYNC_START.length
22+
const end = output.indexOf(SYNC_END, contentStart)
23+
if (end === -1) break
24+
25+
const frame = output.slice(contentStart, end)
26+
if (frame.trim().length > 0) {
27+
lastFrame = frame
28+
}
29+
cursor = end + SYNC_END.length
30+
}
31+
32+
return lastFrame ?? output
33+
}
34+
35+
function createTestStreams(): {
36+
stdout: PassThrough
37+
stdin: PassThrough & {
38+
isTTY: boolean
39+
setRawMode: (mode: boolean) => void
40+
ref: () => void
41+
unref: () => void
42+
}
43+
getOutput: () => string
44+
} {
45+
let output = ''
46+
const stdout = new PassThrough()
47+
const stdin = new PassThrough() as PassThrough & {
48+
isTTY: boolean
49+
setRawMode: (mode: boolean) => void
50+
ref: () => void
51+
unref: () => void
52+
}
53+
54+
stdin.isTTY = true
55+
stdin.setRawMode = () => {}
56+
stdin.ref = () => {}
57+
stdin.unref = () => {}
58+
;(stdout as unknown as { columns: number }).columns = 120
59+
stdout.on('data', chunk => {
60+
output += chunk.toString()
61+
})
62+
63+
return {
64+
stdout,
65+
stdin,
66+
getOutput: () => output,
67+
}
68+
}
69+
70+
afterEach(() => {
71+
mock.restore()
72+
delete process.env.NODE_ENV
73+
})
74+
75+
function mockUpdaterDeps(options: {
76+
latestVersion: string | null
77+
packageManager: 'homebrew' | 'winget' | 'apk' | 'unknown'
78+
disabled?: boolean
79+
}): void {
80+
mock.module('../utils/config.js', () => ({
81+
isAutoUpdaterDisabled: () => options.disabled ?? false,
82+
}))
83+
84+
mock.module('../utils/autoUpdater.js', () => ({
85+
getLatestVersionFromGcs: async () => options.latestVersion,
86+
getMaxVersion: async () => undefined,
87+
shouldSkipVersion: () => false,
88+
}))
89+
90+
mock.module('../utils/nativeInstaller/packageManagers.js', () => ({
91+
getPackageManager: async () => options.packageManager,
92+
}))
93+
94+
mock.module('../utils/settings/settings.js', () => ({
95+
getInitialSettings: () => ({}),
96+
}))
97+
98+
mock.module('../utils/debug.js', () => ({
99+
logForDebugging: () => {},
100+
}))
101+
}
102+
103+
test('shows copy-pasteable homebrew command when update is available', async () => {
104+
process.env.NODE_ENV = 'test-render'
105+
mockUpdaterDeps({ latestVersion: '0.1.9', packageManager: 'homebrew' })
106+
107+
const { stdout, stdin, getOutput } = createTestStreams()
108+
const results: unknown[] = []
109+
const { PackageManagerAutoUpdater: Component } = await import(
110+
'./PackageManagerAutoUpdater.js'
111+
)
112+
const root = await createRoot({
113+
stdout: stdout as unknown as NodeJS.WriteStream,
114+
stdin: stdin as unknown as NodeJS.ReadStream,
115+
patchConsole: false,
116+
})
117+
118+
root.render(
119+
<Component
120+
verbose={false}
121+
isUpdating={false}
122+
onChangeIsUpdating={() => {}}
123+
autoUpdaterResult={null}
124+
showSuccessMessage={false}
125+
onAutoUpdaterResult={result => {
126+
results.push(result)
127+
}}
128+
/>,
129+
)
130+
131+
await Bun.sleep(50)
132+
const output = stripAnsi(extractLastFrame(getOutput()))
133+
134+
root.unmount()
135+
stdin.end()
136+
stdout.end()
137+
await Bun.sleep(10)
138+
139+
expect(output).toContain('Update available')
140+
expect(output).toContain('brew upgrade --cask openclaude')
141+
expect(results).toHaveLength(1)
142+
expect(results[0]).toMatchObject({
143+
status: 'update_available',
144+
actionLabel: 'brew upgrade --cask openclaude',
145+
})
146+
})
147+
148+
test('reports up_to_date after previously having an update', async () => {
149+
process.env.NODE_ENV = 'test-render'
150+
mockUpdaterDeps({ latestVersion: '0.1.8', packageManager: 'winget' })
151+
152+
const results: unknown[] = []
153+
const { PackageManagerAutoUpdater: Component } = await import(
154+
'./PackageManagerAutoUpdater.js'
155+
)
156+
const { stdout, stdin } = createTestStreams()
157+
const root = await createRoot({
158+
stdout: stdout as unknown as NodeJS.WriteStream,
159+
stdin: stdin as unknown as NodeJS.ReadStream,
160+
patchConsole: false,
161+
})
162+
163+
root.render(
164+
<Component
165+
verbose={false}
166+
isUpdating={false}
167+
onChangeIsUpdating={() => {}}
168+
autoUpdaterResult={{
169+
status: 'update_available',
170+
version: '0.1.9',
171+
currentVersion: '0.1.8',
172+
}}
173+
showSuccessMessage={false}
174+
onAutoUpdaterResult={result => {
175+
results.push(result)
176+
}}
177+
/>,
178+
)
179+
180+
await Bun.sleep(50)
181+
182+
root.unmount()
183+
stdin.end()
184+
stdout.end()
185+
await Bun.sleep(10)
186+
187+
expect(results).toContainEqual(
188+
expect.objectContaining({
189+
status: 'up_to_date',
190+
version: '0.1.8',
191+
currentVersion: '0.1.8',
192+
}),
193+
)
194+
})

0 commit comments

Comments
 (0)