Files
liuxuely0626andluoyun 0cc37bf444 feat: add Qwen subscription support across CLI, VS Code, and Desktop (#16378)
* 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]>
2026-11-05 20:44:21 +08:00

494 lines
15 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LanguageDefinition } from '../../packages/cli/src/i18n/languages.js';
import {
checkI18n,
findForbiddenZhTwPatterns,
printCheckI18nResult,
shouldWriteUnusedKeysJson,
type CheckI18nOptions,
} from '../check-i18n.js';
vi.unmock('fs');
vi.unmock('node:fs');
type TestLanguage = Pick<LanguageDefinition, 'code' | 'id' | 'strictParity'>;
type TestLanguageInput =
| string
| (Pick<LanguageDefinition, 'code'> &
Partial<Pick<LanguageDefinition, 'id' | 'strictParity'>>);
type LocaleEntries = Record<string, string>;
const tempDirs: string[] = [];
function makeFixture(): {
root: string;
localesDir: string;
sourceDir: string;
} {
const root = mkdtempSync(path.join(tmpdir(), 'qwen-check-i18n-'));
tempDirs.push(root);
const localesDir = path.join(root, 'locales');
const sourceDir = path.join(root, 'src');
mkdirSync(localesDir, { recursive: true });
mkdirSync(sourceDir, { recursive: true });
return { root, localesDir, sourceDir };
}
function writeLocale(
localesDir: string,
code: string,
entries: LocaleEntries,
): void {
const lines = Object.entries(entries).map(
([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)},`,
);
writeFileSync(
path.join(localesDir, `${code}.js`),
`export default {\n${lines.join('\n')}\n};\n`,
);
}
function writeSource(sourceDir: string, content: string): void {
writeFileSync(path.join(sourceDir, 'fixture.ts'), content);
}
function languages(
...definitions: TestLanguageInput[]
): NonNullable<CheckI18nOptions['supportedLanguages']> {
return definitions.map((definition) => {
const language =
typeof definition === 'string' ? { code: definition } : definition;
const strictParity =
language.strictParity ?? ['zh', 'zh-TW'].includes(language.code);
return {
id: `${language.code}-test`,
...language,
strictParity,
} satisfies TestLanguage;
});
}
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe('checkI18n', () => {
it('records locale load failures and continues checking other locales', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', { Used: 'Used' });
writeFileSync(path.join(localesDir, 'broken.js'), 'export default null;\n');
writeLocale(localesDir, 'fr', { Used: 'Utilise' });
writeSource(sourceDir, "t('Used');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'broken', 'fr'),
mustTranslateKeys: [],
});
expect(result.success).toBe(false);
expect(result.errors).toContainEqual(
expect.stringContaining(
'Failed to load broken.js: Invalid locale module',
),
);
expect(result.stats.locales.map((locale) => locale.code)).toEqual(['fr']);
});
it('returns a failed empty result when en.js is missing', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'fr', { Used: 'Utilise' });
writeSource(sourceDir, "t('Used');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: [],
});
expect(result.success).toBe(false);
expect(result.stats.totalKeys).toBe(0);
expect(result.stats.locales).toEqual([]);
expect(result.errors).toContainEqual(
expect.stringContaining('Failed to load en.js:'),
);
});
it('enforces strict key parity for zh and zh-TW', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
Used: 'Used',
MissingInStrict: 'MissingInStrict',
});
writeLocale(localesDir, 'zh', {
Used: '已使用',
ExtraStrict: '额外',
});
writeLocale(localesDir, 'zh-TW', {
Used: '已使用',
ExtraStrict: '額外',
});
writeSource(sourceDir, "t('Used');\nt('MissingInStrict');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'zh', 'zh-TW'),
mustTranslateKeys: [],
});
expect(result.errors).toContain(
'Missing translation in zh.js: "MissingInStrict"',
);
expect(result.errors).toContain(
'Extra key in zh.js (not in en.js): "ExtraStrict"',
);
expect(result.errors).toContain(
'Missing translation in zh-TW.js: "MissingInStrict"',
);
expect(result.errors).toContain(
'Extra key in zh-TW.js (not in en.js): "ExtraStrict"',
);
});
it('uses language metadata to decide strict key parity', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
Used: 'Used',
MissingInStrict: 'MissingInStrict',
});
writeLocale(localesDir, 'fr', {
Used: 'Utilise',
ExtraStrict: 'Supplémentaire',
});
writeSource(sourceDir, "t('Used');\nt('MissingInStrict');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', {
code: 'fr',
strictParity: true,
}),
mustTranslateKeys: [],
});
expect(result.errors).toContain(
'Missing translation in fr.js: "MissingInStrict"',
);
expect(result.errors).toContain(
'Extra key in fr.js (not in en.js): "ExtraStrict"',
);
});
it('warns for non-strict optional missing keys and errors for required keys', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
Optional: 'Optional',
Required: 'Required',
});
writeLocale(localesDir, 'fr', { ExtraLoose: 'Supplémentaire' });
writeSource(sourceDir, "t('Optional');\nt('Required');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: ['Required'],
});
expect(result.errors).toContain(
'Missing required translation in fr.js: "Required"',
);
expect(result.warnings).toContain(
'fr.js is missing 1 non-required translation keys',
);
});
it('warns instead of errors for extra keys in non-strict locales', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', { Used: 'Used' });
writeLocale(localesDir, 'fr', {
Used: 'Utilise',
ExtraLoose: 'Supplémentaire',
});
writeSource(sourceDir, "t('Used');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: [],
});
expect(result.errors).not.toContain(
'Extra key in fr.js (not in en.js): "ExtraLoose"',
);
expect(result.warnings).toContain('fr.js has 1 keys not present in en.js');
});
it('errors when a required translation still falls back to English', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', { Required: 'Required' });
writeLocale(localesDir, 'fr', { Required: 'Required' });
writeSource(sourceDir, "t('Required');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: ['Required'],
});
expect(result.errors).toContain(
'Required translation still falls back to English in fr.js: "Required"',
);
});
it('allows the AUTO mode entry notice semantic key in en.js while preserving mismatch errors', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
'auto_mode.entry_notice': 'Auto mode enabled.',
OrdinaryMismatch: 'Different English copy',
});
writeLocale(localesDir, 'fr', {
'auto_mode.entry_notice': 'Mode auto activé.',
OrdinaryMismatch: 'Texte français',
});
writeSource(
sourceDir,
"t('auto_mode.entry_notice');\nt('OrdinaryMismatch');\n",
);
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: ['auto_mode.entry_notice'],
});
expect(result.errors).not.toContain(
'Key-value mismatch in en.js: "auto_mode.entry_notice" !== "Auto mode enabled."',
);
expect(result.errors).toContain(
'Key-value mismatch in en.js: "OrdinaryMismatch" !== "Different English copy"',
);
});
it('writes unused locale-only keys only when requested', async () => {
const { root, localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
Used: 'Used',
LocaleOnly: 'LocaleOnly',
});
writeLocale(localesDir, 'fr', {
Used: 'Utilise',
LocaleOnly: 'Locale seulement',
});
writeSource(sourceDir, "t('Used');\n");
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: [],
});
expect(result.stats.unusedKeysOnlyInLocales).toEqual(['LocaleOnly']);
const outputPath = path.join(root, 'unused-keys-only-in-locales.json');
printCheckI18nResult(result, {
writeUnusedKeysJson: false,
unusedKeysOutputPath: outputPath,
});
expect(() => readFileSync(outputPath, 'utf-8')).toThrow();
printCheckI18nResult(result, {
writeUnusedKeysJson: true,
unusedKeysOutputPath: outputPath,
});
expect(JSON.parse(readFileSync(outputPath, 'utf-8'))).toEqual({
keys: ['LocaleOnly'],
count: 1,
});
});
it('extracts escaped string-literal translation keys from source files', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
"Quoted ' key": "Quoted ' key",
'Tabbed\tkey': 'Tabbed\tkey',
'Line\nbreak': 'Line\nbreak',
});
writeLocale(localesDir, 'fr', {
"Quoted ' key": 'Clé avec apostrophe',
'Tabbed\tkey': 'Clé avec tabulation',
'Line\nbreak': 'Saut de ligne',
});
writeSource(
sourceDir,
[
"t('Quoted \\' key');",
't("Tabbed\\tkey");',
"ta('Line\\nbreak');",
].join('\n'),
);
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'fr'),
mustTranslateKeys: [],
});
expect(result.stats.unusedKeys).toEqual([]);
});
it('includes shared Core translator keys in the default scan', async () => {
const { localesDir } = makeFixture();
const key =
"You've used up your weekly credits.{{reset}} Please try again after the reset.";
writeLocale(localesDir, 'en', {
[key]: key,
'Unused quota fixture': 'Unused quota fixture',
});
const result = await checkI18n({
localesDir,
supportedLanguages: languages('en'),
mustTranslateKeys: [],
});
expect(result.stats.unusedKeys).toEqual(['Unused quota fixture']);
});
it('flags Mainland-Chinese vocabulary and variant Traditional chars in zh-TW values', async () => {
const { localesDir, sourceDir } = makeFixture();
writeLocale(localesDir, 'en', {
Open: 'Open',
Server: 'Server',
Menu: 'Menu',
Disable: 'Disable',
Config: 'Config',
});
writeLocale(localesDir, 'zh', {
Open: '打开',
Server: '服务器',
Menu: '菜单',
Disable: '禁用',
Config: '配置',
});
writeLocale(localesDir, 'zh-TW', {
// Regressions we expect the check to catch
Open: '啓動', // variant Traditional 啓 (OpenCC s2t artifact)
Server: '服務器', // Mainland vocabulary
Menu: '菜單', // Mainland vocabulary
// Taiwan-standard vocabulary — must NOT be flagged
Disable: '禁用',
Config: '配置',
});
writeSource(
sourceDir,
"t('Open');\nt('Server');\nt('Menu');\nt('Disable');\nt('Config');\n",
);
const result = await checkI18n({
localesDir,
sourceDir,
supportedLanguages: languages('en', 'zh', 'zh-TW'),
mustTranslateKeys: [],
});
expect(result.errors).toContain(
'Non-Taiwan vocabulary in zh-TW.js at "Open": "啓" should be "啟"',
);
expect(result.errors).toContain(
'Non-Taiwan vocabulary in zh-TW.js at "Server": "服務器" should be "伺服器"',
);
expect(result.errors).toContain(
'Non-Taiwan vocabulary in zh-TW.js at "Menu": "菜單" should be "選單"',
);
expect(result.errors).not.toContainEqual(
expect.stringContaining('at "Disable"'),
);
expect(result.errors).not.toContainEqual(
expect.stringContaining('at "Config"'),
);
});
it('returns no findings for clean Taiwan Traditional translations', () => {
const findings = findForbiddenZhTwPatterns({
Open: '開啟',
Server: '伺服器',
Menu: '選單',
Disable: '禁用',
Config: '配置',
Link: '連結',
History: '歷史',
});
expect(findings).toEqual([]);
});
it('reports only the most specific pattern per value (no duplicate findings)', () => {
// `历史` (Simplified) overlaps with the single-char pattern `历`.
// We expect exactly one finding for the longer/more specific pattern.
const findings = findForbiddenZhTwPatterns({
History: '历史',
});
expect(findings).toEqual([
{ key: 'History', pattern: '历史', preferred: '歷史' },
]);
});
it('detects the unused-keys JSON flag from argv or env', () => {
const originalArgv = process.argv;
const originalEnv = process.env['QWEN_CHECK_I18N_WRITE_UNUSED_KEYS'];
try {
process.argv = ['node', 'check-i18n.ts'];
delete process.env['QWEN_CHECK_I18N_WRITE_UNUSED_KEYS'];
expect(shouldWriteUnusedKeysJson()).toBe(false);
process.argv = ['node', 'check-i18n.ts', '--write-unused-locale-keys'];
expect(shouldWriteUnusedKeysJson()).toBe(true);
process.argv = ['node', 'check-i18n.ts'];
process.env['QWEN_CHECK_I18N_WRITE_UNUSED_KEYS'] = '1';
expect(shouldWriteUnusedKeysJson()).toBe(true);
} finally {
process.argv = originalArgv;
if (originalEnv === undefined) {
delete process.env['QWEN_CHECK_I18N_WRITE_UNUSED_KEYS'];
} else {
process.env['QWEN_CHECK_I18N_WRITE_UNUSED_KEYS'] = originalEnv;
}
}
});
});