Skip to content

Commit 472ddb9

Browse files
committed
Add login smoke checks for Windows binary
1 parent d6c7970 commit 472ddb9

9 files changed

Lines changed: 209 additions & 65 deletions

File tree

.github/workflows/cli-release-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ jobs:
338338
339339
# Sync check — exits via commander before async tasks fire.
340340
"$BIN" --version
341+
"$BIN" --smoke-login-primitives
341342
342343
# Long-running check — gives async startup failures time to surface.
343344
# This is the step that would have caught the post-OpenTUI-upgrade

.github/workflows/freebuff-e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ jobs:
178178
# startup failures (e.g. the Parser.init rejection from a broken
179179
# tree-sitter wasm load).
180180
./cli/bin/freebuff.exe --version
181+
./cli/bin/freebuff.exe --smoke-login-primitives
181182
# Run for several seconds so unhandled rejections during module
182183
# init have time to fire — the freebuff 0.0.62 wasm regression
183184
# surfaced through the *late* renderer-cleanup handler, after the

cli/src/index.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,66 @@ async function main(): Promise<void> {
262262
}
263263
}
264264

265+
// CI gate: `<binary> --smoke-login-primitives` checks the local pieces that
266+
// must work before browser OAuth can complete. This is intentionally not a
267+
// full OAuth flow: CI should not depend on a real GitHub account/browser
268+
// round trip just to validate the compiled Windows executable.
269+
if (process.argv.includes('--smoke-login-primitives')) {
270+
try {
271+
const [{ withTimeout }, fingerprint, { getWindowsOpenUrlCommand }] =
272+
await Promise.all([
273+
import('@codebuff/common/util/promise'),
274+
import('./utils/fingerprint'),
275+
import('./utils/open-url'),
276+
])
277+
278+
let timeoutRejected = false
279+
try {
280+
await withTimeout(
281+
new Promise<never>(() => {}),
282+
50,
283+
'login smoke expected timeout',
284+
)
285+
} catch (err) {
286+
timeoutRejected =
287+
err instanceof Error &&
288+
err.message.includes('login smoke expected timeout')
289+
}
290+
if (!timeoutRejected) {
291+
throw new Error('withTimeout did not reject a hanging promise')
292+
}
293+
294+
const fingerprintId = await withTimeout(
295+
fingerprint.calculateFingerprint(),
296+
5_000,
297+
'calculateFingerprint exceeded login smoke timeout',
298+
)
299+
const fingerprintType = fingerprint.getFingerprintType(fingerprintId)
300+
if (fingerprintType === 'unknown') {
301+
throw new Error(`Unexpected fingerprint type for ${fingerprintId}`)
302+
}
303+
304+
if (process.platform === 'win32') {
305+
const opener = getWindowsOpenUrlCommand('https://example.com')
306+
if (
307+
opener.command !== 'rundll32.exe' ||
308+
opener.args[0] !== 'url.dll,FileProtocolHandler' ||
309+
opener.args[1] !== 'https://example.com'
310+
) {
311+
throw new Error(
312+
`Unexpected Windows URL opener: ${opener.command} ${opener.args.join(' ')}`,
313+
)
314+
}
315+
}
316+
317+
console.log(`login primitives smoke ok (${fingerprintType})`)
318+
process.exit(0)
319+
} catch (err) {
320+
console.error('login primitives smoke FAIL:', err)
321+
process.exit(1)
322+
}
323+
}
324+
265325
// Run OSC theme detection BEFORE anything else.
266326
// This MUST happen before OpenTUI starts because OSC responses come through stdin,
267327
// and OpenTUI also listens to stdin. Running detection here ensures stdin is clean.

cli/src/utils/__tests__/fingerprint.test.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { describe, test, expect } from 'bun:test'
22

3-
import {
4-
getFingerprintType,
5-
generateFingerprintIdSync,
6-
withTimeout,
7-
} from '../fingerprint'
3+
import { getFingerprintType, generateFingerprintIdSync } from '../fingerprint'
84

95
describe('fingerprint utilities', () => {
106
describe('getFingerprintType', () => {
@@ -146,19 +142,4 @@ describe('fingerprint utilities', () => {
146142
})
147143
})
148144

149-
describe('withTimeout', () => {
150-
test('resolves when the promise finishes before the timeout', async () => {
151-
await expect(
152-
withTimeout(Promise.resolve('ok'), 100, 'too slow'),
153-
).resolves.toBe('ok')
154-
})
155-
156-
test('rejects when the promise exceeds the timeout', async () => {
157-
const neverResolves = new Promise<string>(() => {})
158-
159-
await expect(withTimeout(neverResolves, 1, 'too slow')).rejects.toThrow(
160-
'too slow',
161-
)
162-
})
163-
})
164145
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { EventEmitter } from 'events'
2+
import type { ChildProcess, spawn } from 'child_process'
3+
4+
import { describe, expect, mock, test } from 'bun:test'
5+
6+
import {
7+
getWindowsOpenUrlCommand,
8+
openUrlWithWindowsHandler,
9+
} from '../open-url'
10+
11+
function createMockChildProcess(): ChildProcess {
12+
const child = new EventEmitter() as ChildProcess
13+
child.unref = mock(() => {}) as unknown as ChildProcess['unref']
14+
return child
15+
}
16+
17+
describe('Windows URL opener', () => {
18+
test('builds the rundll32 URL handler command', () => {
19+
expect(getWindowsOpenUrlCommand('https://example.com')).toEqual({
20+
command: 'rundll32.exe',
21+
args: ['url.dll,FileProtocolHandler', 'https://example.com'],
22+
})
23+
})
24+
25+
test('returns false when spawn emits an async error', async () => {
26+
const child = createMockChildProcess()
27+
const spawnUrlHandler = mock(() => child) as unknown as typeof spawn
28+
29+
const result = openUrlWithWindowsHandler(
30+
'https://example.com',
31+
spawnUrlHandler,
32+
)
33+
child.emit('error', new Error('ENOENT'))
34+
35+
await expect(result).resolves.toBe(false)
36+
expect(child.unref).not.toHaveBeenCalled()
37+
})
38+
39+
test('returns true and unrefs the process after spawn succeeds', async () => {
40+
const child = createMockChildProcess()
41+
const spawnUrlHandler = mock(() => child) as unknown as typeof spawn
42+
43+
const result = openUrlWithWindowsHandler(
44+
'https://example.com',
45+
spawnUrlHandler,
46+
)
47+
child.emit('spawn')
48+
49+
await expect(result).resolves.toBe(true)
50+
expect(child.unref).toHaveBeenCalledTimes(1)
51+
})
52+
})

cli/src/utils/fingerprint.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createHash, randomBytes } from 'node:crypto'
1111
import { cpus, networkInterfaces } from 'node:os'
1212

1313
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
14+
import { withTimeout } from '@codebuff/common/util/promise'
1415

1516
import { trackEvent } from './analytics'
1617
import { detectShell } from './detect-shell'
@@ -22,26 +23,6 @@ let systeminformationModule: typeof import('systeminformation') | null = null
2223

2324
const ENHANCED_FINGERPRINT_TIMEOUT_MS = 3000
2425

25-
export function withTimeout<T>(
26-
promise: Promise<T>,
27-
timeoutMs: number,
28-
timeoutMessage: string,
29-
): Promise<T> {
30-
let timeout: ReturnType<typeof setTimeout> | null = null
31-
32-
const timeoutPromise = new Promise<never>((_, reject) => {
33-
timeout = setTimeout(() => {
34-
reject(new Error(timeoutMessage))
35-
}, timeoutMs)
36-
})
37-
38-
return Promise.race([promise, timeoutPromise]).finally(() => {
39-
if (timeout) {
40-
clearTimeout(timeout)
41-
}
42-
})
43-
}
44-
4526
async function getMachineId(): Promise<string> {
4627
if (!machineIdModule) {
4728
machineIdModule = await import('node-machine-id')

cli/src/utils/open-url.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,59 @@
11
import os from 'os'
2-
import { spawn } from 'child_process'
2+
import { spawn, type ChildProcess } from 'child_process'
33

44
import open from 'open'
55

66
import { getCliEnv } from './env'
77
import { logger } from './logger'
88

9+
export function getWindowsOpenUrlCommand(url: string): {
10+
command: string
11+
args: string[]
12+
} {
13+
return {
14+
command: 'rundll32.exe',
15+
args: ['url.dll,FileProtocolHandler', url],
16+
}
17+
}
18+
19+
export async function openUrlWithWindowsHandler(
20+
url: string,
21+
spawnUrlHandler: typeof spawn = spawn,
22+
): Promise<boolean> {
23+
const { command, args } = getWindowsOpenUrlCommand(url)
24+
25+
return new Promise((resolve) => {
26+
let subprocess: ChildProcess
27+
try {
28+
subprocess = spawnUrlHandler(command, args, {
29+
detached: true,
30+
stdio: 'ignore',
31+
windowsHide: true,
32+
})
33+
} catch (err) {
34+
logger.error(err, 'Failed to spawn Windows URL handler')
35+
resolve(false)
36+
return
37+
}
38+
39+
let settled = false
40+
const finish = (success: boolean) => {
41+
if (settled) return
42+
settled = true
43+
resolve(success)
44+
}
45+
46+
subprocess.once('error', (err) => {
47+
logger.error(err, 'Failed to open browser with Windows URL handler')
48+
finish(false)
49+
})
50+
subprocess.once('spawn', () => {
51+
subprocess.unref()
52+
finish(true)
53+
})
54+
})
55+
}
56+
957
/**
1058
* Safely open a URL in the user's default browser.
1159
*
@@ -18,22 +66,7 @@ import { logger } from './logger'
1866
*/
1967
export async function safeOpen(url: string): Promise<boolean> {
2068
if (os.platform() === 'win32') {
21-
try {
22-
const subprocess = spawn(
23-
'rundll32.exe',
24-
['url.dll,FileProtocolHandler', url],
25-
{
26-
detached: true,
27-
stdio: 'ignore',
28-
windowsHide: true,
29-
},
30-
)
31-
subprocess.unref()
32-
return true
33-
} catch (err) {
34-
logger.error(err, 'Failed to open browser with Windows URL handler')
35-
return false
36-
}
69+
return openUrlWithWindowsHandler(url)
3770
}
3871

3972
if (os.platform() === 'linux') {

common/src/util/__tests__/promise.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'
22

3-
import { INITIAL_RETRY_DELAY, withRetry } from '../promise'
3+
import { INITIAL_RETRY_DELAY, withRetry, withTimeout } from '../promise'
44

55
describe('withRetry', () => {
66
describe('basic functionality', () => {
@@ -323,3 +323,40 @@ describe('withRetry', () => {
323323
})
324324
})
325325
})
326+
327+
describe('withTimeout', () => {
328+
it('should resolve when the promise finishes before the timeout', async () => {
329+
await expect(
330+
withTimeout(Promise.resolve('ok'), 100, 'too slow'),
331+
).resolves.toBe('ok')
332+
})
333+
334+
it('should reject when the promise exceeds the timeout', async () => {
335+
const neverResolves = new Promise<string>(() => {})
336+
337+
await expect(withTimeout(neverResolves, 1, 'too slow')).rejects.toThrow(
338+
'too slow',
339+
)
340+
})
341+
342+
it('should clear the timeout when the wrapped promise rejects first', async () => {
343+
let clearedTimeout: ReturnType<typeof setTimeout> | null = null
344+
const originalClearTimeout = globalThis.clearTimeout
345+
const clearTimeoutSpy = spyOn(globalThis, 'clearTimeout').mockImplementation(
346+
((timeout: ReturnType<typeof setTimeout>) => {
347+
clearedTimeout = timeout
348+
return originalClearTimeout(timeout)
349+
}) as typeof clearTimeout,
350+
)
351+
352+
try {
353+
await expect(
354+
withTimeout(Promise.reject(new Error('failed first')), 100, 'too slow'),
355+
).rejects.toThrow('failed first')
356+
357+
expect(clearedTimeout).not.toBeNull()
358+
} finally {
359+
clearTimeoutSpy.mockRestore()
360+
}
361+
})
362+
})

common/src/util/promise.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,17 @@ export async function withTimeout<T>(
5555
timeoutMs: number,
5656
timeoutMessage: string = `Operation timed out after ${timeoutMs}ms`,
5757
): Promise<T> {
58-
let timeoutId: NodeJS.Timeout
58+
let timeoutId: ReturnType<typeof setTimeout> | null = null
5959

6060
const timeoutPromise = new Promise<never>((_, reject) => {
6161
timeoutId = setTimeout(() => {
6262
reject(new Error(timeoutMessage))
6363
}, timeoutMs)
6464
})
6565

66-
return Promise.race([
67-
promise.then((result) => {
66+
return Promise.race([promise, timeoutPromise]).finally(() => {
67+
if (timeoutId) {
6868
clearTimeout(timeoutId)
69-
return result
70-
}),
71-
timeoutPromise,
72-
])
69+
}
70+
})
7371
}

0 commit comments

Comments
 (0)