mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
refactor(cli): centralize system information collection
This commit is contained in:
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getSystemInfo,
|
||||
getExtendedSystemInfo,
|
||||
getNpmVersion,
|
||||
getSandboxEnv,
|
||||
getIdeClientName,
|
||||
} from './systemInfo.js';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import * as child_process from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as versionUtils from './version.js';
|
||||
import type { ExecSyncOptions } from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
release: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('systemInfo', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
const originalVersion = process.version;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
baseUrl: 'https://api.openai.com',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'test-auth',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
vi.mocked(os.release).mockReturnValue('22.0.0');
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: 'x64',
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: 'v20.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: originalArch,
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: originalVersion,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getNpmVersion', () => {
|
||||
it('should return npm version when available', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('10.0.0');
|
||||
});
|
||||
|
||||
it('should return unknown when npm command fails', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(() => {
|
||||
throw new Error('npm not found');
|
||||
});
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxEnv', () => {
|
||||
it('should return "no sandbox" when SANDBOX is not set', () => {
|
||||
delete process.env['SANDBOX'];
|
||||
expect(getSandboxEnv()).toBe('no sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)');
|
||||
});
|
||||
|
||||
it('should return sandbox name without prefix when stripPrefix is true', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox name with prefix when stripPrefix is false', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox');
|
||||
});
|
||||
|
||||
it('should handle qwen- prefix removal', () => {
|
||||
process.env['SANDBOX'] = 'qwen-custom-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('custom-sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdeClientName', () => {
|
||||
it('should return IDE client name when IDE mode is enabled', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('test-ide');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE mode is disabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE client detection fails', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockRejectedValue(
|
||||
new Error('IDE client error'),
|
||||
);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemInfo', () => {
|
||||
it('should collect all system information', async () => {
|
||||
// Ensure SANDBOX is not set for this test
|
||||
delete process.env['SANDBOX'];
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo).toEqual({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing config gracefully', async () => {
|
||||
mockContext.services.config = null;
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo.modelVersion).toBe('Unknown');
|
||||
expect(systemInfo.sessionId).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtendedSystemInfo', () => {
|
||||
it('should include memory usage and base URL', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const { AuthType } = await import('@qwen-code/qwen-code-core');
|
||||
// Update the mock context to use OpenAI auth
|
||||
mockContext.services.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_OPENAI;
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.memoryUsage).toBeDefined();
|
||||
expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/);
|
||||
expect(extendedInfo.baseUrl).toBe('https://api.openai.com');
|
||||
});
|
||||
|
||||
it('should use sandbox env without prefix for bug reports', async () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.sandboxEnv).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should not include base URL for non-OpenAI auth', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.baseUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
packages/cli/src/utils/systemInfo.ts
Normal file
173
packages/cli/src/utils/systemInfo.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import os from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { getCliVersion } from './version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { formatMemoryUsage } from '../ui/utils/formatters.js';
|
||||
import { GIT_COMMIT_INFO } from '../generated/git-commit.js';
|
||||
|
||||
/**
|
||||
* System information interface containing all system-related details
|
||||
* that can be collected for debugging and reporting purposes.
|
||||
*/
|
||||
export interface SystemInfo {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional system information for bug reports
|
||||
*/
|
||||
export interface ExtendedSystemInfo extends SystemInfo {
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the NPM version, handling cases where npm might not be available.
|
||||
* Returns 'unknown' if npm command fails or is not found.
|
||||
*/
|
||||
export async function getNpmVersion(): Promise<string> {
|
||||
try {
|
||||
return execSync('npm --version', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IDE client name if IDE mode is enabled.
|
||||
* Returns empty string if IDE mode is disabled or IDE client is not detected.
|
||||
*/
|
||||
export async function getIdeClientName(
|
||||
context: CommandContext,
|
||||
): Promise<string> {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sandbox environment information.
|
||||
* Handles different sandbox types including sandbox-exec and custom sandbox environments.
|
||||
* For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names.
|
||||
*
|
||||
* @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports)
|
||||
*/
|
||||
export function getSandboxEnv(stripPrefix = false): string {
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
|
||||
if (!sandbox || sandbox === 'sandbox-exec') {
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
const profile = process.env['SEATBELT_PROFILE'] || 'unknown';
|
||||
return `sandbox-exec (${profile})`;
|
||||
}
|
||||
return 'no sandbox';
|
||||
}
|
||||
|
||||
// For bug reports, remove qwen- prefix
|
||||
if (stripPrefix) {
|
||||
return sandbox.replace(/^qwen-(?:code-)?/, '');
|
||||
}
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects comprehensive system information for debugging and reporting.
|
||||
* This function gathers all system-related details including OS, versions,
|
||||
* sandbox environment, authentication, and session information.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to SystemInfo object with all collected information
|
||||
*/
|
||||
export async function getSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<SystemInfo> {
|
||||
const osPlatform = process.platform;
|
||||
const osArch = process.arch;
|
||||
const osRelease = os.release();
|
||||
const nodeVersion = process.version;
|
||||
const npmVersion = await getNpmVersion();
|
||||
const sandboxEnv = getSandboxEnv();
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||
|
||||
return {
|
||||
cliVersion,
|
||||
osPlatform,
|
||||
osArch,
|
||||
osRelease,
|
||||
nodeVersion,
|
||||
npmVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
ideClient,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects extended system information for bug reports.
|
||||
* Includes all standard system info plus memory usage and optional base URL.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to ExtendedSystemInfo object
|
||||
*/
|
||||
export async function getExtendedSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<ExtendedSystemInfo> {
|
||||
const baseInfo = await getSystemInfo(context);
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
|
||||
// For bug reports, use sandbox name without prefix
|
||||
const sandboxEnv = getSandboxEnv(true);
|
||||
|
||||
// Get base URL if using OpenAI auth
|
||||
const baseUrl =
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI
|
||||
? context.services.config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
|
||||
// Get git commit info
|
||||
const gitCommit =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...baseInfo,
|
||||
sandboxEnv,
|
||||
memoryUsage,
|
||||
baseUrl,
|
||||
gitCommit,
|
||||
};
|
||||
}
|
||||
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
|
||||
/**
|
||||
* Field configuration for system information display
|
||||
*/
|
||||
export interface SystemInfoField {
|
||||
label: string;
|
||||
key: keyof ExtendedSystemInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified field configuration for system information display.
|
||||
* This ensures consistent labeling between /about and /bug commands.
|
||||
*/
|
||||
export function getSystemInfoFields(
|
||||
info: ExtendedSystemInfo,
|
||||
): SystemInfoField[] {
|
||||
const allFields: SystemInfoField[] = [
|
||||
{
|
||||
label: 'CLI Version',
|
||||
key: 'cliVersion',
|
||||
},
|
||||
{
|
||||
label: 'Git Commit',
|
||||
key: 'gitCommit',
|
||||
},
|
||||
{
|
||||
label: 'Model',
|
||||
key: 'modelVersion',
|
||||
},
|
||||
{
|
||||
label: 'Sandbox',
|
||||
key: 'sandboxEnv',
|
||||
},
|
||||
{
|
||||
label: 'OS Platform',
|
||||
key: 'osPlatform',
|
||||
},
|
||||
{
|
||||
label: 'OS Arch',
|
||||
key: 'osArch',
|
||||
},
|
||||
{
|
||||
label: 'OS Release',
|
||||
key: 'osRelease',
|
||||
},
|
||||
{
|
||||
label: 'Node.js Version',
|
||||
key: 'nodeVersion',
|
||||
},
|
||||
{
|
||||
label: 'NPM Version',
|
||||
key: 'npmVersion',
|
||||
},
|
||||
{
|
||||
label: 'Session ID',
|
||||
key: 'sessionId',
|
||||
},
|
||||
{
|
||||
label: 'Auth Method',
|
||||
key: 'selectedAuthType',
|
||||
},
|
||||
{
|
||||
label: 'Base URL',
|
||||
key: 'baseUrl',
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage',
|
||||
key: 'memoryUsage',
|
||||
},
|
||||
{
|
||||
label: 'IDE Client',
|
||||
key: 'ideClient',
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out optional fields that are not present
|
||||
return allFields.filter((field) => {
|
||||
const value = info[field.key];
|
||||
// Optional fields: only show if they exist and are non-empty
|
||||
if (
|
||||
field.key === 'baseUrl' ||
|
||||
field.key === 'gitCommit' ||
|
||||
field.key === 'ideClient'
|
||||
) {
|
||||
return Boolean(value);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a field from system info
|
||||
*/
|
||||
export function getFieldValue(
|
||||
field: SystemInfoField,
|
||||
info: ExtendedSystemInfo,
|
||||
): string {
|
||||
const value = info[field.key];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Special formatting for selectedAuthType
|
||||
if (field.key === 'selectedAuthType') {
|
||||
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
Reference in New Issue
Block a user