Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
6 changes: 5 additions & 1 deletion 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 { 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 @@ -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')));
Expand All @@ -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' });
Expand All @@ -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;

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
31 changes: 31 additions & 0 deletions extension/src/test/e2eLaunchProfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading