mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(subscription): add development login, models and web search * feat(subscription): refine account flows and host integration Align desktop and VS Code subscription UI, add account logout and confirmed plan display, and map subscription thinking parameters. Remove simulation code and obsolete native authentication paths, consolidate design documentation, and cover cancellation and recovery boundaries. * fix(subscription): preserve request errors and remove unused compatibility * fix(subscription): refine command visibility and client logout flows * fix(subscription): simplify account actions and desktop sign-in feedback * docs(subscription): clarify sign-in and provider guidance * feat(subscription): support switching accounts across clients * fix(subscription): align account actions and open plans on demand * fix(subscription): restore model selection after logout Filter subscription-only commands consistently across terminal renderers, preserve model catalog error classification, and display the subscription authentication name consistently. * fix(subscription): unify status authentication labels across clients * fix(subscription): preserve provider connections across client logout Restore configured providers after subscription logout across CLI, Desktop, and VS Code. Keep unrelated connections and conversation history, synchronize model state silently, and return to an unconfigured chat when no provider remains. * fix(subscription): correct native client logout state * feat(subscription): enable sign-in without development mode * refactor(ui): 优化会员登录成功提示和界面交互体验 - 移除订阅欢迎提示相关状态和行为,简化 UI 状态管理 - 替换登录成功提示为四行摘要,包含邮箱、套餐、模型及常用命令提示 - 更新国际化文本,支持新的登录成功摘要内容 - 取消订阅模式下输入框下方的命令提示行,改为在登录成功摘要中展示 - 移除多余的测试用例,新增登录成功消息的单元测试校验 - 优化 AppHeader 组件,不再显示订阅启动重复提示 - 调整 Composer 组件逻辑,删除订阅提示相关副作用及渲染条件 - 修改历史消息显示组件以适应新的成功提示格式 * fix(subscription): enable Flash reasoning controls in shared UI * fix(subscription): prioritize authorized models and simplify login dialogs * fix(subscription): preserve gateway errors and request IDs * fix(subscription): synchronize account state across clients Preserve model selections after sign-out, synchronize account commands and model catalogs, and retain explicit browser sign-in flows across CLI, Web Shell, and VS Code. Cancel subscription background agents on logout and normalize expired refresh credentials without SDK error wrapping. * fix(subscription): reset default model on explicit sign-in Choose the default served model when explicitly signing in to Qwen Subscription, preferring Max over a previously saved Flash selection. Preserve normal startup behavior and other provider flows, and cover CLI and workspace activation with regression tests. * fix(vscode): refresh models after successful sign-in * fix(web-shell): hide account actions during automatic sign-in * fix(web-shell): align model details and auth keyboard navigation * docs(subscription): revert previous guide changes * fix(core): reuse subscription catalog for same-login model switches * docs(subscription): document Qwen membership sign-in * fix(subscription): unify quota notices and preserve provider switching Carry structured subscription quota details through chat and search errors for localized CLI, Desktop, and VS Code display. Keep other providers available when subscription discovery fails and refine sidebar account actions. * fix(web-shell): align Chinese subscription entry title with CLI * fix(subscription): open plans for saved non-member accounts * fix(web-shell): show subscription commands after desktop sign-in * fix(subscription): reauthorize explicit account sign-ins * fix(auth): add model switching hint to subscription success * docs(auth): list Qwen OAuth as the first option * docs(models): simplify subscription sign-in guidance * docs(models): remove legacy OAuth from supported auth types * docs(subscription): link membership references and remove obsolete allowance note * fix(subscription): preserve session until reauthorization succeeds * fix(subscription): use pre for browser authorization and plans * fix(subscription): use production endpoints * test(vscode): fix editor message test formatting * fix(subscription): preserve unauthenticated session startup * fix(cli): classify subscription auth during ACP preflight * fix(subscription): scope activation to the current project --------- Co-authored-by: luoyun <[email protected]>
273 lines
9.3 KiB
TypeScript
273 lines
9.3 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
import { beforeEach, expect, it, vi } from 'vitest';
|
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
|
import { logoutCommand, upgradeCommand } from './subscription-commands.js';
|
|
import { SubscriptionOAuthError } from '@qwen-code/qwen-code-core/qwen/subscription-oauth-client.js';
|
|
import { AuthType } from '@qwen-code/qwen-code-core/utils/auth-type.js';
|
|
import type { AvailableModel } from '@qwen-code/qwen-code-core/models/types.js';
|
|
import { setLanguageAsync } from '../../i18n/index.js';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
browser: vi.fn(),
|
|
signOut: vi.fn(),
|
|
isSignedIn: vi.fn(),
|
|
}));
|
|
vi.mock('@qwen-code/qwen-code-core/qwen/subscription-session.js', () => ({
|
|
QwenSubscriptionSession: class {
|
|
signOut = mocks.signOut;
|
|
isSignedIn = mocks.isSignedIn;
|
|
},
|
|
}));
|
|
vi.mock('@qwen-code/qwen-code-core/utils/secure-browser-launcher.js', () => ({
|
|
openBrowserSecurely: mocks.browser,
|
|
}));
|
|
beforeEach(() => {
|
|
mocks.isSignedIn.mockReset().mockReturnValue(true);
|
|
mocks.browser.mockReset().mockResolvedValue(undefined);
|
|
mocks.signOut.mockReset().mockResolvedValue(undefined);
|
|
});
|
|
|
|
it.each([logoutCommand, upgradeCommand])(
|
|
'rejects $name after logout while the subscription model remains selected',
|
|
async (command) => {
|
|
mocks.isSignedIn.mockReturnValue(false);
|
|
expect(await command.action!(memberContext(), '')).toMatchObject({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
});
|
|
expect(mocks.signOut).not.toHaveBeenCalled();
|
|
expect(mocks.browser).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
function memberContext() {
|
|
return createMockCommandContext({
|
|
services: {
|
|
config: {
|
|
getAuthType: () => 'qwen-subscription',
|
|
clearAuth: vi.fn(),
|
|
setSubscriptionPlan: vi.fn(),
|
|
getBackgroundTaskRegistry: () => ({ abortAll: vi.fn() }),
|
|
getAllConfiguredModels: vi.fn(() => []),
|
|
switchModel: vi.fn().mockResolvedValue(undefined),
|
|
getContentGeneratorConfig: vi.fn(() => undefined),
|
|
isBrowserLaunchSuppressed: () => false,
|
|
},
|
|
settings: {
|
|
isTrusted: true,
|
|
merged: {},
|
|
user: { settings: {} },
|
|
workspace: {
|
|
settings: {
|
|
security: { auth: { selectedType: AuthType.QWEN_SUBSCRIPTION } },
|
|
},
|
|
},
|
|
forScope: vi.fn(() => ({
|
|
settings: {
|
|
security: { auth: { selectedType: 'qwen-subscription' } },
|
|
},
|
|
})),
|
|
setValue: vi.fn(),
|
|
},
|
|
},
|
|
ui: { isIdleRef: { current: true } },
|
|
});
|
|
}
|
|
|
|
it('signs out without changing the selected provider or model', async () => {
|
|
const context = memberContext();
|
|
expect(await logoutCommand.action!(context, '')).toBeUndefined();
|
|
expect(mocks.signOut).toHaveBeenCalledOnce();
|
|
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
|
|
expect(context.services.config?.switchModel).not.toHaveBeenCalled();
|
|
expect(context.services.config?.setSubscriptionPlan).toHaveBeenCalledWith(
|
|
undefined,
|
|
);
|
|
expect(context.services.settings.setValue).not.toHaveBeenCalled();
|
|
expect(context.ui.cancelBtw).toHaveBeenCalledOnce();
|
|
expect(context.ui.addItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
const bailianModel: AvailableModel = {
|
|
id: 'qwen3.6-plus',
|
|
label: 'Bailian',
|
|
authType: AuthType.USE_OPENAI,
|
|
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
envKey: 'LOGOUT_TEST_BAILIAN_KEY',
|
|
};
|
|
|
|
it('keeps the subscription model even when another provider is configured', async () => {
|
|
const context = memberContext();
|
|
vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([
|
|
bailianModel,
|
|
]);
|
|
await logoutCommand.action!(context, '');
|
|
expect(context.services.config!.switchModel).not.toHaveBeenCalled();
|
|
expect(context.services.config!.clearAuth).not.toHaveBeenCalled();
|
|
expect(context.services.settings.setValue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects an enforced subscription provider without switching to another provider', async () => {
|
|
const context = memberContext();
|
|
context.services.settings.merged.security = {
|
|
auth: { enforcedType: AuthType.QWEN_SUBSCRIPTION },
|
|
};
|
|
vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([
|
|
bailianModel,
|
|
]);
|
|
expect(await logoutCommand.action!(context, '')).toBeUndefined();
|
|
expect(context.services.config!.switchModel).not.toHaveBeenCalled();
|
|
expect(context.ui.addItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('finishes local logout even if remote revocation fails', async () => {
|
|
const context = memberContext();
|
|
mocks.signOut.mockRejectedValue(
|
|
new SubscriptionOAuthError(
|
|
'network_error',
|
|
undefined,
|
|
'private-token',
|
|
undefined,
|
|
'revoke',
|
|
),
|
|
);
|
|
vi.mocked(context.services.config!.getAllConfiguredModels).mockReturnValue([
|
|
bailianModel,
|
|
]);
|
|
expect(await logoutCommand.action!(context, '')).toBeUndefined();
|
|
expect(context.services.config!.switchModel).not.toHaveBeenCalled();
|
|
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
|
|
expect(context.ui.addItem).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'warning',
|
|
text: 'You are signed out locally, but the remote session could not be revoked.',
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('does not claim logout or clear the runtime when deleting credentials fails', async () => {
|
|
const context = memberContext();
|
|
mocks.signOut.mockRejectedValue(
|
|
new SubscriptionOAuthError(
|
|
'access_denied',
|
|
undefined,
|
|
'private-token',
|
|
undefined,
|
|
'cleanup',
|
|
),
|
|
);
|
|
expect(await logoutCommand.action!(context, '')).toMatchObject({
|
|
type: 'message',
|
|
messageType: 'error',
|
|
});
|
|
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
|
|
expect(context.services.settings.setValue).not.toHaveBeenCalled();
|
|
expect(context.ui.addItem).not.toHaveBeenCalled();
|
|
expect(context.ui.cancelBtw).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects logout while a response is active', async () => {
|
|
const busy = memberContext();
|
|
busy.ui.isIdleRef.current = false;
|
|
expect(await logoutCommand.action!(busy, '')).toMatchObject({
|
|
messageType: 'warning',
|
|
});
|
|
expect(mocks.signOut).not.toHaveBeenCalled();
|
|
expect(busy.services.config?.clearAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens the existing Qwen Code plans page from /upgrade', async () => {
|
|
const context = memberContext();
|
|
const result = await upgradeCommand.action!(context, '');
|
|
expect(mocks.browser).toHaveBeenCalledWith(
|
|
'https://chat.qwen.ai/?memberPlan=qwen_code',
|
|
);
|
|
expect(result).toMatchObject({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content: expect.stringContaining(
|
|
'https://chat.qwen.ai/?memberPlan=qwen_code',
|
|
),
|
|
});
|
|
});
|
|
|
|
it('provides a usable plans URL when browser launch is suppressed or fails', async () => {
|
|
const context = createMockCommandContext({
|
|
services: {
|
|
config: {
|
|
getAuthType: () => AuthType.QWEN_SUBSCRIPTION,
|
|
isBrowserLaunchSuppressed: () => true,
|
|
},
|
|
},
|
|
});
|
|
expect(await upgradeCommand.action!(context, '')).toMatchObject({
|
|
messageType: 'info',
|
|
content: expect.stringContaining('/?memberPlan=qwen_code'),
|
|
});
|
|
expect(mocks.browser).not.toHaveBeenCalled();
|
|
mocks.browser.mockRejectedValue(new Error('private error'));
|
|
expect(await upgradeCommand.action!(memberContext(), '')).toMatchObject({
|
|
messageType: 'error',
|
|
content:
|
|
'Could not open your browser. Visit https://chat.qwen.ai/?memberPlan=qwen_code',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
undefined,
|
|
AuthType.USE_OPENAI,
|
|
AuthType.USE_OPENAI_RESPONSES,
|
|
AuthType.USE_ANTHROPIC,
|
|
AuthType.USE_GEMINI,
|
|
AuthType.USE_VERTEX_AI,
|
|
AuthType.QWEN_OAUTH,
|
|
])(
|
|
'only explains subscription commands for a %s connection without side effects',
|
|
async (authType) => {
|
|
const context = memberContext();
|
|
vi.spyOn(context.services.config!, 'getAuthType').mockReturnValue(authType);
|
|
|
|
expect(await logoutCommand.action!(context, '')).toEqual({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content:
|
|
'This connection does not require /logout. Use /auth to switch model providers.',
|
|
});
|
|
expect(await upgradeCommand.action!(context, '')).toEqual({
|
|
type: 'message',
|
|
messageType: 'info',
|
|
content:
|
|
'This command is only available with Qwen Subscription login. Use /auth to sign in to Qwen Subscription.',
|
|
});
|
|
expect(mocks.signOut).not.toHaveBeenCalled();
|
|
expect(mocks.browser).not.toHaveBeenCalled();
|
|
expect(context.services.config?.clearAuth).not.toHaveBeenCalled();
|
|
expect(context.services.settings.setValue).not.toHaveBeenCalled();
|
|
expect(context.ui.cancelBtw).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('uses the approved Chinese messages for a non-subscription connection', async () => {
|
|
await setLanguageAsync('zh');
|
|
try {
|
|
const context = createMockCommandContext({
|
|
services: { config: { getAuthType: () => AuthType.USE_OPENAI } },
|
|
});
|
|
expect(await logoutCommand.action!(context, '')).toMatchObject({
|
|
content:
|
|
'当前连接无需通过 /logout 退出。如需切换模型服务,请使用 /auth。',
|
|
});
|
|
expect(await upgradeCommand.action!(context, '')).toMatchObject({
|
|
content:
|
|
'此命令仅适用于 Qwen 订阅登录。如需使用 Qwen 订阅,请通过 /auth 登录。',
|
|
});
|
|
} finally {
|
|
await setLanguageAsync('en');
|
|
}
|
|
});
|