diff --git a/src/extension.ts b/src/extension.ts index 86b1324a..88ba3c59 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -75,6 +75,7 @@ import { } from './features/interpreterSelection'; import { PythonProjectManagerImpl } from './features/projectManager'; import { getPythonApi, setPythonApi } from './features/pythonApi'; +import { reportIssue } from './features/reportIssue'; import { registerCompletionProvider } from './features/settings/settingCompletions'; import { migrateGlobalDefaultEnvManagerSetting } from './features/settings/settingHelpers'; import { setActivateMenuButtonContext } from './features/terminal/activateMenuButton'; @@ -94,7 +95,6 @@ import { updateViewsAndStatus } from './features/views/revealHandler'; import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; import { - collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, isInlineScriptsFeatureEnabled, runPetInTerminalImpl, @@ -503,47 +503,7 @@ export async function activate(context: ExtensionContext): Promise { - try { - // Prompt for issue title - const rawTitle = await window.showInputBox({ - title: l10n.t('Report Issue - Title'), - prompt: l10n.t('Enter a brief title for the issue'), - placeHolder: l10n.t('e.g., Environment not detected, activation fails, etc.'), - ignoreFocusOut: true, - }); - const title = rawTitle?.trim(); - - if (!title) { - // User cancelled or provided empty title - return; - } - - // Prompt for issue description - const rawDescription = await window.showInputBox({ - title: l10n.t('Report Issue - Description'), - prompt: l10n.t('Describe the issue in more detail'), - placeHolder: l10n.t('Provide additional context about what happened...'), - ignoreFocusOut: true, - }); - const description = rawDescription?.trim(); - - if (!description) { - // User cancelled or provided empty description - return; - } - - const issueData = await collectEnvironmentInfo(context, envManagers, projectManager); - - await commands.executeCommand('workbench.action.openIssueReporter', { - extensionId: 'ms-python.vscode-python-envs', - issueTitle: `[Python Environments] ${title}`, - issueBody: `## Description\n${description}\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n## Actual Behavior\n\n\n\n\n
\nEnvironment Information\n\n\`\`\`\n${issueData}\n\`\`\`\n\n
`, - }); - } catch (error) { - window.showErrorMessage(`Failed to open issue reporter: ${error}`); - } - }), + commands.registerCommand('python-envs.reportIssue', () => reportIssue(context, envManagers, projectManager)), commands.registerCommand('python-envs.runPetInTerminal', async () => { try { await runPetInTerminalImpl(); diff --git a/src/features/reportIssue.ts b/src/features/reportIssue.ts new file mode 100644 index 00000000..926ea841 --- /dev/null +++ b/src/features/reportIssue.ts @@ -0,0 +1,75 @@ +import { ExtensionContext, l10n } from 'vscode'; +import * as commandApi from '../common/command.api'; +import { traceError } from '../common/logging'; +import * as windowApis from '../common/window.apis'; +import { collectEnvironmentInfo } from '../helpers'; +import { EnvironmentManagers, PythonProjectManager } from '../internal.api'; + +const MINIMUM_DESCRIPTION_LENGTH = 3; + +/** + * Collects issue details and opens a prefilled issue reporter after the user confirms diagnostic collection. + * + * @param context The extension context used to collect extension information. + * @param envManagers The registered Python environment managers. + * @param projectManager The Python project manager. + */ +export async function reportIssue( + context: ExtensionContext, + envManagers: EnvironmentManagers, + projectManager: PythonProjectManager, +): Promise { + try { + const rawTitle = await windowApis.showInputBox({ + title: l10n.t('Report Issue - Title'), + prompt: l10n.t('Enter a brief title for the issue'), + placeHolder: l10n.t('e.g., Environment not detected, activation fails, etc.'), + ignoreFocusOut: true, + }); + const title = rawTitle?.trim(); + + if (!title) { + return; + } + + const rawDescription = await windowApis.showInputBox({ + title: l10n.t('Report Issue - Description'), + prompt: l10n.t('Describe the issue in more detail'), + placeHolder: l10n.t('Provide additional context about what happened...'), + ignoreFocusOut: true, + validateInput: (value) => + value.trim().length < MINIMUM_DESCRIPTION_LENGTH + ? l10n.t('Enter at least {0} characters.', MINIMUM_DESCRIPTION_LENGTH) + : undefined, + }); + const description = rawDescription?.trim(); + + if (!description || description.length < MINIMUM_DESCRIPTION_LENGTH) { + return; + } + + const continueAction = l10n.t('Continue to Issue Reporter'); + const confirmation = await windowApis.showInformationMessage( + l10n.t( + 'To help the Python Environments team investigate, VS Code will collect details about your Python environments and projects and open a prefilled GitHub issue. You can review and edit it before submitting.', + ), + { modal: true }, + continueAction, + ); + + if (confirmation !== continueAction) { + return; + } + + const issueData = await collectEnvironmentInfo(context, envManagers, projectManager); + + await commandApi.executeCommand('workbench.action.openIssueReporter', { + extensionId: 'ms-python.vscode-python-envs', + issueTitle: `[Python Environments] ${title}`, + issueBody: `## Description\n${description}\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n## Actual Behavior\n\n\n\n\n
\nEnvironment Information\n\n\`\`\`\n${issueData}\n\`\`\`\n\n
`, + }); + } catch (error) { + traceError('Failed to open issue reporter', error); + await windowApis.showErrorMessage(l10n.t('Failed to open the issue reporter. Please try again.')); + } +} diff --git a/src/test/features/reportIssue.unit.test.ts b/src/test/features/reportIssue.unit.test.ts index 8f80c709..ae63447e 100644 --- a/src/test/features/reportIssue.unit.test.ts +++ b/src/test/features/reportIssue.unit.test.ts @@ -1,112 +1,120 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; -import * as typeMoq from 'typemoq'; -import * as vscode from 'vscode'; -import { PythonEnvironment, PythonEnvironmentId } from '../../api'; +import * as sinon from 'sinon'; +import { ExtensionContext, InputBoxOptions, l10n } from 'vscode'; +import * as commandApi from '../../common/command.api'; +import * as windowApis from '../../common/window.apis'; +import { reportIssue } from '../../features/reportIssue'; +import * as helpers from '../../helpers'; import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; -import { PythonProject } from '../../api'; - -// We need to mock the extension's activate function to test the collectEnvironmentInfo function -// Since it's a local function, we'll test the command registration instead suite('Report Issue Command Tests', () => { - let mockEnvManagers: typeMoq.IMock; - let mockProjectManager: typeMoq.IMock; + const context = {} as ExtensionContext; + const envManagers = {} as EnvironmentManagers; + const projectManager = {} as PythonProjectManager; - setup(() => { - mockEnvManagers = typeMoq.Mock.ofType(); - mockProjectManager = typeMoq.Mock.ofType(); + teardown(() => { + sinon.restore(); }); - test('should handle environment collection with empty data', () => { - mockEnvManagers.setup((em) => em.managers).returns(() => []); - mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => []); - - // Test that empty collections are handled gracefully - const managers = mockEnvManagers.object.managers; - const projects = mockProjectManager.object.getProjects(); - - assert.strictEqual(managers.length, 0); - assert.strictEqual(projects.length, 0); + test('stops when the title input is cancelled', async () => { + sinon.stub(windowApis, 'showInputBox').resolves(undefined); + const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo'); + const executeCommand = sinon.stub(commandApi, 'executeCommand'); + + await reportIssue(context, envManagers, projectManager); + + sinon.assert.notCalled(collectEnvironmentInfo); + sinon.assert.notCalled(executeCommand); }); - test('should handle environment collection with mock data', async () => { - // Create mock environment - const mockEnvId: PythonEnvironmentId = { - id: 'test-env-id', - managerId: 'test-manager' - }; - - const mockEnv: PythonEnvironment = { - envId: mockEnvId, - name: 'Test Environment', - displayName: 'Test Environment 3.9', - displayPath: '/path/to/python', - version: '3.9.0', - environmentPath: vscode.Uri.file('/path/to/env'), - execInfo: { - run: { - executable: '/path/to/python', - args: [] - } - }, - sysPrefix: '/path/to/env' - }; - - const mockManager = { - id: 'test-manager', - displayName: 'Test Manager', - getEnvironments: async () => [mockEnv] - } as any; - - // Create mock project - const mockProject: PythonProject = { - uri: vscode.Uri.file('/path/to/project'), - name: 'Test Project' - }; - - mockEnvManagers.setup((em) => em.managers).returns(() => [mockManager]); - mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => [mockProject]); - mockEnvManagers.setup((em) => em.getEnvironment(typeMoq.It.isAny())).returns(() => Promise.resolve(mockEnv)); - - // Verify mocks are set up correctly - const managers = mockEnvManagers.object.managers; - const projects = mockProjectManager.object.getProjects(); - - assert.strictEqual(managers.length, 1); - assert.strictEqual(projects.length, 1); - assert.strictEqual(managers[0].id, 'test-manager'); - assert.strictEqual(projects[0].name, 'Test Project'); + test('stops when the description input is cancelled', async () => { + sinon.stub(windowApis, 'showInputBox').onFirstCall().resolves('Issue title').onSecondCall().resolves(undefined); + const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo'); + const executeCommand = sinon.stub(commandApi, 'executeCommand'); + + await reportIssue(context, envManagers, projectManager); + + sinon.assert.notCalled(collectEnvironmentInfo); + sinon.assert.notCalled(executeCommand); }); - test('should handle errors gracefully during environment collection', async () => { - const mockManager = { - id: 'error-manager', - displayName: 'Error Manager', - getEnvironments: async () => { - throw new Error('Test error'); - } - } as any; - - mockEnvManagers.setup((em) => em.managers).returns(() => [mockManager]); - mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => []); - - // Verify that error conditions don't break the test setup - const managers = mockEnvManagers.object.managers; - assert.strictEqual(managers.length, 1); - assert.strictEqual(managers[0].id, 'error-manager'); + test('validates the minimum description length in the input box', async () => { + const showInputBox = sinon + .stub(windowApis, 'showInputBox') + .onFirstCall() + .resolves('Issue title') + .onSecondCall() + .resolves(undefined); + + await reportIssue(context, envManagers, projectManager); + + const options = showInputBox.secondCall.args[0] as InputBoxOptions; + assert.ok(options.validateInput); + assert.strictEqual(await options.validateInput('ab'), l10n.t('Enter at least {0} characters.', 3)); + assert.strictEqual(await options.validateInput('abc'), undefined); }); - test('should register report issue command', () => { - // Basic test to ensure command registration structure would work - // The actual command registration happens during extension activation - // This tests the mock setup and basic functionality - - mockEnvManagers.setup((em) => em.managers).returns(() => []); - mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => []); - - // Verify basic setup works - assert.notStrictEqual(mockEnvManagers.object, undefined); - assert.notStrictEqual(mockProjectManager.object, undefined); + test('does not collect environment information when confirmation is dismissed', async () => { + sinon.stub(windowApis, 'showInputBox').onFirstCall().resolves('Issue title').onSecondCall().resolves('Details'); + sinon.stub(windowApis, 'showInformationMessage').resolves(undefined); + const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo'); + const executeCommand = sinon.stub(commandApi, 'executeCommand'); + + await reportIssue(context, envManagers, projectManager); + + sinon.assert.notCalled(collectEnvironmentInfo); + sinon.assert.notCalled(executeCommand); + }); + + test('opens a prefilled issue reporter after confirmation', async () => { + sinon + .stub(windowApis, 'showInputBox') + .onFirstCall() + .resolves(' Issue title ') + .onSecondCall() + .resolves(' Issue details '); + const showInformationMessage = sinon + .stub(windowApis, 'showInformationMessage') + .resolves(l10n.t('Continue to Issue Reporter')); + const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo').resolves('Environment details'); + const executeCommand = sinon.stub(commandApi, 'executeCommand').resolves(); + + await reportIssue(context, envManagers, projectManager); + + sinon.assert.calledOnce(showInformationMessage); + assert.deepStrictEqual(showInformationMessage.firstCall.args, [ + l10n.t( + 'To help the Python Environments team investigate, VS Code will collect details about your Python environments and projects and open a prefilled GitHub issue. You can review and edit it before submitting.', + ), + { modal: true }, + l10n.t('Continue to Issue Reporter'), + ]); + sinon.assert.calledOnceWithExactly(collectEnvironmentInfo, context, envManagers, projectManager); + sinon.assert.calledOnce(executeCommand); + assert.strictEqual(executeCommand.firstCall.args[0], 'workbench.action.openIssueReporter'); + assert.deepStrictEqual(executeCommand.firstCall.args[1], { + extensionId: 'ms-python.vscode-python-envs', + issueTitle: '[Python Environments] Issue title', + issueBody: + '## Description\nIssue details\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n' + + '## Actual Behavior\n\n\n\n\n
\n' + + 'Environment Information\n\n```\nEnvironment details\n```\n\n
', + }); + }); + + test('shows a localized error when opening the issue reporter fails', async () => { + sinon.stub(windowApis, 'showInputBox').onFirstCall().resolves('Issue title').onSecondCall().resolves('Details'); + sinon.stub(windowApis, 'showInformationMessage').resolves(l10n.t('Continue to Issue Reporter')); + sinon.stub(helpers, 'collectEnvironmentInfo').resolves('Environment details'); + sinon.stub(commandApi, 'executeCommand').rejects(new Error('Reporter failed')); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + + await reportIssue(context, envManagers, projectManager); + + sinon.assert.calledOnce(showErrorMessage); + assert.strictEqual( + showErrorMessage.firstCall.args[0], + l10n.t('Failed to open the issue reporter. Please try again.'), + ); }); -}); \ No newline at end of file +});