feat(oauth): add Qwen OAuth integration

This commit is contained in:
mingholy.lmh
2025-08-08 09:48:31 +08:00
parent ffc2d27ca3
commit ea7dcf8347
37 changed files with 7795 additions and 169 deletions

View File

@@ -189,7 +189,7 @@ describe('AuthDialog', () => {
);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 1. OpenAI');
expect(lastFrame()).toContain('● 2. OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
@@ -217,8 +217,8 @@ describe('AuthDialog', () => {
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Default is OpenAI (only option available)
expect(lastFrame()).toContain('● 1. OpenAI');
// Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
@@ -249,8 +249,8 @@ describe('AuthDialog', () => {
);
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default OpenAI option
expect(lastFrame()).toContain('● 1. OpenAI');
// it will just show the default Qwen OAuth option
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
});

View File

@@ -45,7 +45,10 @@ export function AuthDialog({
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
const items = [{ label: 'OpenAI', value: AuthType.USE_OPENAI }];
const items = [
{ label: 'Qwen OAuth', value: AuthType.QWEN_OAUTH },
{ label: 'OpenAI', value: AuthType.USE_OPENAI },
];
const initialAuthIndex = Math.max(
0,
@@ -65,6 +68,10 @@ export function AuthDialog({
return item.value === AuthType.USE_GEMINI;
}
if (process.env.QWEN_OAUTH_TOKEN) {
return item.value === AuthType.QWEN_OAUTH;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
}),
);

View File

@@ -0,0 +1,428 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// React import not needed for test files
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
// Mock qrcode-terminal module
vi.mock('qrcode-terminal', () => ({
default: {
generate: vi.fn(),
},
}));
// Mock ink-spinner
vi.mock('ink-spinner', () => ({
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
}));
describe('QwenOAuthProgress', () => {
const mockOnTimeout = vi.fn();
const mockOnCancel = vi.fn();
const createMockDeviceAuth = (
overrides: Partial<DeviceAuthorizationInfo> = {},
): DeviceAuthorizationInfo => ({
verification_uri: 'https://example.com/device',
verification_uri_complete: 'https://example.com/device?user_code=ABC123',
user_code: 'ABC123',
expires_in: 300,
...overrides,
});
const mockDeviceAuth = createMockDeviceAuth();
const renderComponent = (
props: Partial<{ deviceAuth: DeviceAuthorizationInfo }> = {},
) =>
render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
{...props}
/>,
);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('Loading state (no deviceAuth)', () => {
it('should render loading state when deviceAuth is not provided', () => {
const { lastFrame } = renderComponent();
const output = lastFrame();
expect(output).toContain('MockSpinner(dots)');
expect(output).toContain('Waiting for Qwen OAuth authentication...');
expect(output).toContain('(Press ESC to cancel)');
});
it('should render loading state with gray border', () => {
const { lastFrame } = renderComponent();
const output = lastFrame();
// Should not contain auth flow elements
expect(output).not.toContain('Qwen OAuth Authentication');
expect(output).not.toContain('Please visit this URL to authorize:');
// Loading state still shows time remaining with default timeout
expect(output).toContain('Time remaining:');
});
});
describe('Authenticated state (with deviceAuth)', () => {
it('should render authentication flow when deviceAuth is provided', () => {
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
const output = lastFrame();
expect(output).toContain('Qwen OAuth Authentication');
expect(output).toContain('Please visit this URL to authorize:');
expect(output).toContain(mockDeviceAuth.verification_uri_complete);
expect(output).toContain('MockSpinner(dots)');
expect(output).toContain('Waiting for authorization');
expect(output).toContain('Time remaining: 5:00');
expect(output).toContain('(Press ESC to cancel)');
});
it('should display correct URL in bordered box', () => {
const customAuth = createMockDeviceAuth({
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
});
const { lastFrame } = renderComponent({ deviceAuth: customAuth });
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
});
it('should format time correctly', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 125, // 2 minutes and 5 seconds
};
const { lastFrame } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={deviceAuthWithCustomTime}
/>,
);
const output = lastFrame();
expect(output).toContain('Time remaining: 2:05');
});
it('should format single digit seconds with leading zero', () => {
const deviceAuthWithCustomTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 67, // 1 minute and 7 seconds
};
const { lastFrame } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={deviceAuthWithCustomTime}
/>,
);
const output = lastFrame();
expect(output).toContain('Time remaining: 1:07');
});
});
describe('Timer functionality', () => {
it('should countdown and call onTimeout when timer expires', async () => {
const deviceAuthWithShortTime: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 2, // 2 seconds
};
const { rerender } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={deviceAuthWithShortTime}
/>,
);
// Advance timer by 1 second
vi.advanceTimersByTime(1000);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={deviceAuthWithShortTime}
/>,
);
// Advance timer by another second to trigger timeout
vi.advanceTimersByTime(1000);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={deviceAuthWithShortTime}
/>,
);
expect(mockOnTimeout).toHaveBeenCalledTimes(1);
});
it('should update time remaining display', async () => {
const { lastFrame, rerender } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Initial time should be 5:00
expect(lastFrame()).toContain('Time remaining: 5:00');
// Advance by 1 second
vi.advanceTimersByTime(1000);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Should now show 4:59
expect(lastFrame()).toContain('Time remaining: 4:59');
});
it('should not start timer when deviceAuth is null', () => {
render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
// Advance timer and ensure onTimeout is not called
vi.advanceTimersByTime(5000);
expect(mockOnTimeout).not.toHaveBeenCalled();
});
});
describe('Animated dots', () => {
it('should cycle through animated dots', async () => {
const { lastFrame, rerender } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Initial state should have no dots
expect(lastFrame()).toContain('Waiting for authorization');
// Advance by 500ms to add first dot
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization.');
// Advance by another 500ms to add second dot
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization..');
// Advance by another 500ms to add third dot
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization...');
// Advance by another 500ms to reset dots
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization');
});
});
describe('QR Code functionality', () => {
it('should generate QR code when deviceAuth is provided', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
mockGenerate.mockImplementation((url, options, callback) => {
callback!('Mock QR Code Data');
});
render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(mockGenerate).toHaveBeenCalledWith(
mockDeviceAuth.verification_uri_complete,
{ small: true },
expect.any(Function),
);
});
// Note: QR code display test skipped due to timing complexities with async state updates
// The QR code generation is already tested in 'should generate QR code when deviceAuth is provided'
it('should handle QR code generation errors gracefully', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGenerate.mockImplementation(() => {
throw new Error('QR Code generation failed');
});
const { lastFrame } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Should not crash and should not show QR code section
const output = lastFrame();
expect(output).not.toContain('Or scan the QR code below:');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to generate QR code:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it('should not generate QR code when deviceAuth is null', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
expect(mockGenerate).not.toHaveBeenCalled();
});
});
describe('User interactions', () => {
it('should call onCancel when ESC key is pressed', () => {
const { stdin } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Simulate ESC key press
stdin.write('\u001b'); // ESC character
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('should call onCancel when ESC is pressed in loading state', () => {
const { stdin } = render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
// Simulate ESC key press
stdin.write('\u001b'); // ESC character
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('should not call onCancel for other key presses', () => {
const { stdin } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Simulate other key presses
stdin.write('a');
stdin.write('\r'); // Enter
stdin.write(' '); // Space
expect(mockOnCancel).not.toHaveBeenCalled();
});
});
describe('Props changes', () => {
it('should display initial timer value from deviceAuth', () => {
const deviceAuthWith10Min: DeviceAuthorizationInfo = {
...mockDeviceAuth,
expires_in: 600, // 10 minutes
};
const { lastFrame } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={deviceAuthWith10Min}
/>,
);
expect(lastFrame()).toContain('Time remaining: 10:00');
});
it('should reset to loading state when deviceAuth becomes null', () => {
const { rerender, lastFrame } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Qwen OAuth Authentication');
rerender(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
expect(lastFrame()).toContain('Waiting for Qwen OAuth authentication...');
expect(lastFrame()).not.toContain('Qwen OAuth Authentication');
});
});
});

View File

@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js';
import { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
interface QwenOAuthProgressProps {
onTimeout: () => void;
onCancel: () => void;
deviceAuth?: DeviceAuthorizationInfo;
authStatus?:
| 'idle'
| 'polling'
| 'success'
| 'error'
| 'timeout'
| 'rate_limit';
authMessage?: string | null;
}
export function QwenOAuthProgress({
onTimeout,
onCancel,
deviceAuth,
authStatus,
authMessage,
}: QwenOAuthProgressProps): React.JSX.Element {
const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes
const [timeRemaining, setTimeRemaining] = useState<number>(defaultTimeout);
const [dots, setDots] = useState<string>('');
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
useInput((input, key) => {
if (authStatus === 'timeout') {
// Any key press in timeout state should trigger cancel to return to auth dialog
onCancel();
} else if (key.escape) {
onCancel();
}
});
// Generate QR code when device auth is available
useEffect(() => {
if (!deviceAuth) {
setQrCodeData(null);
return;
}
// Generate QR code string
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
generateQR();
}, [deviceAuth]);
// Countdown timer
useEffect(() => {
const timer = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1) {
onTimeout();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [onTimeout]);
// Animated dots
useEffect(() => {
const dotsTimer = setInterval(() => {
setDots((prev) => {
if (prev.length >= 3) return '';
return prev + '.';
});
}, 500);
return () => clearInterval(dotsTimer);
}, []);
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// Handle timeout state
if (authStatus === 'timeout') {
return (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentRed}>
Qwen OAuth Authentication Timeout
</Text>
<Box marginTop={1}>
<Text>
{authMessage ||
`OAuth token expired (over ${defaultTimeout} seconds). Please select authentication method again.`}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press any key to return to authentication type selection.
</Text>
</Box>
</Box>
);
}
if (!deviceAuth) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Box>
<Text>
<Spinner type="dots" /> Waiting for Qwen OAuth authentication...
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
);
}
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={deviceAuth.verification_uri_complete} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{deviceAuth.verification_uri_complete}
</Text>
</Link>
{qrCodeData && (
<>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{qrCodeData}</Text>
</Box>
</>
)}
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
);
}