From 07b598b16ee98582ecd9276f4724cbe2f7cdf039 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 13:30:01 -0600 Subject: [PATCH 1/8] Copy AppHost path to clipboard when clicking the Path tree item (#18578) The Path row in the Aspire: AppHosts view previously did nothing when clicked. Wire its command to the existing aspire-vscode.copyAppHostPath handler so a click copies the AppHost path, and show a confirmation notification after copying (per the issue owner's request). The notification also covers the existing right-click 'Copy path' action since both route through the same handler. Adds unit tests for the command wiring and the copy/notification behavior, plus an E2E test that clicks the Path item and verifies the clipboard contents and confirmation notification. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/src/loc/strings.ts | 1 + .../src/test-e2e/appHostTree.e2e.test.ts | 31 +++++++- extension/src/test/appHostTreeView.test.ts | 70 +++++++++++++++++-- .../src/views/AspireAppHostTreeProvider.ts | 11 +++ 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index 1ef4b1ea1d8..5377a9e48ff 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -121,6 +121,7 @@ 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 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..d8cdedc7fb1 100644 --- a/extension/src/test-e2e/appHostTree.e2e.test.ts +++ b/extension/src/test-e2e/appHostTree.e2e.test.ts @@ -1,8 +1,8 @@ import * as assert from 'assert'; -import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; +import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, 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); @@ -31,6 +31,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(); + const discovered = await waitForWorkspaceAppHost(); + const appHostLabel = getTreeAppHostLabel(discovered.state); + const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); + 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 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.'); + + const clipboard = await executeE2eControlCommand({ name: 'readClipboard' }); + assert.ok(isSamePath(String(clipboard.result), appHostPath), `Expected clipboard '${clipboard.result}' to match AppHost path '${appHostPath}'.`); + }); + 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/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 0bc0bc53b09..b55fcaec2c0 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 } 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,61 @@ 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 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. + 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); + provider.dispose(); + }); + + test('shows a warning and skips the notification when the AppHost path is missing', async () => { + const provider = makeTreeProvider([]); + 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); + provider.dispose(); + }); +}); + suite('viewAppHostSource', () => { let sandbox: sinon.SinonSandbox; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 3ef1b8fbad6..cfa61694cb4 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -18,6 +18,7 @@ import { appHostRunActionLabel, appHostDebugActionLabel, appHostPathLabel, + appHostPathCopiedToClipboard, resourceCountDescription, tooltipType, tooltipState, @@ -246,6 +247,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] + }; } } @@ -1546,6 +1556,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider { From 8ce131c46a8485d9bc3591d238eb0972074343af Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 15:11:36 -0600 Subject: [PATCH 2/8] Restore clipboard after AppHost path tests Save and restore the existing VS Code clipboard value around the AppHost path clipboard tests so they do not leak test state into later tests or the user environment. Add a minimal typed E2E writeClipboard bridge to pair with readClipboard for teardown restoration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/test-e2e/appHostTree.e2e.test.ts | 20 ++++++-- extension/src/test-e2e/helpers/fixtures.ts | 13 +++++ extension/src/test/appHostTreeView.test.ts | 50 ++++++++++++------- extension/src/testing/e2eStateFileBridge.ts | 5 ++ extension/src/types/extensionApi.ts | 1 + 5 files changed, 69 insertions(+), 20 deletions(-) diff --git a/extension/src/test-e2e/appHostTree.e2e.test.ts b/extension/src/test-e2e/appHostTree.e2e.test.ts index d8cdedc7fb1..7e5cfc5f38d 100644 --- a/extension/src/test-e2e/appHostTree.e2e.test.ts +++ b/extension/src/test-e2e/appHostTree.e2e.test.ts @@ -1,14 +1,27 @@ import * as assert from 'assert'; import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { executeE2eControlCommand, readClipboardForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, writeClipboardForE2E } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForChildTreeItem, waitForNotificationMessage, waitForTreeItem } from './helpers/vscode'; suite('Aspire AppHost tree E2E', function () { this.timeout(240000); + let clipboardTextToRestore: string | undefined; + + async function restoreClipboardIfNeeded(): Promise { + if (clipboardTextToRestore === undefined) { + return; + } + + const clipboardText = clipboardTextToRestore; + await writeClipboardForE2E(clipboardText); + clipboardTextToRestore = undefined; + } + teardown(async () => { await runE2eTeardown([ + () => restoreClipboardIfNeeded(), () => setCliUnavailableForE2E(false), () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreWorkspaceCliPath(), @@ -34,6 +47,7 @@ suite('Aspire AppHost tree E2E', function () { test('clicking the Path tree item copies the AppHost path and shows a confirmation notification', async () => { await openAspireView(); await waitForRepositoryIdle(); + clipboardTextToRestore = await readClipboardForE2E(); const discovered = await waitForWorkspaceAppHost(); const appHostLabel = getTreeAppHostLabel(discovered.state); const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); @@ -54,8 +68,8 @@ suite('Aspire AppHost tree E2E', function () { // routed through aspire-vscode.copyAppHostPath rather than reading a stale clipboard value. await waitForNotificationMessage('AppHost path copied to clipboard.'); - const clipboard = await executeE2eControlCommand({ name: 'readClipboard' }); - assert.ok(isSamePath(String(clipboard.result), appHostPath), `Expected clipboard '${clipboard.result}' to match AppHost path '${appHostPath}'.`); + const clipboard = await readClipboardForE2E(); + assert.ok(isSamePath(clipboard, appHostPath), `Expected clipboard '${clipboard}' to match AppHost path '${appHostPath}'.`); }); test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => { diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index e8a51730c2c..c9fcde6d010 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -75,6 +75,19 @@ export async function executeE2eControlCommand( return await applyE2eControl({ command }, options?.waitFor ?? 'applied', timeoutMs); } +export async function readClipboardForE2E(): Promise { + const clipboard = await executeE2eControlCommand({ name: 'readClipboard' }); + if (typeof clipboard.result !== 'string') { + throw new Error(`Expected E2E clipboard read to return a string, but got ${typeof clipboard.result}.`); + } + + return clipboard.result; +} + +export async function writeClipboardForE2E(text: string): Promise { + await executeE2eControlCommand({ name: 'writeClipboard', text }); +} + 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/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index b55fcaec2c0..cc946164836 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -2875,6 +2875,7 @@ suite('copyAppHostPath', () => { }); 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 = { @@ -2890,31 +2891,46 @@ suite('copyAppHostPath', () => { 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. - await vscode.env.clipboard.writeText('sentinel-before-copy'); - const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); + 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); + 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); - provider.dispose(); + 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([]); - 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); + 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); + 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); - provider.dispose(); + assert.strictEqual(await vscode.env.clipboard.readText(), 'sentinel-unchanged'); + assert.strictEqual(infoStub.callCount, 0); + assert.ok(warningStub.calledOnce); + } finally { + try { + await vscode.env.clipboard.writeText(previousClipboard); + } finally { + provider.dispose(); + } + } }); }); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 190ec6b15c9..289b294c9bd 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -549,6 +549,11 @@ async function executeE2eControlCommand( markStarted(); return await vscode.env.clipboard.readText(); } + case 'writeClipboard': { + markStarted(); + await vscode.env.clipboard.writeText(command.text); + return undefined; + } case 'openWorkspaceFolder': { const folderPath = getE2eWorkspaceFolderPath(command.folderPath); markStarted(); diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index d4d4040de57..fa294806278 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -189,6 +189,7 @@ export type AspireExtensionE2EControlCommand = | { name: 'getExtensionFileStatus'; relativePaths: readonly string[] } | { name: 'getDiagnostics'; filePath: string } | { name: 'readClipboard' } + | { name: 'writeClipboard'; text: string } | { name: 'openWorkspaceFolder'; folderPath: string } | { name: 'getWorkspaceFolders' } | { name: 'getActiveEditor' } From 137f8e5a6d77f2f693003df0c7b54572023e4704 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 15:49:37 -0600 Subject: [PATCH 3/8] Keep E2E clipboard snapshots in memory Avoid sending clipboard snapshot contents through the VS Code extension E2E state/control JSON bridge. Snapshot and restore the clipboard in the extension host, and assert copied clipboard values in-host instead of returning raw clipboard contents. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test-e2e/appHostTree.e2e.test.ts | 21 ++---- extension/src/test-e2e/helpers/fixtures.ts | 15 ++-- .../src/test-e2e/treeActions.e2e.test.ts | 6 +- extension/src/test/e2eLaunchProfile.test.ts | 31 +++++++++ extension/src/testing/e2eStateFileBridge.ts | 68 ++++++++++++++++--- extension/src/types/extensionApi.ts | 5 +- 6 files changed, 109 insertions(+), 37 deletions(-) diff --git a/extension/src/test-e2e/appHostTree.e2e.test.ts b/extension/src/test-e2e/appHostTree.e2e.test.ts index 7e5cfc5f38d..f14e181cf6b 100644 --- a/extension/src/test-e2e/appHostTree.e2e.test.ts +++ b/extension/src/test-e2e/appHostTree.e2e.test.ts @@ -1,27 +1,15 @@ import * as assert from 'assert'; import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { executeE2eControlCommand, readClipboardForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, writeClipboardForE2E } from './helpers/fixtures'; +import { assertClipboardTextForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForChildTreeItem, waitForNotificationMessage, waitForTreeItem } from './helpers/vscode'; suite('Aspire AppHost tree E2E', function () { this.timeout(240000); - let clipboardTextToRestore: string | undefined; - - async function restoreClipboardIfNeeded(): Promise { - if (clipboardTextToRestore === undefined) { - return; - } - - const clipboardText = clipboardTextToRestore; - await writeClipboardForE2E(clipboardText); - clipboardTextToRestore = undefined; - } - teardown(async () => { await runE2eTeardown([ - () => restoreClipboardIfNeeded(), + () => restoreClipboardSnapshotForE2E(), () => setCliUnavailableForE2E(false), () => setTerminalCommandExecutionSuppressedForE2E(false), () => restoreWorkspaceCliPath(), @@ -47,7 +35,7 @@ suite('Aspire AppHost tree E2E', function () { test('clicking the Path tree item copies the AppHost path and shows a confirmation notification', async () => { await openAspireView(); await waitForRepositoryIdle(); - clipboardTextToRestore = await readClipboardForE2E(); + await snapshotClipboardForE2E(); const discovered = await waitForWorkspaceAppHost(); const appHostLabel = getTreeAppHostLabel(discovered.state); const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); @@ -68,8 +56,7 @@ suite('Aspire AppHost tree E2E', function () { // routed through aspire-vscode.copyAppHostPath rather than reading a stale clipboard value. await waitForNotificationMessage('AppHost path copied to clipboard.'); - const clipboard = await readClipboardForE2E(); - assert.ok(isSamePath(clipboard, appHostPath), `Expected clipboard '${clipboard}' to match AppHost path '${appHostPath}'.`); + await assertClipboardTextForE2E(appHostPath, 'path'); }); test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => { diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index c9fcde6d010..9606675a930 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -75,17 +75,16 @@ export async function executeE2eControlCommand( return await applyE2eControl({ command }, options?.waitFor ?? 'applied', timeoutMs); } -export async function readClipboardForE2E(): Promise { - const clipboard = await executeE2eControlCommand({ name: 'readClipboard' }); - if (typeof clipboard.result !== 'string') { - throw new Error(`Expected E2E clipboard read to return a string, but got ${typeof clipboard.result}.`); - } +export async function snapshotClipboardForE2E(): Promise { + await executeE2eControlCommand({ name: 'snapshotClipboard' }); +} - return clipboard.result; +export async function restoreClipboardSnapshotForE2E(): Promise { + await executeE2eControlCommand({ name: 'restoreClipboardSnapshot' }); } -export async function writeClipboardForE2E(text: string): Promise { - await executeE2eControlCommand({ name: 'writeClipboard', text }); +export async function assertClipboardTextForE2E(expectedText: string, comparison: 'exact' | 'path' = 'exact'): Promise { + await executeE2eControlCommand({ name: 'assertClipboardText', expectedText, comparison }); } export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise>, failureMessage: string): Promise { diff --git a/extension/src/test-e2e/treeActions.e2e.test.ts b/extension/src/test-e2e/treeActions.e2e.test.ts index 6f721e7cb05..b1f79e324be 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 { assertClipboardTextForE2E, executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { answerActiveInput, chooseActiveQuickPick, getActiveQuickPickLabels, openAspireView, waitForChildTreeItem, waitForTreeItem, waitForWorkbenchTextAfterIntegratedBrowserNavigation } from './helpers/vscode'; @@ -82,6 +82,7 @@ suite('Aspire tree action command E2E', function () { const copiedAppHost = await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); assert.ok(isSamePath(String(copiedAppHost.result), appHostPath)); + await assertClipboardTextForE2E(appHostPath, 'path'); const openedSource = await executeE2eControlCommand({ name: 'openAppHostSource', appHostPath }); assert.ok(String((openedSource.result as { fileName?: string }).fileName).endsWith(path.join('AspireE2E.AppHost', 'AppHost.cs'))); @@ -91,10 +92,12 @@ suite('Aspire tree action command E2E', function () { const copiedResourceName = await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' }); assert.strictEqual(copiedResourceName.result, 'e2e-worker'); + await assertClipboardTextForE2E('e2e-worker'); const copiedEndpointUrl = await executeE2eControlCommand({ name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker' }); const endpointUrl = String(copiedEndpointUrl.result); assert.ok(endpointUrl.startsWith('http')); + await assertClipboardTextForE2E(endpointUrl); before = getCommandInvocationCount('aspire-vscode.openInIntegratedBrowser'); const openedEndpoint = await executeE2eControlCommand({ name: 'openInIntegratedBrowser', appHostPath, resourceName: 'e2e-worker' }); @@ -109,6 +112,7 @@ suite('Aspire tree action command E2E', function () { const copiedLogPath = await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath }); assert.ok(path.isAbsolute(String(copiedLogPath.result))); + await assertClipboardTextForE2E(String(copiedLogPath.result), 'path'); let terminalBefore: number; diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 26b5e86caf2..c60cd6cd6c0 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -272,6 +272,37 @@ 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'); + + assert.ok(apiTypes.includes("{ name: 'snapshotClipboard' }")); + assert.ok(apiTypes.includes("{ name: 'restoreClipboardSnapshot' }")); + assert.ok(apiTypes.includes("{ name: 'assertClipboardText'; expectedText: string; comparison?: 'exact' | 'path' }")); + 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 'assertClipboardText':")); + 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('assertClipboardTextForE2E')); + 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 assertClipboardTextForE2E(appHostPath, 'path');")); + assert.ok(!appHostTreeE2E.includes('clipboardTextToRestore')); + }); + 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'); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 289b294c9bd..0caad6fc682 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -40,6 +40,7 @@ export function createE2eStateFileBridge( const debugLaunches: AspireExtensionE2EDebugLaunch[] = []; const debugConsoleOutputs: AspireExtensionE2EDebugConsoleOutput[] = []; const stoppingPathEvents: AspireExtensionE2EStoppingPathEvent[] = []; + const clipboardSnapshot: E2eClipboardSnapshot = { hasSnapshot: false }; let commandInvocationSequence = 0; let terminalCommandSequence = 0; let debugLaunchSequence = 0; @@ -177,7 +178,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, payload.command, markCommandStarted); controlStatus = { revision, status: 'applied', startedObserved: commandStarted, result }; } else { @@ -281,6 +282,7 @@ async function executeE2eControlCommand( appHostLaunchService: AppHostLaunchService, appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider, + clipboardSnapshot: E2eClipboardSnapshot, command: AspireExtensionE2EControlCommand, markStarted: () => void ): Promise { @@ -354,10 +356,11 @@ async function executeE2eControlCommand( } case 'copyAppHostPath': { const element = getAppHostElement(appHostTreeProvider, command.appHostPath); + const copiedPath = getElementStringProperty(element, 'appHostPath') ?? command.appHostPath; const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyAppHostPath', element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + return copiedPath; } case 'viewAppHostLogFile': { const element = getLogFileElement(appHostTreeProvider, command.appHostPath); @@ -368,10 +371,11 @@ async function executeE2eControlCommand( } case 'copyLogFilePath': { const element = getLogFileElement(appHostTreeProvider, command.appHostPath); + const logFilePath = getRequiredElementStringProperty(element, 'logFilePath', 'Aspire extension E2E log file command found a tree item without a log file path.'); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyLogFilePath', element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + return logFilePath; } case 'viewResourceLogs': { const element = getResourceElement(appHostTreeProvider, command.resourceName, command.appHostPath); @@ -390,14 +394,14 @@ async function executeE2eControlCommand( const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyResourceName', element); markStarted(); await commandPromise; - return await vscode.env.clipboard.readText(); + return command.resourceName; } 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(); + return endpoint.url; } case 'openInIntegratedBrowser': { const endpoint = getEndpointElement(appHostTreeProvider, command); @@ -545,13 +549,36 @@ async function executeE2eControlCommand( markStarted(); return await getDiagnosticsForFile(command.filePath); } - case 'readClipboard': { + case 'snapshotClipboard': { markStarted(); - return await vscode.env.clipboard.readText(); + // 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 'writeClipboard': { + case 'restoreClipboardSnapshot': { markStarted(); - await vscode.env.clipboard.writeText(command.text); + if (clipboardSnapshot.hasSnapshot) { + await vscode.env.clipboard.writeText(clipboardSnapshot.text ?? ''); + clipboardSnapshot.text = undefined; + clipboardSnapshot.hasSnapshot = false; + } + + return undefined; + } + case 'assertClipboardText': { + markStarted(); + const clipboardText = await vscode.env.clipboard.readText(); + const matches = command.comparison === 'path' + ? isSamePath(clipboardText, command.expectedText) + : clipboardText === command.expectedText; + if (!matches) { + throw new Error(command.comparison === 'path' + ? 'E2E clipboard path did not match the expected path.' + : 'E2E clipboard text did not match the expected text.'); + } + return undefined; } case 'openWorkspaceFolder': { @@ -578,6 +605,11 @@ async function executeE2eControlCommand( } } +interface E2eClipboardSnapshot { + text?: string; + hasSnapshot: boolean; +} + 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.'); @@ -1249,6 +1281,24 @@ function getLogFileElement(appHostTreeProvider: AspireAppHostTreeProvider, appHo return element; } +function getRequiredElementStringProperty(element: unknown, propertyName: string, errorMessage: string): string { + const value = getElementStringProperty(element, propertyName); + if (value === undefined) { + throw new Error(errorMessage); + } + + return value; +} + +function getElementStringProperty(element: unknown, propertyName: string): string | undefined { + if (!element || typeof element !== 'object' || !(propertyName in element)) { + return undefined; + } + + const value = (element as Record)[propertyName]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + 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 fa294806278..81c3476ac5f 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -188,8 +188,9 @@ export type AspireExtensionE2EControlCommand = | { name: 'getExtensionPackageJson' } | { name: 'getExtensionFileStatus'; relativePaths: readonly string[] } | { name: 'getDiagnostics'; filePath: string } - | { name: 'readClipboard' } - | { name: 'writeClipboard'; text: string } + | { name: 'snapshotClipboard' } + | { name: 'restoreClipboardSnapshot' } + | { name: 'assertClipboardText'; expectedText: string; comparison?: 'exact' | 'path' } | { name: 'openWorkspaceFolder'; folderPath: string } | { name: 'getWorkspaceFolders' } | { name: 'getActiveEditor' } From 3890fd76a05d1e70c4c1f5ddc06596590edcc5b8 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 16:24:15 -0600 Subject: [PATCH 4/8] Avoid E2E copy command result leaks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test-e2e/treeActions.e2e.test.ts | 18 ++++----- extension/src/test/e2eLaunchProfile.test.ts | 38 +++++++++++++++++++ extension/src/testing/e2eStateFileBridge.ts | 28 ++------------ 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/extension/src/test-e2e/treeActions.e2e.test.ts b/extension/src/test-e2e/treeActions.e2e.test.ts index b1f79e324be..cdae8223b0f 100644 --- a/extension/src/test-e2e/treeActions.e2e.test.ts +++ b/extension/src/test-e2e/treeActions.e2e.test.ts @@ -1,6 +1,6 @@ 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 { findResource, getCommandInvocationCount, getTerminalCommandCount, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; import { assertClipboardTextForE2E, executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { answerActiveInput, chooseActiveQuickPick, getActiveQuickPickLabels, openAspireView, waitForChildTreeItem, waitForTreeItem, waitForWorkbenchTextAfterIntegratedBrowserNavigation } from './helpers/vscode'; @@ -80,8 +80,7 @@ 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 executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); await assertClipboardTextForE2E(appHostPath, 'path'); const openedSource = await executeE2eControlCommand({ name: 'openAppHostSource', appHostPath }); @@ -90,13 +89,13 @@ 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 assertClipboardTextForE2E('e2e-worker'); - 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', url: endpointUrl }); await assertClipboardTextForE2E(endpointUrl); before = getCommandInvocationCount('aspire-vscode.openInIntegratedBrowser'); @@ -110,9 +109,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 assertClipboardTextForE2E(String(copiedLogPath.result), 'path'); + await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath }); + await assertClipboardTextForE2E(viewedLogFileName, 'path'); let terminalBefore: number; diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index c60cd6cd6c0..2e7121a8b38 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -303,6 +303,34 @@ suite('E2E launch profile', () => { assert.ok(!appHostTreeE2E.includes('clipboardTextToRestore')); }); + test('keeps copied values out of E2E control command results', () => { + const extensionRoot = path.resolve(__dirname, '..', '..'); + const e2eStateFileBridge = fs.readFileSync(path.join(extensionRoot, 'src', 'testing', 'e2eStateFileBridge.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(!treeActionsE2E.includes('copiedAppHost.result')); + assert.ok(!treeActionsE2E.includes('copiedResourceName.result')); + assert.ok(!treeActionsE2E.includes('copiedEndpointUrl.result')); + assert.ok(!treeActionsE2E.includes('copiedLogPath.result')); + }); + 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'); @@ -527,3 +555,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/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 0caad6fc682..2c05887b537 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -356,11 +356,10 @@ async function executeE2eControlCommand( } case 'copyAppHostPath': { const element = getAppHostElement(appHostTreeProvider, command.appHostPath); - const copiedPath = getElementStringProperty(element, 'appHostPath') ?? command.appHostPath; const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyAppHostPath', element); markStarted(); await commandPromise; - return copiedPath; + return undefined; } case 'viewAppHostLogFile': { const element = getLogFileElement(appHostTreeProvider, command.appHostPath); @@ -371,11 +370,10 @@ async function executeE2eControlCommand( } case 'copyLogFilePath': { const element = getLogFileElement(appHostTreeProvider, command.appHostPath); - const logFilePath = getRequiredElementStringProperty(element, 'logFilePath', 'Aspire extension E2E log file command found a tree item without a log file path.'); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyLogFilePath', element); markStarted(); await commandPromise; - return logFilePath; + return undefined; } case 'viewResourceLogs': { const element = getResourceElement(appHostTreeProvider, command.resourceName, command.appHostPath); @@ -394,14 +392,14 @@ async function executeE2eControlCommand( const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyResourceName', element); markStarted(); await commandPromise; - return command.resourceName; + return undefined; } case 'copyEndpointUrl': { const endpoint = getEndpointElement(appHostTreeProvider, command); const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyEndpointUrl', endpoint.element); markStarted(); await commandPromise; - return endpoint.url; + return undefined; } case 'openInIntegratedBrowser': { const endpoint = getEndpointElement(appHostTreeProvider, command); @@ -1281,24 +1279,6 @@ function getLogFileElement(appHostTreeProvider: AspireAppHostTreeProvider, appHo return element; } -function getRequiredElementStringProperty(element: unknown, propertyName: string, errorMessage: string): string { - const value = getElementStringProperty(element, propertyName); - if (value === undefined) { - throw new Error(errorMessage); - } - - return value; -} - -function getElementStringProperty(element: unknown, propertyName: string): string | undefined { - if (!element || typeof element !== 'object' || !(propertyName in element)) { - return undefined; - } - - const value = (element as Record)[propertyName]; - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - function getActiveEditorInfo(): { uri?: string; fileName?: string; text?: string } { const document = vscode.window.activeTextEditor?.document; return { From 18e590e7e9e76e7591833b8af751c5f66b441e40 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 16:30:30 -0600 Subject: [PATCH 5/8] Localize AppHost path copy messages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/package.nls.json | 2 ++ extension/src/loc/strings.ts | 1 + extension/src/test/appHostTreeView.test.ts | 3 ++- extension/src/test/strings.test.ts | 19 +++++++++++++++++++ .../src/views/AspireAppHostTreeProvider.ts | 3 ++- 5 files changed, 26 insertions(+), 2 deletions(-) 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 5377a9e48ff..caf9eab2441 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -122,6 +122,7 @@ 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/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index cc946164836..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, appHostPathCopiedToClipboard } from '../loc/strings'; +import { terminalCommandArgumentControlCharacters, appHostPathCopiedToClipboard, appHostPathInvalid } from '../loc/strings'; import { onDidInvokeCommand, withCommandTelemetry } from '../utils/telemetry'; function makeResource(overrides: Partial = {}): ResourceJson { @@ -2924,6 +2924,7 @@ suite('copyAppHostPath', () => { 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); 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/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index cfa61694cb4..19ee9968529 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -19,6 +19,7 @@ import { appHostDebugActionLabel, appHostPathLabel, appHostPathCopiedToClipboard, + appHostPathInvalid, resourceCountDescription, tooltipType, tooltipState, @@ -1552,7 +1553,7 @@ 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); From cf6ccc3bc7a387ba33557158a0de2ac8fe3f797f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 16:59:38 -0600 Subject: [PATCH 6/8] Restore clipboard after tree action E2E copies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extension/src/test-e2e/treeActions.e2e.test.ts | 4 +++- extension/src/test/e2eLaunchProfile.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/extension/src/test-e2e/treeActions.e2e.test.ts b/extension/src/test-e2e/treeActions.e2e.test.ts index cdae8223b0f..41859b5db86 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, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { assertClipboardTextForE2E, executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { assertClipboardTextForE2E, 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,6 +81,7 @@ suite('Aspire tree action command E2E', function () { await noCommandsResource.expand(); assert.strictEqual(await noCommandsResource.findChildItem('Commands'), undefined); + await snapshotClipboardForE2E(); await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); await assertClipboardTextForE2E(appHostPath, 'path'); diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 2e7121a8b38..b0cd182cde9 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -278,6 +278,7 @@ suite('E2E launch profile', () => { 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' }")); @@ -301,6 +302,11 @@ suite('E2E launch profile', () => { assert.ok(appHostTreeE2E.includes('restoreClipboardSnapshotForE2E')); assert.ok(appHostTreeE2E.includes("await assertClipboardTextForE2E(appHostPath, 'path');")); 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', () => { From a6337186feb550bac2bbe7d16ff35271b912325b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 17:12:43 -0600 Subject: [PATCH 7/8] Keep clipboard assertions out of E2E control payloads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test-e2e/appHostTree.e2e.test.ts | 5 +- extension/src/test-e2e/helpers/fixtures.ts | 16 +++- .../src/test-e2e/treeActions.e2e.test.ts | 12 +-- extension/src/test/e2eLaunchProfile.test.ts | 23 ++++- extension/src/testing/e2eStateFileBridge.ts | 87 ++++++++++++++++--- extension/src/types/extensionApi.ts | 5 +- 6 files changed, 121 insertions(+), 27 deletions(-) diff --git a/extension/src/test-e2e/appHostTree.e2e.test.ts b/extension/src/test-e2e/appHostTree.e2e.test.ts index f14e181cf6b..6ef494860d0 100644 --- a/extension/src/test-e2e/appHostTree.e2e.test.ts +++ b/extension/src/test-e2e/appHostTree.e2e.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { assertClipboardTextForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { assertClipboardMatchesWorkspaceAppHostPathForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; import { getPrimaryAppHostProjectPath } from './helpers/paths'; import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForChildTreeItem, waitForNotificationMessage, waitForTreeItem } from './helpers/vscode'; @@ -38,7 +38,6 @@ suite('Aspire AppHost tree E2E', function () { await snapshotClipboardForE2E(); const discovered = await waitForWorkspaceAppHost(); const appHostLabel = getTreeAppHostLabel(discovered.state); - const appHostPath = discovered.state.workspaceAppHostPath ?? getPrimaryAppHostProjectPath(); const section = await openAspireView(); // The Path row only appears under an idle (non-running) workspace AppHost item, so exercise @@ -56,7 +55,7 @@ suite('Aspire AppHost tree E2E', function () { // routed through aspire-vscode.copyAppHostPath rather than reading a stale clipboard value. await waitForNotificationMessage('AppHost path copied to clipboard.'); - await assertClipboardTextForE2E(appHostPath, 'path'); + await assertClipboardMatchesWorkspaceAppHostPathForE2E(); }); test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => { diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index 9606675a930..ca17d79d031 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -83,8 +83,20 @@ export async function restoreClipboardSnapshotForE2E(): Promise { await executeE2eControlCommand({ name: 'restoreClipboardSnapshot' }); } -export async function assertClipboardTextForE2E(expectedText: string, comparison: 'exact' | 'path' = 'exact'): Promise { - await executeE2eControlCommand({ name: 'assertClipboardText', expectedText, comparison }); +export async function assertClipboardMatchesWorkspaceAppHostPathForE2E(): Promise { + await executeE2eControlCommand({ name: 'assertClipboardMatchesWorkspaceAppHostPath' }); +} + +export async function assertClipboardMatchesResourceNameForE2E(resourceName: string, appHostPath?: string): Promise { + await executeE2eControlCommand({ name: 'assertClipboardMatchesResourceName', appHostPath, resourceName }); +} + +export async function assertClipboardMatchesEndpointUrlForE2E(options: { appHostPath?: string; resourceName?: string }): Promise { + await executeE2eControlCommand({ name: 'assertClipboardMatchesEndpointUrl', ...options }); +} + +export async function assertClipboardMatchesLogFilePathForE2E(appHostPath?: string): Promise { + await executeE2eControlCommand({ name: 'assertClipboardMatchesLogFilePath', appHostPath }); } export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise>, failureMessage: string): Promise { diff --git a/extension/src/test-e2e/treeActions.e2e.test.ts b/extension/src/test-e2e/treeActions.e2e.test.ts index 41859b5db86..fba6766e029 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, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { assertClipboardTextForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +import { assertClipboardMatchesEndpointUrlForE2E, assertClipboardMatchesLogFilePathForE2E, assertClipboardMatchesResourceNameForE2E, assertClipboardMatchesWorkspaceAppHostPathForE2E, 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'; @@ -83,7 +83,7 @@ suite('Aspire tree action command E2E', function () { await snapshotClipboardForE2E(); await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); - await assertClipboardTextForE2E(appHostPath, 'path'); + await assertClipboardMatchesWorkspaceAppHostPathForE2E(); const openedSource = await executeE2eControlCommand({ name: 'openAppHostSource', appHostPath }); assert.ok(String((openedSource.result as { fileName?: string }).fileName).endsWith(path.join('AspireE2E.AppHost', 'AppHost.cs'))); @@ -92,13 +92,13 @@ suite('Aspire tree action command E2E', function () { assert.ok(String((viewedSource.result as { uri?: string }).uri).startsWith('aspire-source:')); await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' }); - await assertClipboardTextForE2E('e2e-worker'); + await assertClipboardMatchesResourceNameForE2E('e2e-worker', appHostPath); 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', url: endpointUrl }); - await assertClipboardTextForE2E(endpointUrl); + await executeE2eControlCommand({ name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker' }); + await assertClipboardMatchesEndpointUrlForE2E({ appHostPath, resourceName: 'e2e-worker' }); before = getCommandInvocationCount('aspire-vscode.openInIntegratedBrowser'); const openedEndpoint = await executeE2eControlCommand({ name: 'openInIntegratedBrowser', appHostPath, resourceName: 'e2e-worker' }); @@ -112,7 +112,7 @@ suite('Aspire tree action command E2E', function () { assert.ok(viewedLogFileName && path.isAbsolute(viewedLogFileName)); await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath }); - await assertClipboardTextForE2E(viewedLogFileName, 'path'); + await assertClipboardMatchesLogFilePathForE2E(appHostPath); let terminalBefore: number; diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index b0cd182cde9..60bfd497fbe 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -282,25 +282,34 @@ suite('E2E launch profile', () => { assert.ok(apiTypes.includes("{ name: 'snapshotClipboard' }")); assert.ok(apiTypes.includes("{ name: 'restoreClipboardSnapshot' }")); - assert.ok(apiTypes.includes("{ name: 'assertClipboardText'; expectedText: string; comparison?: 'exact' | 'path' }")); + assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesWorkspaceAppHostPath' }")); + assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesResourceName'; appHostPath?: string; resourceName: string }")); + assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesEndpointUrl'; appHostPath?: string; resourceName?: string }")); + assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesLogFilePath'; appHostPath?: string }")); 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 'assertClipboardText':")); + assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesWorkspaceAppHostPath':")); + assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesResourceName':")); + assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesEndpointUrl':")); + assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesLogFilePath':")); 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('assertClipboardTextForE2E')); + assert.ok(fixtures.includes('assertClipboardMatchesWorkspaceAppHostPathForE2E')); + assert.ok(fixtures.includes('assertClipboardMatchesResourceNameForE2E')); + assert.ok(fixtures.includes('assertClipboardMatchesEndpointUrlForE2E')); + assert.ok(fixtures.includes('assertClipboardMatchesLogFilePathForE2E')); 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 assertClipboardTextForE2E(appHostPath, 'path');")); + assert.ok(appHostTreeE2E.includes('await assertClipboardMatchesWorkspaceAppHostPathForE2E();')); assert.ok(!appHostTreeE2E.includes('clipboardTextToRestore')); assert.ok(treeActionsE2E.includes('snapshotClipboardForE2E')); @@ -311,7 +320,9 @@ suite('E2E launch profile', () => { 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'); @@ -330,6 +341,10 @@ suite('E2E launch profile', () => { 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')); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 2c05887b537..1f3036ffd21 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -565,18 +565,34 @@ async function executeE2eControlCommand( return undefined; } - case 'assertClipboardText': { + case 'assertClipboardMatchesWorkspaceAppHostPath': { markStarted(); - const clipboardText = await vscode.env.clipboard.readText(); - const matches = command.comparison === 'path' - ? isSamePath(clipboardText, command.expectedText) - : clipboardText === command.expectedText; - if (!matches) { - throw new Error(command.comparison === 'path' - ? 'E2E clipboard path did not match the expected path.' - : 'E2E clipboard text did not match the expected text.'); + const state = createStateSnapshot(dataRepository, appHostLaunchService, appHostTreeProvider, aspireContext, true); + if (!state.workspaceAppHostPath) { + throw new Error('E2E clipboard assertion could not determine the workspace AppHost path.'); } + await assertClipboardText(state.workspaceAppHostPath, 'path'); + return undefined; + } + case 'assertClipboardMatchesResourceName': { + markStarted(); + const element = getResourceElement(appHostTreeProvider, command.resourceName, command.appHostPath); + const resourceName = getResourceNameForClipboard(element); + await assertClipboardText(resourceName); + return undefined; + } + case 'assertClipboardMatchesEndpointUrl': { + markStarted(); + const endpoint = getEndpointElement(appHostTreeProvider, command); + await assertClipboardText(endpoint.url); + return undefined; + } + case 'assertClipboardMatchesLogFilePath': { + markStarted(); + const element = getLogFileElement(appHostTreeProvider, command.appHostPath); + const logFilePath = getLogFilePathForClipboard(element); + await assertClipboardText(logFilePath, 'path'); return undefined; } case 'openWorkspaceFolder': { @@ -608,6 +624,18 @@ interface E2eClipboardSnapshot { hasSnapshot: boolean; } +async function assertClipboardText(expectedText: string, comparison: 'exact' | 'path' = 'exact'): Promise { + 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.'); @@ -1192,12 +1220,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.'); @@ -1218,6 +1246,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 @@ -1279,6 +1328,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 81c3476ac5f..931bd57ff4e 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -190,7 +190,10 @@ export type AspireExtensionE2EControlCommand = | { name: 'getDiagnostics'; filePath: string } | { name: 'snapshotClipboard' } | { name: 'restoreClipboardSnapshot' } - | { name: 'assertClipboardText'; expectedText: string; comparison?: 'exact' | 'path' } + | { name: 'assertClipboardMatchesWorkspaceAppHostPath' } + | { name: 'assertClipboardMatchesResourceName'; appHostPath?: string; resourceName: string } + | { name: 'assertClipboardMatchesEndpointUrl'; appHostPath?: string; resourceName?: string } + | { name: 'assertClipboardMatchesLogFilePath'; appHostPath?: string } | { name: 'openWorkspaceFolder'; folderPath: string } | { name: 'getWorkspaceFolders' } | { name: 'getActiveEditor' } From 6b622243413a8851eb1185a58ff20381ef11583a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 2 Jul 2026 17:24:58 -0600 Subject: [PATCH 8/8] Capture E2E clipboard expectations in memory Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test-e2e/appHostTree.e2e.test.ts | 5 +- extension/src/test-e2e/helpers/fixtures.ts | 16 +--- .../src/test-e2e/treeActions.e2e.test.ts | 10 +-- extension/src/test/e2eLaunchProfile.test.ts | 43 ++++++--- extension/src/testing/e2eStateFileBridge.ts | 88 ++++++++++++++----- extension/src/types/extensionApi.ts | 6 +- 6 files changed, 111 insertions(+), 57 deletions(-) diff --git a/extension/src/test-e2e/appHostTree.e2e.test.ts b/extension/src/test-e2e/appHostTree.e2e.test.ts index 6ef494860d0..f2afdab8c0c 100644 --- a/extension/src/test-e2e/appHostTree.e2e.test.ts +++ b/extension/src/test-e2e/appHostTree.e2e.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { assertClipboardMatchesWorkspaceAppHostPathForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +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, waitForChildTreeItem, waitForNotificationMessage, waitForTreeItem } from './helpers/vscode'; @@ -49,13 +49,14 @@ suite('Aspire AppHost tree E2E', function () { // 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 assertClipboardMatchesWorkspaceAppHostPathForE2E(); + await assertClipboardMatchesLastExpectationForE2E(); }); test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => { diff --git a/extension/src/test-e2e/helpers/fixtures.ts b/extension/src/test-e2e/helpers/fixtures.ts index ca17d79d031..848aaa52560 100644 --- a/extension/src/test-e2e/helpers/fixtures.ts +++ b/extension/src/test-e2e/helpers/fixtures.ts @@ -83,20 +83,12 @@ export async function restoreClipboardSnapshotForE2E(): Promise { await executeE2eControlCommand({ name: 'restoreClipboardSnapshot' }); } -export async function assertClipboardMatchesWorkspaceAppHostPathForE2E(): Promise { - await executeE2eControlCommand({ name: 'assertClipboardMatchesWorkspaceAppHostPath' }); +export async function captureWorkspaceAppHostPathClipboardExpectationForE2E(): Promise { + await executeE2eControlCommand({ name: 'captureWorkspaceAppHostPathClipboardExpectation' }); } -export async function assertClipboardMatchesResourceNameForE2E(resourceName: string, appHostPath?: string): Promise { - await executeE2eControlCommand({ name: 'assertClipboardMatchesResourceName', appHostPath, resourceName }); -} - -export async function assertClipboardMatchesEndpointUrlForE2E(options: { appHostPath?: string; resourceName?: string }): Promise { - await executeE2eControlCommand({ name: 'assertClipboardMatchesEndpointUrl', ...options }); -} - -export async function assertClipboardMatchesLogFilePathForE2E(appHostPath?: string): Promise { - await executeE2eControlCommand({ name: 'assertClipboardMatchesLogFilePath', appHostPath }); +export async function assertClipboardMatchesLastExpectationForE2E(): Promise { + await executeE2eControlCommand({ name: 'assertClipboardMatchesLastExpectation' }); } export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise>, failureMessage: string): Promise { diff --git a/extension/src/test-e2e/treeActions.e2e.test.ts b/extension/src/test-e2e/treeActions.e2e.test.ts index fba6766e029..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, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions'; -import { assertClipboardMatchesEndpointUrlForE2E, assertClipboardMatchesLogFilePathForE2E, assertClipboardMatchesResourceNameForE2E, assertClipboardMatchesWorkspaceAppHostPathForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures'; +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'; @@ -83,7 +83,7 @@ suite('Aspire tree action command E2E', function () { await snapshotClipboardForE2E(); await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath }); - await assertClipboardMatchesWorkspaceAppHostPathForE2E(); + 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'))); @@ -92,13 +92,13 @@ suite('Aspire tree action command E2E', function () { assert.ok(String((viewedSource.result as { uri?: string }).uri).startsWith('aspire-source:')); await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' }); - await assertClipboardMatchesResourceNameForE2E('e2e-worker', appHostPath); + await assertClipboardMatchesLastExpectationForE2E(); 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 assertClipboardMatchesEndpointUrlForE2E({ appHostPath, resourceName: 'e2e-worker' }); + await assertClipboardMatchesLastExpectationForE2E(); before = getCommandInvocationCount('aspire-vscode.openInIntegratedBrowser'); const openedEndpoint = await executeE2eControlCommand({ name: 'openInIntegratedBrowser', appHostPath, resourceName: 'e2e-worker' }); @@ -112,7 +112,7 @@ suite('Aspire tree action command E2E', function () { assert.ok(viewedLogFileName && path.isAbsolute(viewedLogFileName)); await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath }); - await assertClipboardMatchesLogFilePathForE2E(appHostPath); + await assertClipboardMatchesLastExpectationForE2E(); let terminalBefore: number; diff --git a/extension/src/test/e2eLaunchProfile.test.ts b/extension/src/test/e2eLaunchProfile.test.ts index 60bfd497fbe..7c8cb42d521 100644 --- a/extension/src/test/e2eLaunchProfile.test.ts +++ b/extension/src/test/e2eLaunchProfile.test.ts @@ -282,34 +282,29 @@ suite('E2E launch profile', () => { assert.ok(apiTypes.includes("{ name: 'snapshotClipboard' }")); assert.ok(apiTypes.includes("{ name: 'restoreClipboardSnapshot' }")); - assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesWorkspaceAppHostPath' }")); - assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesResourceName'; appHostPath?: string; resourceName: string }")); - assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesEndpointUrl'; appHostPath?: string; resourceName?: string }")); - assert.ok(apiTypes.includes("{ name: 'assertClipboardMatchesLogFilePath'; appHostPath?: string }")); + 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 'assertClipboardMatchesWorkspaceAppHostPath':")); - assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesResourceName':")); - assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesEndpointUrl':")); - assert.ok(e2eStateFileBridge.includes("case 'assertClipboardMatchesLogFilePath':")); + 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('assertClipboardMatchesWorkspaceAppHostPathForE2E')); - assert.ok(fixtures.includes('assertClipboardMatchesResourceNameForE2E')); - assert.ok(fixtures.includes('assertClipboardMatchesEndpointUrlForE2E')); - assert.ok(fixtures.includes('assertClipboardMatchesLogFilePathForE2E')); + 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 assertClipboardMatchesWorkspaceAppHostPathForE2E();')); + assert.ok(appHostTreeE2E.includes('await captureWorkspaceAppHostPathClipboardExpectationForE2E();')); + assert.ok(appHostTreeE2E.includes('await assertClipboardMatchesLastExpectationForE2E();')); assert.ok(!appHostTreeE2E.includes('clipboardTextToRestore')); assert.ok(treeActionsE2E.includes('snapshotClipboardForE2E')); @@ -352,6 +347,28 @@ suite('E2E launch profile', () => { 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'); diff --git a/extension/src/testing/e2eStateFileBridge.ts b/extension/src/testing/e2eStateFileBridge.ts index 1f3036ffd21..ecd8bcd331d 100644 --- a/extension/src/testing/e2eStateFileBridge.ts +++ b/extension/src/testing/e2eStateFileBridge.ts @@ -41,6 +41,7 @@ export function createE2eStateFileBridge( const debugConsoleOutputs: AspireExtensionE2EDebugConsoleOutput[] = []; const stoppingPathEvents: AspireExtensionE2EStoppingPathEvent[] = []; const clipboardSnapshot: E2eClipboardSnapshot = { hasSnapshot: false }; + const clipboardExpectation: E2eClipboardExpectation = {}; let commandInvocationSequence = 0; let terminalCommandSequence = 0; let debugLaunchSequence = 0; @@ -178,7 +179,7 @@ export function createE2eStateFileBridge( } }; - const result = await executeE2eControlCommand(context, aspireContext, dataRepository, appHostLaunchService, appHostTreeProvider, terminalProvider, clipboardSnapshot, 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 { @@ -283,6 +284,7 @@ async function executeE2eControlCommand( appHostTreeProvider: AspireAppHostTreeProvider, terminalProvider: AspireTerminalProvider, clipboardSnapshot: E2eClipboardSnapshot, + clipboardExpectation: E2eClipboardExpectation, command: AspireExtensionE2EControlCommand, markStarted: () => void ): Promise { @@ -356,9 +358,11 @@ 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; + setClipboardExpectation(clipboardExpectation, expectedClipboardText, 'path'); return undefined; } case 'viewAppHostLogFile': { @@ -370,9 +374,11 @@ 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; + setClipboardExpectation(clipboardExpectation, expectedClipboardText, 'path'); return undefined; } case 'viewResourceLogs': { @@ -389,9 +395,11 @@ 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; + setClipboardExpectation(clipboardExpectation, expectedClipboardText); return undefined; } case 'copyEndpointUrl': { @@ -399,6 +407,7 @@ async function executeE2eControlCommand( const commandPromise = vscode.commands.executeCommand('aspire-vscode.copyEndpointUrl', endpoint.element); markStarted(); await commandPromise; + setClipboardExpectation(clipboardExpectation, endpoint.url); return undefined; } case 'openInIntegratedBrowser': { @@ -565,34 +574,19 @@ async function executeE2eControlCommand( return undefined; } - case 'assertClipboardMatchesWorkspaceAppHostPath': { + case 'captureWorkspaceAppHostPathClipboardExpectation': { markStarted(); const state = createStateSnapshot(dataRepository, appHostLaunchService, appHostTreeProvider, aspireContext, true); if (!state.workspaceAppHostPath) { throw new Error('E2E clipboard assertion could not determine the workspace AppHost path.'); } - await assertClipboardText(state.workspaceAppHostPath, 'path'); + setClipboardExpectation(clipboardExpectation, state.workspaceAppHostPath, 'path'); return undefined; } - case 'assertClipboardMatchesResourceName': { + case 'assertClipboardMatchesLastExpectation': { markStarted(); - const element = getResourceElement(appHostTreeProvider, command.resourceName, command.appHostPath); - const resourceName = getResourceNameForClipboard(element); - await assertClipboardText(resourceName); - return undefined; - } - case 'assertClipboardMatchesEndpointUrl': { - markStarted(); - const endpoint = getEndpointElement(appHostTreeProvider, command); - await assertClipboardText(endpoint.url); - return undefined; - } - case 'assertClipboardMatchesLogFilePath': { - markStarted(); - const element = getLogFileElement(appHostTreeProvider, command.appHostPath); - const logFilePath = getLogFilePathForClipboard(element); - await assertClipboardText(logFilePath, 'path'); + await assertExpectedClipboardText(clipboardExpectation); return undefined; } case 'openWorkspaceFolder': { @@ -624,7 +618,26 @@ interface E2eClipboardSnapshot { hasSnapshot: boolean; } -async function assertClipboardText(expectedText: string, comparison: 'exact' | 'path' = 'exact'): Promise { +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) @@ -1205,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.'); diff --git a/extension/src/types/extensionApi.ts b/extension/src/types/extensionApi.ts index 931bd57ff4e..099386b8c30 100644 --- a/extension/src/types/extensionApi.ts +++ b/extension/src/types/extensionApi.ts @@ -190,10 +190,8 @@ export type AspireExtensionE2EControlCommand = | { name: 'getDiagnostics'; filePath: string } | { name: 'snapshotClipboard' } | { name: 'restoreClipboardSnapshot' } - | { name: 'assertClipboardMatchesWorkspaceAppHostPath' } - | { name: 'assertClipboardMatchesResourceName'; appHostPath?: string; resourceName: string } - | { name: 'assertClipboardMatchesEndpointUrl'; appHostPath?: string; resourceName?: string } - | { name: 'assertClipboardMatchesLogFilePath'; appHostPath?: string } + | { name: 'captureWorkspaceAppHostPathClipboardExpectation' } + | { name: 'assertClipboardMatchesLastExpectation' } | { name: 'openWorkspaceFolder'; folderPath: string } | { name: 'getWorkspaceFolders' } | { name: 'getActiveEditor' }