diff --git a/extension/package.nls.json b/extension/package.nls.json index 5d1100b016f..474131b62e7 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -168,6 +168,8 @@ "aspire-vscode.strings.aspireRestoreAllCompleted": "Aspire restore completed", "aspire-vscode.strings.aspireRestoreFailed": "aspire restore failed for {0}: {1}", "aspire-vscode.strings.aspireRestoreFailedStatusBar": "Aspire restore failed (click to retry)", + "aspire-vscode.strings.appHostPathCopiedToClipboard": "AppHost path copied to clipboard.", + "aspire-vscode.strings.appHostPathInvalid": "Could not determine the AppHost path to copy.", "aspire-vscode.strings.appHostSourceNotFound": "Could not determine the AppHost source file to open.", "aspire-vscode.strings.appHostSourceOpenFailed": "Failed to open AppHost source file: {0}", "aspire-vscode.strings.logFilePathInvalid": "Could not determine the AppHost log file to open.", diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 1ef4b1ea1d8..caf9eab2441 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -121,6 +121,8 @@ export const appHostOpenSourceActionLabel = vscode.l10n.t('Open AppHost source') export const appHostRunActionLabel = vscode.l10n.t('Run AppHost'); export const appHostDebugActionLabel = vscode.l10n.t('Debug AppHost'); export const appHostPathLabel = vscode.l10n.t('Path'); +export const appHostPathCopiedToClipboard = vscode.l10n.t('AppHost path copied to clipboard.'); +export const appHostPathInvalid = vscode.l10n.t('Could not determine the AppHost path to copy.'); export const appHostStartingDescription = vscode.l10n.t('Starting...'); export const appHostStoppingDescription = vscode.l10n.t('Stopping...'); export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count); diff --git a/extension/src/test-e2e/appHostTree.e2e.test.ts b/extension/src/test-e2e/appHostTree.e2e.test.ts index 0155ee8e814..f2afdab8c0c 100644 --- a/extension/src/test-e2e/appHostTree.e2e.test.ts +++ b/extension/src/test-e2e/appHostTree.e2e.test.ts @@ -1,14 +1,15 @@ import * as assert from 'assert'; -import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; +import { assertClipboardMatchesLastExpectationForE2E, captureWorkspaceAppHostPathClipboardExpectationForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; -import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForTreeItem } from './helpers/vscode'; +import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForChildTreeItem, waitForNotificationMessage, waitForTreeItem } from './helpers/vscode'; suite('Aspire AppHost tree E2E', function () { this.timeout(240000); teardown(async () => { await runE2eTeardown([ + () => restoreClipboardSnapshotForE2E(), () => setCliUnavailableForE2E(false), () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreWorkspaceCliPath(), @@ -31,6 +32,33 @@ suite('Aspire AppHost tree E2E', function () { assert.ok(stateFile.state.workspaceAppHostCandidatePaths.length >= 1); }); + test('clicking the Path tree item copies the AppHost path and shows a confirmation notification', async () => { + await openAspireView(); + await waitForRepositoryIdle(); + await snapshotClipboardForE2E(); + const discovered = await waitForWorkspaceAppHost(); + const appHostLabel = getTreeAppHostLabel(discovered.state); + const section = await openAspireView(); + + // The Path row only appears under an idle (non-running) workspace AppHost item, so exercise + // it before starting the AppHost. See https://github.com/microsoft/aspire/issues/18578. + const idleItem = await waitForTreeItem(section, appHostLabel); + await idleItem.expand(); + + // Labels below match loc/strings.ts (appHostPathLabel / appHostPathCopiedToClipboard); the + // E2E host runs in English so the literals are stable, mirroring other tree-item labels + // asserted in this suite (e.g. 'Run AppHost'). + const pathItem = await waitForChildTreeItem(idleItem, 'Path'); + await captureWorkspaceAppHostPathClipboardExpectationForE2E(); + await pathItem.click(); + + // The notification only fires after a successful copy, so its appearance proves the click + // routed through aspire-vscode.copyAppHostPath rather than reading a stale clipboard value. + await waitForNotificationMessage('AppHost path copied to clipboard.'); + + await assertClipboardMatchesLastExpectationForE2E(); + }); + test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => { await openAspireView(); await waitForRepositoryIdle(); diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index e8a51730c2c..848aaa52560 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -75,6 +75,22 @@ export async function executeE2eControlCommand( return await applyE2eControl({ command }, options?.waitFor ?? 'applied', timeoutMs); } +export async function snapshotClipboardForE2E(): Promise { + await executeE2eControlCommand({ name: 'snapshotClipboard' }); +} + +export async function restoreClipboardSnapshotForE2E(): Promise { + await executeE2eControlCommand({ name: 'restoreClipboardSnapshot' }); +} + +export async function captureWorkspaceAppHostPathClipboardExpectationForE2E(): Promise { + await executeE2eControlCommand({ name: 'captureWorkspaceAppHostPathClipboardExpectation' }); +} + +export async function assertClipboardMatchesLastExpectationForE2E(): Promise { + await executeE2eControlCommand({ name: 'assertClipboardMatchesLastExpectation' }); +} + export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise>, failureMessage: string): Promise { const failures: unknown[] = []; for (const cleanup of cleanups) { diff --git a/extension/src/test-e2e/treeActions.e2e.test.ts b/extension/src/test-e2e/treeActions.e2e.test.ts index 6f721e7cb05..681fbe09c91 100644 --- a/extension/src/test-e2e/treeActions.e2e.test.ts +++ b/extension/src/test-e2e/treeActions.e2e.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import * as path from 'path'; -import { findResource, getCommandInvocationCount, getTerminalCommandCount, isSamePath, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { findResource, getCommandInvocationCount, getTerminalCommandCount, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; +import { assertClipboardMatchesLastExpectationForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { answerActiveInput, chooseActiveQuickPick, getActiveQuickPickLabels, openAspireView, waitForChildTreeItem, waitForTreeItem, waitForWorkbenchTextAfterIntegratedBrowserNavigation } from './helpers/vscode'; @@ -16,6 +16,7 @@ suite('Aspire tree action command E2E', function () { teardown(async () => { await runE2eTeardown([ + () => restoreClipboardSnapshotForE2E(), () => setCliUnavailableForE2E(false), () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreWorkspaceCliPath(), @@ -80,8 +81,9 @@ suite('Aspire tree action command E2E', function () { await noCommandsResource.expand(); assert.strictEqual(await noCommandsResource.findChildItem('Commands'), undefined); - const copiedAppHost = await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); - assert.ok(isSamePath(String(copiedAppHost.result), appHostPath)); + await snapshotClipboardForE2E(); + await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); + await assertClipboardMatchesLastExpectationForE2E(); const openedSource = await executeE2eControlCommand({ name: 'openAppHostSource', appHostPath }); assert.ok(String((openedSource.result as { fileName?: string }).fileName).endsWith(path.join('AspireE2E.AppHost', 'AppHost.cs'))); @@ -89,12 +91,14 @@ suite('Aspire tree action command E2E', function () { const viewedSource = await executeE2eControlCommand({ name: 'viewAppHostSource', appHostPath }); assert.ok(String((viewedSource.result as { uri?: string }).uri).startsWith('aspire-source:')); - const copiedResourceName = await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' }); - assert.strictEqual(copiedResourceName.result, 'e2e-worker'); + await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' }); + await assertClipboardMatchesLastExpectationForE2E(); - const copiedEndpointUrl = await executeE2eControlCommand({ name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker' }); - const endpointUrl = String(copiedEndpointUrl.result); + const endpointUrl = workerResource.urls?.find(url => !url.isInternal)?.url ?? workerResource.urls?.[0]?.url; + assert.ok(endpointUrl, 'Expected e2e-worker to expose an endpoint URL.'); assert.ok(endpointUrl.startsWith('http')); + await executeE2eControlCommand({ name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker' }); + await assertClipboardMatchesLastExpectationForE2E(); before = getCommandInvocationCount('aspire-vscode.openInIntegratedBrowser'); const openedEndpoint = await executeE2eControlCommand({ name: 'openInIntegratedBrowser', appHostPath, resourceName: 'e2e-worker' }); @@ -107,8 +111,8 @@ suite('Aspire tree action command E2E', function () { const viewedLogFileName = (viewedLog.result as { fileName?: string }).fileName; assert.ok(viewedLogFileName && path.isAbsolute(viewedLogFileName)); - const copiedLogPath = await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath }); - assert.ok(path.isAbsolute(String(copiedLogPath.result))); + await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath }); + await assertClipboardMatchesLastExpectationForE2E(); let terminalBefore: number; diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 0bc0bc53b09..712691286e6 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -16,7 +16,7 @@ import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConst import type { AspireSubcommand } from '../utils/AspireTerminalProvider'; import { AspireTerminalProvider, shellArg } from '../utils/AspireTerminalProvider'; import { AppHostLaunchService } from '../services/AppHostLaunchService'; -import { terminalCommandArgumentControlCharacters } from '../loc/strings'; +import { terminalCommandArgumentControlCharacters, appHostPathCopiedToClipboard, appHostPathInvalid } from '../loc/strings'; import { onDidInvokeCommand, withCommandTelemetry } from '../utils/telemetry'; function makeResource(overrides: Partial = {}): ResourceJson { @@ -2028,18 +2028,25 @@ suite('AspireAppHostTreeProvider.findAppHostElement', () => { assert.strictEqual(appHostItem.label, 'AppHost.csproj'); assert.strictEqual(appHostItem.contextValue, 'workspaceAppHost'); assert.strictEqual(appHostItem.collapsibleState, vscode.TreeItemCollapsibleState.Collapsed); - assert.deepStrictEqual(provider.getChildren(appHostItem).map(item => item.contextValue), [ + const appHostChildren = provider.getChildren(appHostItem); + assert.deepStrictEqual(appHostChildren.map(item => item.contextValue), [ 'workspaceAppHostAction:openSource', 'workspaceAppHostAction:run', 'workspaceAppHostAction:debug', 'workspaceAppHostPath', ]); - assert.deepStrictEqual(provider.getChildren(appHostItem).map(item => item.command?.command), [ + assert.deepStrictEqual(appHostChildren.map(item => item.command?.command), [ 'aspire-vscode.openAppHostSource', 'aspire-vscode.runAppHost', 'aspire-vscode.debugAppHost', - undefined, + 'aspire-vscode.copyAppHostPath', ]); + // Clicking the Path row copies the AppHost path via the same handler as the right-click + // context menu, so its command must carry the parent AppHost item as its argument + // (https://github.com/microsoft/aspire/issues/18578). + const pathItem = appHostChildren.find(item => item.contextValue === 'workspaceAppHostPath'); + assert.ok(pathItem, 'Expected a Path tree item under the workspace AppHost.'); + assert.deepStrictEqual(pathItem.command?.arguments, [appHostItem]); // findAppHostElement rebuilds the tree (getChildren is not cached), so the returned // element is a fresh instance. Match by stable id/contextValue rather than reference. assert.ok(result, 'Expected to find the workspace AppHost candidate'); @@ -2856,6 +2863,78 @@ suite('viewAppHostLogFile', () => { }); }); +suite('copyAppHostPath', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + test('copies the workspace AppHost path and shows a confirmation notification', async () => { + const previousClipboard = await vscode.env.clipboard.readText(); + const appHostPath = path.resolve('workspace', 'apps', 'Store', 'AppHost.csproj'); + const onDidChangeData: vscode.Event = () => ({ dispose: () => { } }); + const repository = { + viewMode: 'workspace' as ViewMode, + appHosts: [], + workspaceResources: [], + workspaceAppHostPath: appHostPath, + workspaceAppHostCandidatePaths: [appHostPath], + workspaceAppHostName: 'Store', + workspaceAppHostDescription: undefined, + onDidChangeData, + } as unknown as AppHostDataRepository; + const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService()); + // vscode.env.clipboard.writeText is non-configurable, so exercise the real clipboard and read + // it back rather than stubbing it. Seed a sentinel first so a matching read proves the copy ran. + try { + await vscode.env.clipboard.writeText('sentinel-before-copy'); + const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + + const [appHostItem] = provider.getChildren(); + assert.strictEqual(appHostItem.contextValue, 'workspaceAppHost'); + await provider.copyAppHostPath(appHostItem as any); + + assert.strictEqual(await vscode.env.clipboard.readText(), appHostPath); + assert.strictEqual(infoStub.callCount, 1); + assert.strictEqual(infoStub.firstCall.args[0], appHostPathCopiedToClipboard); + } finally { + try { + await vscode.env.clipboard.writeText(previousClipboard); + } finally { + provider.dispose(); + } + } + }); + + test('shows a warning and skips the notification when the AppHost path is missing', async () => { + const previousClipboard = await vscode.env.clipboard.readText(); + const provider = makeTreeProvider([]); + try { + await vscode.env.clipboard.writeText('sentinel-unchanged'); + const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + const warningStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined as any); + + await provider.copyAppHostPath({ appHostPath: undefined } as any); + + assert.strictEqual(await vscode.env.clipboard.readText(), 'sentinel-unchanged'); + assert.strictEqual(infoStub.callCount, 0); + assert.ok(warningStub.calledOnce); + assert.strictEqual(warningStub.firstCall.args[0], appHostPathInvalid); + } finally { + try { + await vscode.env.clipboard.writeText(previousClipboard); + } finally { + provider.dispose(); + } + } + }); +}); + suite('viewAppHostSource', () => { let sandbox: sinon.SinonSandbox; diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 26b5e86caf2..7c8cb42d521 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -272,6 +272,103 @@ suite('E2E launch profile', () => { assert.ok(assertions.includes("waitFor === 'applied' ? file.control.status === 'applied' : file.control.startedObserved === true")); }); + test('keeps E2E clipboard snapshots out of diagnostic state and control files', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const apiTypes = fs.readFileSync(path.join(extensionRoot, 'src', 'types', 'extensionApi.ts'), 'utf8'); + const e2eStateFileBridge = fs.readFileSync(path.join(extensionRoot, 'src', 'testing', 'e2eStateFileBridge.ts'), 'utf8'); + const fixtures = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'helpers', 'fixtures.ts'), 'utf8'); + const appHostTreeE2E = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'appHostTree.e2e.test.ts'), 'utf8'); + const treeActionsE2E = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'treeActions.e2e.test.ts'), 'utf8'); + + assert.ok(apiTypes.includes("{ name: 'snapshotClipboard' }")); + assert.ok(apiTypes.includes("{ name: 'restoreClipboardSnapshot' }")); + assert.ok(apiTypes.includes("{ name: 'captureWorkspaceAppHostPathClipboardExpectation' }")); + assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesLastExpectation' }")); + assert.ok(!apiTypes.includes("{ name: 'readClipboard' }")); + assert.ok(!apiTypes.includes("{ name: 'writeClipboard'; text: string }")); + + assert.ok(e2eStateFileBridge.includes("case 'snapshotClipboard':")); + assert.ok(e2eStateFileBridge.includes("case 'restoreClipboardSnapshot':")); + assert.ok(e2eStateFileBridge.includes("case 'captureWorkspaceAppHostPathClipboardExpectation':")); + assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesLastExpectation':")); + assert.ok(!e2eStateFileBridge.includes('return await vscode.env.clipboard.readText();')); + assert.ok(!e2eStateFileBridge.includes('await vscode.env.clipboard.writeText(command.text);')); + + assert.ok(fixtures.includes('snapshotClipboardForE2E')); + assert.ok(fixtures.includes('restoreClipboardSnapshotForE2E')); + assert.ok(fixtures.includes('captureWorkspaceAppHostPathClipboardExpectationForE2E')); + assert.ok(fixtures.includes('assertClipboardMatchesLastExpectationForE2E')); + assert.ok(!fixtures.includes('readClipboardForE2E')); + assert.ok(!fixtures.includes('writeClipboardForE2E')); + + assert.ok(appHostTreeE2E.includes('snapshotClipboardForE2E')); + assert.ok(appHostTreeE2E.includes('restoreClipboardSnapshotForE2E')); + assert.ok(appHostTreeE2E.includes('await captureWorkspaceAppHostPathClipboardExpectationForE2E();')); + assert.ok(appHostTreeE2E.includes('await assertClipboardMatchesLastExpectationForE2E();')); + assert.ok(!appHostTreeE2E.includes('clipboardTextToRestore')); + + assert.ok(treeActionsE2E.includes('snapshotClipboardForE2E')); + assert.ok(treeActionsE2E.includes('restoreClipboardSnapshotForE2E')); + assert.ok(treeActionsE2E.indexOf('() => restoreClipboardSnapshotForE2E()') < treeActionsE2E.indexOf('() => setCliUnavailableForE2E(false)')); + assert.ok(treeActionsE2E.indexOf('await snapshotClipboardForE2E();') < treeActionsE2E.indexOf("await executeE2eControlCommand({ name: 'copyAppHostPath'")); + }); + + test('keeps copied values out of E2E control command results', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const apiTypes = fs.readFileSync(path.join(extensionRoot, 'src', 'types', 'extensionApi.ts'), 'utf8'); + const e2eStateFileBridge = fs.readFileSync(path.join(extensionRoot, 'src', 'testing', 'e2eStateFileBridge.ts'), 'utf8'); + const fixtures = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'helpers', 'fixtures.ts'), 'utf8'); + const treeActionsE2E = fs.readFileSync(path.join(extensionRoot, 'src', 'test-e2e', 'treeActions.e2e.test.ts'), 'utf8'); + + const copyAppHostPathCase = getSwitchCase(e2eStateFileBridge, 'copyAppHostPath', 'viewAppHostLogFile'); + const copyLogFilePathCase = getSwitchCase(e2eStateFileBridge, 'copyLogFilePath', 'viewResourceLogs'); + const copyResourceNameCase = getSwitchCase(e2eStateFileBridge, 'copyResourceName', 'copyEndpointUrl'); + const copyEndpointUrlCase = getSwitchCase(e2eStateFileBridge, 'copyEndpointUrl', 'openInIntegratedBrowser'); + + assert.ok(copyAppHostPathCase.includes("vscode.commands.executeCommand('aspire-vscode.copyAppHostPath'")); + assert.ok(copyLogFilePathCase.includes("vscode.commands.executeCommand('aspire-vscode.copyLogFilePath'")); + assert.ok(copyResourceNameCase.includes("vscode.commands.executeCommand('aspire-vscode.copyResourceName'")); + assert.ok(copyEndpointUrlCase.includes("vscode.commands.executeCommand('aspire-vscode.copyEndpointUrl'")); + + assert.ok(!copyAppHostPathCase.includes('return copiedPath;')); + assert.ok(!copyAppHostPathCase.includes("'appHostPath'")); + assert.ok(!copyLogFilePathCase.includes('return logFilePath;')); + assert.ok(!copyLogFilePathCase.includes("'logFilePath'")); + assert.ok(!copyResourceNameCase.includes('return command.resourceName;')); + assert.ok(!copyEndpointUrlCase.includes('return endpoint.url;')); + assert.ok(!apiTypes.includes('expectedText: string')); + assert.ok(!fixtures.includes('assertClipboardTextForE2E(expectedText')); + assert.ok(!e2eStateFileBridge.includes('command.expectedText')); + assert.ok(!treeActionsE2E.includes("name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker', url")); + + assert.ok(!treeActionsE2E.includes('copiedAppHost.result')); + assert.ok(!treeActionsE2E.includes('copiedResourceName.result')); + assert.ok(!treeActionsE2E.includes('copiedEndpointUrl.result')); + assert.ok(!treeActionsE2E.includes('copiedLogPath.result')); + }); + + test('keeps E2E clipboard assertions tied to captured in-memory expectations', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const e2eStateFileBridge = fs.readFileSync(path.join(extensionRoot, 'src', 'testing', 'e2eStateFileBridge.ts'), 'utf8'); + + const copyAppHostPathCase = getSwitchCase(e2eStateFileBridge, 'copyAppHostPath', 'viewAppHostLogFile'); + const copyLogFilePathCase = getSwitchCase(e2eStateFileBridge, 'copyLogFilePath', 'viewResourceLogs'); + const copyResourceNameCase = getSwitchCase(e2eStateFileBridge, 'copyResourceName', 'copyEndpointUrl'); + const copyEndpointUrlCase = getSwitchCase(e2eStateFileBridge, 'copyEndpointUrl', 'openInIntegratedBrowser'); + const assertClipboardCase = getSwitchCase(e2eStateFileBridge, 'assertClipboardMatchesLastExpectation', 'openWorkspaceFolder'); + + assert.ok(e2eStateFileBridge.includes('const clipboardExpectation: E2eClipboardExpectation = {};')); + assert.ok(copyAppHostPathCase.includes("setClipboardExpectation(clipboardExpectation, expectedClipboardText, 'path');")); + assert.ok(copyLogFilePathCase.includes("setClipboardExpectation(clipboardExpectation, expectedClipboardText, 'path');")); + assert.ok(copyResourceNameCase.includes('setClipboardExpectation(clipboardExpectation, expectedClipboardText);')); + assert.ok(copyEndpointUrlCase.includes('setClipboardExpectation(clipboardExpectation, endpoint.url);')); + assert.ok(assertClipboardCase.includes('await assertExpectedClipboardText(clipboardExpectation);')); + assert.ok(!assertClipboardCase.includes('createStateSnapshot')); + assert.ok(!assertClipboardCase.includes('getEndpointElement')); + assert.ok(!assertClipboardCase.includes('getLogFileElement')); + assert.ok(!assertClipboardCase.includes('getResourceElement')); + }); + test('latches E2E AppHost stopping path transitions before snapshots can clear', () => { const extensionRoot = path.resolve(__dirname, '..', '..'); const apiTypes = fs.readFileSync(path.join(extensionRoot, 'src', 'types', 'extensionApi.ts'), 'utf8'); @@ -496,3 +593,13 @@ suite('E2E launch profile', () => { assert.ok(!resourceLifecycleCommands.includes("['Stopped', 'Finished', 'Exited']")); }); }); + +function getSwitchCase(source: string, startCase: string, nextCase: string): string { + const start = source.indexOf(`case '${startCase}':`); + const end = source.indexOf(`case '${nextCase}':`, start); + + assert.ok(start >= 0, `Expected to find ${startCase} case.`); + assert.ok(end > start, `Expected to find ${nextCase} case after ${startCase}.`); + + return source.slice(start, end); +} diff --git a/extension/src/test/strings.test.ts b/extension/src/test/strings.test.ts index ab1e39e671f..07607147ffa 100644 --- a/extension/src/test/strings.test.ts +++ b/extension/src/test/strings.test.ts @@ -1,4 +1,6 @@ import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; import { formatText } from '../utils/strings'; suite('utils/strings tests', () => { @@ -18,4 +20,21 @@ suite('utils/strings tests', () => { const resultWithNoEmojis = formatText(inputWithNoEmojis); assert.strictEqual(resultWithNoEmojis, expectedOutputWithNoEmojis); }); + + + test('copy AppHost path loc strings have package nls entries', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const stringsSource = fs.readFileSync(path.join(extensionRoot, 'src', 'loc', 'strings.ts'), 'utf8'); + const packageNls = JSON.parse(fs.readFileSync(path.join(extensionRoot, 'package.nls.json'), 'utf8')) as Record; + + const expectedStrings = { + appHostPathCopiedToClipboard: 'AppHost path copied to clipboard.', + appHostPathInvalid: 'Could not determine the AppHost path to copy.', + }; + + for (const [name, value] of Object.entries(expectedStrings)) { + assert.ok(stringsSource.includes(`export const ${name} = vscode.l10n.t('${value}');`)); + assert.strictEqual(packageNls[`aspire-vscode.strings.${name}`], value); + } + }); }); \ No newline at end of file diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 190ec6b15c9..ecd8bcd331d 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -40,6 +40,8 @@ export function createE2eStateFileBridge( const debugLaunches: AspireExtensionE2EDebugLaunch[] = []; const debugConsoleOutputs: AspireExtensionE2EDebugConsoleOutput[] = []; const stoppingPathEvents: AspireExtensionE2EStoppingPathEvent[] = []; + const clipboardSnapshot: E2eClipboardSnapshot = { hasSnapshot: false }; + const clipboardExpectation: E2eClipboardExpectation = {}; let commandInvocationSequence = 0; let terminalCommandSequence = 0; let debugLaunchSequence = 0; @@ -177,7 +179,7 @@ export function createE2eStateFileBridge( } }; - const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, payload.command, markCommandStarted); + const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, clipboardSnapshot, clipboardExpectation, payload.command, markCommandStarted); controlStatus = { revision, status: 'applied', startedObserved: commandStarted, result }; } else { @@ -281,6 +283,8 @@ async function executeE2eControlCommand( appHostLaunchService: AppHostLaunchService, appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider, + clipboardSnapshot: E2eClipboardSnapshot, + clipboardExpectation: E2eClipboardExpectation, command: AspireExtensionE2EControlCommand, markStarted: () => void ): Promise { @@ -354,10 +358,12 @@ async function executeE2eControlCommand( } case 'copyAppHostPath': { const element = getAppHostElement(appHostTreeProvider, command.appHostPath); + const expectedClipboardText = getAppHostPathForClipboard(element); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyAppHostPath', element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + setClipboardExpectation(clipboardExpectation, expectedClipboardText, 'path'); + return undefined; } case 'viewAppHostLogFile': { const element = getLogFileElement(appHostTreeProvider, command.appHostPath); @@ -368,10 +374,12 @@ async function executeE2eControlCommand( } case 'copyLogFilePath': { const element = getLogFileElement(appHostTreeProvider, command.appHostPath); + const expectedClipboardText = getLogFilePathForClipboard(element); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyLogFilePath', element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + setClipboardExpectation(clipboardExpectation, expectedClipboardText, 'path'); + return undefined; } case 'viewResourceLogs': { const element = getResourceElement(appHostTreeProvider, command.resourceName, command.appHostPath); @@ -387,17 +395,20 @@ async function executeE2eControlCommand( } case 'copyResourceName': { const element = getResourceElement(appHostTreeProvider, command.resourceName, command.appHostPath); + const expectedClipboardText = getResourceNameForClipboard(element); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyResourceName', element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + setClipboardExpectation(clipboardExpectation, expectedClipboardText); + return undefined; } case 'copyEndpointUrl': { const endpoint = getEndpointElement(appHostTreeProvider, command); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyEndpointUrl', endpoint.element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + setClipboardExpectation(clipboardExpectation, endpoint.url); + return undefined; } case 'openInIntegratedBrowser': { const endpoint = getEndpointElement(appHostTreeProvider, command); @@ -545,9 +556,38 @@ async function executeE2eControlCommand( markStarted(); return await getDiagnosticsForFile(command.filePath); } - case 'readClipboard': { + case 'snapshotClipboard': { + markStarted(); + // The state and control files are uploaded as E2E diagnostics, so arbitrary user + // clipboard text must stay in extension-host memory instead of crossing the JSON bridge. + clipboardSnapshot.text = await vscode.env.clipboard.readText(); + clipboardSnapshot.hasSnapshot = true; + return undefined; + } + case 'restoreClipboardSnapshot': { + markStarted(); + if (clipboardSnapshot.hasSnapshot) { + await vscode.env.clipboard.writeText(clipboardSnapshot.text ?? ''); + clipboardSnapshot.text = undefined; + clipboardSnapshot.hasSnapshot = false; + } + + return undefined; + } + case 'captureWorkspaceAppHostPathClipboardExpectation': { markStarted(); - return await vscode.env.clipboard.readText(); + const state = createStateSnapshot(dataRepository, appHostLaunchService, appHostTreeProvider, aspireContext, true); + if (!state.workspaceAppHostPath) { + throw new Error('E2E clipboard assertion could not determine the workspace AppHost path.'); + } + + setClipboardExpectation(clipboardExpectation, state.workspaceAppHostPath, 'path'); + return undefined; + } + case 'assertClipboardMatchesLastExpectation': { + markStarted(); + await assertExpectedClipboardText(clipboardExpectation); + return undefined; } case 'openWorkspaceFolder': { const folderPath = getE2eWorkspaceFolderPath(command.folderPath); @@ -573,6 +613,42 @@ async function executeE2eControlCommand( } } +interface E2eClipboardSnapshot { + text?: string; + hasSnapshot: boolean; +} + +interface E2eClipboardExpectation { + text?: string; + comparison?: 'exact' | 'path'; +} + +function setClipboardExpectation(expectation: E2eClipboardExpectation, text: string, comparison: 'exact' | 'path' = 'exact'): void { + expectation.text = text; + expectation.comparison = comparison; +} + +async function assertExpectedClipboardText(expectation: E2eClipboardExpectation): Promise { + if (expectation.text === undefined) { + throw new Error('E2E clipboard assertion did not have an expected value captured in memory.'); + } + + const expectedText = expectation.text; + const comparison = expectation.comparison ?? 'exact'; + expectation.text = undefined; + expectation.comparison = undefined; + + const clipboardText = await vscode.env.clipboard.readText(); + const matches = comparison === 'path' + ? isSamePath(clipboardText, expectedText) + : clipboardText === expectedText; + if (!matches) { + throw new Error(comparison === 'path' + ? 'E2E clipboard path did not match the expected path.' + : 'E2E clipboard text did not match the expected text.'); + } +} + function getE2eLaunchConfiguration(value: unknown): ExecutableLaunchConfiguration { if (!value || typeof value !== 'object' || !('type' in value) || typeof value.type !== 'string' || value.type.length === 0) { throw new Error('Aspire extension E2E createResourceDebugConfiguration requires a launchConfig object with a non-empty type.'); @@ -1142,6 +1218,39 @@ function getAppHostElement(appHostTreeProvider: AspireAppHostTreeProvider, appHo return appHostPath ? appHostTreeProvider.findAppHostElement(appHostPath) ?? { appHostPath } : undefined; } +function getAppHostPathForClipboard(element: unknown): string { + if (hasAppHostPath(element)) { + return element.appHostPath; + } + + if (hasNestedAppHostPath(element)) { + return element.appHost.appHostPath; + } + + throw new Error('Aspire extension E2E AppHost clipboard assertion found an AppHost tree item with an unexpected shape.'); +} + +function hasAppHostPath(element: unknown): element is { appHostPath: string } { + return typeof element === 'object' + && element !== null + && 'appHostPath' in element + && typeof element.appHostPath === 'string' + && element.appHostPath.length > 0; +} + +function hasNestedAppHostPath(element: unknown): element is { appHost: { appHostPath: string } } { + if (typeof element !== 'object' || element === null || !('appHost' in element)) { + return false; + } + + const appHost = element.appHost; + return typeof appHost === 'object' + && appHost !== null + && 'appHostPath' in appHost + && typeof appHost.appHostPath === 'string' + && appHost.appHostPath.length > 0; +} + function getResourceElement(appHostTreeProvider: AspireAppHostTreeProvider, resourceName: string, appHostPath?: string): unknown { if (typeof resourceName !== 'string' || resourceName.length === 0) { throw new Error('Aspire extension E2E resource command requires resourceName.'); @@ -1157,12 +1266,12 @@ function getResourceElement(appHostTreeProvider: AspireAppHostTreeProvider, reso function getEndpointElement( appHostTreeProvider: AspireAppHostTreeProvider, - command: Extract + command: Extract ): { element: unknown; url: string } { const element = appHostTreeProvider.findEndpointElement({ appHostPath: command.appHostPath, resourceName: command.resourceName, - url: command.url, + url: 'url' in command ? command.url : undefined, }); if (!element) { throw new Error('Aspire extension E2E endpoint command could not find a matching endpoint.'); @@ -1183,6 +1292,27 @@ function hasEndpointUrl(element: unknown): element is { url: string } { && element.url.length > 0; } +function getResourceNameForClipboard(element: unknown): string { + if (!hasResourceForClipboard(element)) { + throw new Error('Aspire extension E2E resource clipboard assertion found a resource tree item with an unexpected shape.'); + } + + return element.resource.displayName ?? element.resource.name; +} + +function hasResourceForClipboard(element: unknown): element is { resource: { displayName?: string | null; name: string } } { + if (typeof element !== 'object' || element === null || !('resource' in element)) { + return false; + } + + const resource = element.resource; + return typeof resource === 'object' + && resource !== null + && 'name' in resource + && typeof resource.name === 'string' + && (!('displayName' in resource) || resource.displayName === undefined || resource.displayName === null || typeof resource.displayName === 'string'); +} + function getResourceCommandElement( appHostTreeProvider: AspireAppHostTreeProvider, command: Extract @@ -1244,6 +1374,22 @@ function getLogFileElement(appHostTreeProvider: AspireAppHostTreeProvider, appHo return element; } +function getLogFilePathForClipboard(element: unknown): string { + if (!hasLogFilePath(element)) { + throw new Error('Aspire extension E2E log file clipboard assertion found a log file tree item with an unexpected shape.'); + } + + return element.logFilePath; +} + +function hasLogFilePath(element: unknown): element is { logFilePath: string } { + return typeof element === 'object' + && element !== null + && 'logFilePath' in element + && typeof element.logFilePath === 'string' + && element.logFilePath.length > 0; +} + function getActiveEditorInfo(): { uri?: string; fileName?: string; text?: string } { const document = vscode.window.activeTextEditor?.document; return { diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index d4d4040de57..099386b8c30 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -188,7 +188,10 @@ export type AspireExtensionE2EControlCommand = | { name: 'getExtensionPackageJson' } | { name: 'getExtensionFileStatus'; relativePaths: readonly string[] } | { name: 'getDiagnostics'; filePath: string } - | { name: 'readClipboard' } + | { name: 'snapshotClipboard' } + | { name: 'restoreClipboardSnapshot' } + | { name: 'captureWorkspaceAppHostPathClipboardExpectation' } + | { name: 'assertClipboardMatchesLastExpectation' } | { name: 'openWorkspaceFolder'; folderPath: string } | { name: 'getWorkspaceFolders' } | { name: 'getActiveEditor' } diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 3ef1b8fbad6..19ee9968529 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -18,6 +18,8 @@ import { appHostRunActionLabel, appHostDebugActionLabel, appHostPathLabel, + appHostPathCopiedToClipboard, + appHostPathInvalid, resourceCountDescription, tooltipType, tooltipState, @@ -246,6 +248,15 @@ class WorkspaceAppHostPathItem extends vscode.TreeItem { this.contextValue = 'workspaceAppHostPath'; this.description = parent.appHostPath; this.tooltip = parent.appHostPath; + // Clicking the Path row copies the AppHost path, since that's the most obvious thing a user + // expects when clicking a path. This mirrors WorkspaceAppHostActionItem/EndpointUrlItem and + // reuses the same handler as the right-click context menu. See + // https://github.com/microsoft/aspire/issues/18578. + this.command = { + command: 'aspire-vscode.copyAppHostPath', + title: appHostPathLabel, + arguments: [parent] + }; } } @@ -1542,10 +1553,11 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { const appHostPath = element instanceof AppHostItem ? element.appHost.appHostPath : element.appHostPath; if (!appHostPath) { - vscode.window.showWarningMessage(appHostSourceNotFound); + vscode.window.showWarningMessage(appHostPathInvalid); return; } await vscode.env.clipboard.writeText(appHostPath); + vscode.window.showInformationMessage(appHostPathCopiedToClipboard); } async viewAppHostLogFile(element: unknown): Promise {