Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Comment on lines 121 to 126
export const appHostStoppingDescription = vscode.l10n.t('Stopping...');
export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} resources)', count);
Expand Down
47 changes: 44 additions & 3 deletions extension/src/test-e2e/appHostTree.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import * as assert from 'assert';
import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures';
import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { executeE2eControlCommand, readClipboardForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning, writeClipboardForE2E } 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);

let clipboardTextToRestore: string | undefined;

async function restoreClipboardIfNeeded(): Promise<void> {
if (clipboardTextToRestore === undefined) {
return;
}

const clipboardText = clipboardTextToRestore;
await writeClipboardForE2E(clipboardText);
clipboardTextToRestore = undefined;
}

teardown(async () => {
await runE2eTeardown([
() => restoreClipboardIfNeeded(),
() => setCliUnavailableForE2E(false),
() => setTerminalCommandExecutionSuppressedForE2E(false),
() => restoreWorkspaceCliPath(),
Expand All @@ -31,6 +44,34 @@ 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();
clipboardTextToRestore = await readClipboardForE2E();
Comment thread
adamint marked this conversation as resolved.
Outdated
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 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 () => {
await openAspireView();
await waitForRepositoryIdle();
Expand Down
13 changes: 13 additions & 0 deletions extension/src/test-e2e/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ export async function executeE2eControlCommand(
return await applyE2eControl({ command }, options?.waitFor ?? 'applied', timeoutMs);
}

export async function readClipboardForE2E(): Promise<string> {
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<void> {
await executeE2eControlCommand({ name: 'writeClipboard', text });
}

export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise<unknown>>, failureMessage: string): Promise<void> {
const failures: unknown[] = [];
for (const cleanup of cleanups) {
Expand Down
86 changes: 82 additions & 4 deletions extension/src/test/appHostTreeView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ResourceJson {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -2856,6 +2863,77 @@ suite('viewAppHostLogFile', () => {
});
});

suite('copyAppHostPath', () => {
let sandbox: sinon.SinonSandbox;

setup(() => {
sandbox = sinon.createSandbox();
});

teardown(() => {
sandbox.restore();
});

test('copies the workspace AppHost path and shows a confirmation notification', async () => {
const previousClipboard = await vscode.env.clipboard.readText();
const appHostPath = path.resolve('workspace', 'apps', 'Store', 'AppHost.csproj');
const onDidChangeData: vscode.Event<void> = () => ({ dispose: () => { } });
const repository = {
viewMode: 'workspace' as ViewMode,
appHosts: [],
workspaceResources: [],
workspaceAppHostPath: appHostPath,
workspaceAppHostCandidatePaths: [appHostPath],
workspaceAppHostName: 'Store',
workspaceAppHostDescription: undefined,
onDidChangeData,
} as unknown as AppHostDataRepository;
const provider = new AspireAppHostTreeProvider(repository, makeTerminalProvider(), makeLaunchService());
// vscode.env.clipboard.writeText is non-configurable, so exercise the real clipboard and read
// it back rather than stubbing it. Seed a sentinel first so a matching read proves the copy ran.
try {
await vscode.env.clipboard.writeText('sentinel-before-copy');
const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined);

const [appHostItem] = provider.getChildren();
assert.strictEqual(appHostItem.contextValue, 'workspaceAppHost');
await provider.copyAppHostPath(appHostItem as any);

assert.strictEqual(await vscode.env.clipboard.readText(), appHostPath);
assert.strictEqual(infoStub.callCount, 1);
assert.strictEqual(infoStub.firstCall.args[0], appHostPathCopiedToClipboard);
} finally {
try {
await vscode.env.clipboard.writeText(previousClipboard);
} finally {
provider.dispose();
}
}
});

test('shows a warning and skips the notification when the AppHost path is missing', async () => {
const previousClipboard = await vscode.env.clipboard.readText();
const provider = makeTreeProvider([]);
try {
await vscode.env.clipboard.writeText('sentinel-unchanged');
const infoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined);
const warningStub = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined as any);

await provider.copyAppHostPath({ appHostPath: undefined } as any);

assert.strictEqual(await vscode.env.clipboard.readText(), 'sentinel-unchanged');
assert.strictEqual(infoStub.callCount, 0);
assert.ok(warningStub.calledOnce);
} finally {
try {
await vscode.env.clipboard.writeText(previousClipboard);
} finally {
provider.dispose();
}
}
});
});

suite('viewAppHostSource', () => {
let sandbox: sinon.SinonSandbox;

Expand Down
5 changes: 5 additions & 0 deletions extension/src/testing/e2eStateFileBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions extension/src/types/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
11 changes: 11 additions & 0 deletions extension/src/views/AspireAppHostTreeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
appHostRunActionLabel,
appHostDebugActionLabel,
appHostPathLabel,
appHostPathCopiedToClipboard,
resourceCountDescription,
tooltipType,
tooltipState,
Expand Down Expand Up @@ -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]
};
}
}

Expand Down Expand Up @@ -1546,6 +1556,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider<TreeEl
return;
}
await vscode.env.clipboard.writeText(appHostPath);
vscode.window.showInformationMessage(appHostPathCopiedToClipboard);
Comment on lines 1553 to +1560
}

async viewAppHostLogFile(element: unknown): Promise<void> {
Expand Down
Loading