Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Comment on lines +171 to +172
"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.",
Expand Down
2 changes: 2 additions & 0 deletions extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export const appHostOpenSourceActionLabel = vscode.l10n.t('Open AppHost source')
export const appHostRunActionLabel = vscode.l10n.t('Run AppHost');
export const appHostDebugActionLabel = vscode.l10n.t('Debug AppHost');
export const appHostPathLabel = vscode.l10n.t('Path');
export const appHostPathCopiedToClipboard = vscode.l10n.t('AppHost path copied to clipboard.');
export const appHostPathInvalid = vscode.l10n.t('Could not determine the AppHost path to copy.');
export const appHostStartingDescription = vscode.l10n.t('Starting...');
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
34 changes: 31 additions & 3 deletions extension/src/test-e2e/appHostTree.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import * as assert from 'assert';
import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures';
import { getCommandInvocationCount, getResources, getTerminalCommandCount, getTreeAppHostLabel, isSamePath, waitForCommandOutcome, waitForDashboardUrl, waitForNoDebugSessions, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { assertClipboardMatchesLastExpectationForE2E, captureWorkspaceAppHostPathClipboardExpectationForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopAppHostIfRunning, stopPrimaryAppHostIfRunning } from './helpers/fixtures';
import { getPrimaryAppHostProjectPath } from './helpers/paths';
import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForTreeItem } from './helpers/vscode';
import { cancelActiveInput, clickTreeItem, executeCommandFromPalette, openAspireView, waitForChildTreeItem, waitForNotificationMessage, waitForTreeItem } from './helpers/vscode';

suite('Aspire AppHost tree E2E', function () {
this.timeout(240000);

teardown(async () => {
await runE2eTeardown([
() => restoreClipboardSnapshotForE2E(),
() => setCliUnavailableForE2E(false),
() => setTerminalCommandExecutionSuppressedForE2E(false),
() => restoreWorkspaceCliPath(),
Expand All @@ -31,6 +32,33 @@ suite('Aspire AppHost tree E2E', function () {
assert.ok(stateFile.state.workspaceAppHostCandidatePaths.length >= 1);
});

test('clicking the Path tree item copies the AppHost path and shows a confirmation notification', async () => {
await openAspireView();
await waitForRepositoryIdle();
await snapshotClipboardForE2E();
const discovered = await waitForWorkspaceAppHost();
const appHostLabel = getTreeAppHostLabel(discovered.state);
const section = await openAspireView();

// The Path row only appears under an idle (non-running) workspace AppHost item, so exercise
// it before starting the AppHost. See https://github.com/microsoft/aspire/issues/18578.
const idleItem = await waitForTreeItem(section, appHostLabel);
await idleItem.expand();

// Labels below match loc/strings.ts (appHostPathLabel / appHostPathCopiedToClipboard); the
// E2E host runs in English so the literals are stable, mirroring other tree-item labels
// asserted in this suite (e.g. 'Run AppHost').
const pathItem = await waitForChildTreeItem(idleItem, 'Path');
await captureWorkspaceAppHostPathClipboardExpectationForE2E();
await pathItem.click();

// The notification only fires after a successful copy, so its appearance proves the click
// routed through aspire-vscode.copyAppHostPath rather than reading a stale clipboard value.
await waitForNotificationMessage('AppHost path copied to clipboard.');

await assertClipboardMatchesLastExpectationForE2E();
});

test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => {
await openAspireView();
await waitForRepositoryIdle();
Expand Down
16 changes: 16 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,22 @@ export async function executeE2eControlCommand(
return await applyE2eControl({ command }, options?.waitFor ?? 'applied', timeoutMs);
}

export async function snapshotClipboardForE2E(): Promise<void> {
await executeE2eControlCommand({ name: 'snapshotClipboard' });
}

export async function restoreClipboardSnapshotForE2E(): Promise<void> {
await executeE2eControlCommand({ name: 'restoreClipboardSnapshot' });
}

export async function captureWorkspaceAppHostPathClipboardExpectationForE2E(): Promise<void> {
await executeE2eControlCommand({ name: 'captureWorkspaceAppHostPathClipboardExpectation' });
}

export async function assertClipboardMatchesLastExpectationForE2E(): Promise<void> {
await executeE2eControlCommand({ name: 'assertClipboardMatchesLastExpectation' });
}

export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise<unknown>>, failureMessage: string): Promise<void> {
const failures: unknown[] = [];
for (const cleanup of cleanups) {
Expand Down
24 changes: 14 additions & 10 deletions extension/src/test-e2e/treeActions.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as assert from 'assert';
import * as path from 'path';
import { findResource, getCommandInvocationCount, getTerminalCommandCount, isSamePath, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { executeE2eControlCommand, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures';
import { findResource, getCommandInvocationCount, getTerminalCommandCount, waitForAppHostLaunching, waitForCommandOutcome, waitForDashboardUrl, waitForExtensionState, waitForHttpText, waitForNoRunningAppHost, waitForRepositoryIdle, waitForResource, waitForResourceState, waitForRunningAppHost, waitForTerminalCommand, waitForWorkspaceAppHost } from './helpers/assertions';
import { assertClipboardMatchesLastExpectationForE2E, executeE2eControlCommand, restoreClipboardSnapshotForE2E, restoreWorkspaceCliPath, runE2eTeardown, setCliUnavailableForE2E, setTerminalCommandExecutionSuppressedForE2E, snapshotClipboardForE2E, stopPrimaryAppHostIfRunning } from './helpers/fixtures';
import { getPrimaryAppHostProjectPath } from './helpers/paths';
import { answerActiveInput, chooseActiveQuickPick, getActiveQuickPickLabels, openAspireView, waitForChildTreeItem, waitForTreeItem, waitForWorkbenchTextAfterIntegratedBrowserNavigation } from './helpers/vscode';

Expand All @@ -16,6 +16,7 @@ suite('Aspire tree action command E2E', function () {

teardown(async () => {
await runE2eTeardown([
() => restoreClipboardSnapshotForE2E(),
() => setCliUnavailableForE2E(false),
() => setTerminalCommandExecutionSuppressedForE2E(false),
() => restoreWorkspaceCliPath(),
Expand Down Expand Up @@ -80,21 +81,24 @@ suite('Aspire tree action command E2E', function () {
await noCommandsResource.expand();
assert.strictEqual(await noCommandsResource.findChildItem('Commands'), undefined);

const copiedAppHost = await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath });
assert.ok(isSamePath(String(copiedAppHost.result), appHostPath));
await snapshotClipboardForE2E();
await executeE2eControlCommand({ name: 'copyAppHostPath', appHostPath });
Comment thread
adamint marked this conversation as resolved.
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')));

const viewedSource = await executeE2eControlCommand({ name: 'viewAppHostSource', appHostPath });
assert.ok(String((viewedSource.result as { uri?: string }).uri).startsWith('aspire-source:'));

const copiedResourceName = await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' });
assert.strictEqual(copiedResourceName.result, 'e2e-worker');
await executeE2eControlCommand({ name: 'copyResourceName', appHostPath, resourceName: 'e2e-worker' });
await assertClipboardMatchesLastExpectationForE2E();

const copiedEndpointUrl = await executeE2eControlCommand({ name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker' });
const endpointUrl = String(copiedEndpointUrl.result);
const endpointUrl = workerResource.urls?.find(url => !url.isInternal)?.url ?? workerResource.urls?.[0]?.url;
assert.ok(endpointUrl, 'Expected e2e-worker to expose an endpoint URL.');
assert.ok(endpointUrl.startsWith('http'));
await executeE2eControlCommand({ name: 'copyEndpointUrl', appHostPath, resourceName: 'e2e-worker' });
await assertClipboardMatchesLastExpectationForE2E();

before = getCommandInvocationCount('aspire-vscode.openInIntegratedBrowser');
const openedEndpoint = await executeE2eControlCommand({ name: 'openInIntegratedBrowser', appHostPath, resourceName: 'e2e-worker' });
Expand All @@ -107,8 +111,8 @@ suite('Aspire tree action command E2E', function () {
const viewedLogFileName = (viewedLog.result as { fileName?: string }).fileName;
assert.ok(viewedLogFileName && path.isAbsolute(viewedLogFileName));

const copiedLogPath = await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath });
assert.ok(path.isAbsolute(String(copiedLogPath.result)));
await executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath });
await assertClipboardMatchesLastExpectationForE2E();

let terminalBefore: number;

Expand Down
87 changes: 83 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, appHostPathInvalid } 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,78 @@ suite('viewAppHostLogFile', () => {
});
});

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

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

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

test('copies the workspace AppHost path and shows a confirmation notification', async () => {
const previousClipboard = await vscode.env.clipboard.readText();
const appHostPath = path.resolve('workspace', 'apps', 'Store', 'AppHost.csproj');
const onDidChangeData: vscode.Event<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);
assert.strictEqual(warningStub.firstCall.args[0], appHostPathInvalid);
} finally {
try {
await vscode.env.clipboard.writeText(previousClipboard);
} finally {
provider.dispose();
}
}
});
});

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

Expand Down
Loading
Loading