Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
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 { assertClipboardTextForE2E, 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 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.');

await assertClipboardTextForE2E(appHostPath, 'path');
});

test('runs, shows resources and dashboard state, routes resource commands, and stops from the tree', async () => {
await openAspireView();
await waitForRepositoryIdle();
Expand Down
12 changes: 12 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,18 @@ 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 assertClipboardTextForE2E(expectedText: string, comparison: 'exact' | 'path' = 'exact'): Promise<void> {
Comment thread
adamint marked this conversation as resolved.
Outdated
await executeE2eControlCommand({ name: 'assertClipboardText', expectedText, comparison });
}

export async function runE2eTeardown(cleanups: ReadonlyArray<() => unknown | Promise<unknown>>, failureMessage: string): Promise<void> {
const failures: unknown[] = [];
for (const cleanup of cleanups) {
Expand Down
22 changes: 12 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 { 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';

Expand Down Expand Up @@ -80,21 +80,23 @@ 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 });
Comment thread
adamint marked this conversation as resolved.
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')));

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');
const openedEndpoint = await executeE2eControlCommand({ name: 'openInIntegratedBrowser', appHostPath, resourceName: 'e2e-worker' });
Expand All @@ -107,8 +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 executeE2eControlCommand({ name: 'copyLogFilePath', appHostPath });
await assertClipboardTextForE2E(viewedLogFileName, 'path');

let terminalBefore: number;

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
Loading
Loading