Skip to content

Commit 9158b1a

Browse files
committed
Add VS Code integration test project scaffolding
Adds an AppHost-aware Aspire CLI test template workflow and exposes it through a capability-gated VS Code command. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a7314ab-4780-4472-bee8-f265bab3de94
1 parent f7073a8 commit 9158b1a

93 files changed

Lines changed: 2883 additions & 132 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

extension/loc/xlf/aspire-vscode.xlf

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,12 @@
300300
"title": "%command.new%",
301301
"category": "Aspire"
302302
},
303+
{
304+
"command": "aspire-vscode.addIntegrationTestProject",
305+
"title": "%command.addIntegrationTestProject%",
306+
"category": "Aspire",
307+
"enablement": "aspire.addIntegrationTestProjectSupported"
308+
},
303309
{
304310
"command": "aspire-vscode.init",
305311
"title": "%command.init%",
@@ -656,6 +662,10 @@
656662
}
657663
],
658664
"commandPalette": [
665+
{
666+
"command": "aspire-vscode.addIntegrationTestProject",
667+
"when": "aspire.addIntegrationTestProjectSupported"
668+
},
659669
{
660670
"command": "aspire-vscode.createWithAspire",
661671
"when": "false"

extension/package.nls.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"extension.debug.defaultConfiguration.name": "Aspire: Launch default AppHost",
1313
"extension.debug.defaultConfiguration.description": "Launch the effective Aspire AppHost in your workspace",
1414
"command.add": "Add an integration",
15+
"command.addIntegrationTestProject": "Add integration test project",
1516
"command.new": "New Aspire project",
1617
"command.init": "Initialize Aspire in an existing codebase",
1718
"command.createWithAspire": "Set up Aspire",
@@ -59,6 +60,9 @@
5960
"command.runPipelineStepAppHost": "Run pipeline step",
6061
"command.debugPipelineStepAppHost": "Debug pipeline step",
6162
"aspire-vscode.strings.noCsprojFound": "No AppHost found in the current workspace.",
63+
"aspire-vscode.strings.addIntegrationTestProjectCommandTitle": "Add integration test project",
64+
"aspire-vscode.strings.addIntegrationTestProjectUnsupported": "The selected Aspire CLI does not support integration test project scaffolding. Update the Aspire CLI and try again.",
65+
"aspire-vscode.strings.addIntegrationTestProjectUnavailable": "The selected Aspire CLI could not be verified for integration test project scaffolding. Verify the CLI installation and try again.",
6266
"aspire-vscode.strings.error": "Error: {0}",
6367
"aspire-vscode.strings.yes": "Yes",
6468
"aspire-vscode.strings.no": "No",

extension/src/activation/instrumentedCommand.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { isCommandCancellation, withCommandTelemetry } from '../utils/telemetry'
1111
* walkthrough commands) — tryExecuteCommand already wraps its callers.
1212
*
1313
* `source` distinguishes invocation sites we can statically classify
14-
* (`tree`, `codelens`, `walkthrough`); palette is the default and is
15-
* already used by tryExecuteCommand-wrapped commands.
14+
* (`tree`, `codelens`, `walkthrough`, `command_palette`); palette is the default
15+
* used by tryExecuteCommand-wrapped commands.
1616
*/
1717
export function registerInstrumentedCommand(
1818
commandName: string,
19-
source: 'tree' | 'codelens' | 'walkthrough' | 'editor',
19+
source: 'tree' | 'codelens' | 'walkthrough' | 'editor' | 'command_palette',
2020
// The signature mirrors vscode.commands.registerCommand which accepts
2121
// `(...args: any[]) => any`. Using `any` here preserves the inline
2222
// lambda parameter inference at the call sites (otherwise a generic

extension/src/activation/registerCliCommands.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { isE2eBridgeEnabled } from '../testing/e2eStateFileBridge';
2424
import { registerInstrumentedCommand } from './instrumentedCommand';
2525
import { AppHostCommandTarget, getAppHostArgs } from '../utils/appHostArgs';
2626
import { getCliPathTargetForUri } from '../utils/cliPathVariables';
27+
import { addIntegrationTestProject } from '../commands/addIntegrationTestProject';
2728

2829
interface CommandInvocation {
2930
readonly target: CliPathResolutionTarget;
@@ -56,6 +57,12 @@ export function registerCliCommands(
5657
const openGlobalSettingsCommandRegistration = vscode.commands.registerCommand('aspire-vscode.openGlobalSettings', () => tryExecuteCommand('aspire-vscode.openGlobalSettings', terminalProvider, openGlobalSettingsCommand));
5758
const runAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.runAppHostCommand', 'editor', () => editorCommandProvider.tryExecuteRunAppHost(true));
5859
const debugAppHostCommandRegistration = registerInstrumentedCommand('aspire-vscode.debugAppHostCommand', 'editor', () => editorCommandProvider.tryExecuteRunAppHost(false));
60+
// This command resolves the Run-selected AppHost's CLI before probing its specific scaffold
61+
// capability, so it cannot use the generic window/folder gate in tryExecuteCommand.
62+
const addIntegrationTestProjectRegistration = registerInstrumentedCommand(
63+
'aspire-vscode.addIntegrationTestProject',
64+
'command_palette',
65+
() => addIntegrationTestProject(editorCommandProvider, terminalProvider, configInfoProvider));
5966

6067
// Walkthrough commands (no CLI check - the CLI may not be installed yet).
6168
const installCliRegistration = registerInstrumentedCommand('aspire-vscode.installCli', 'walkthrough', installCliCommand);
@@ -78,6 +85,7 @@ export function registerCliCommands(
7885
openGlobalSettingsCommandRegistration,
7986
runAppHostCommandRegistration,
8087
debugAppHostCommandRegistration,
88+
addIntegrationTestProjectRegistration,
8189
installCliRegistration,
8290
verifyCliInstalledRegistration,
8391
];
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import * as path from 'path';
2+
import * as vscode from 'vscode';
3+
import type { AspireEditorCommandProvider } from '../editor/AspireEditorCommandProvider';
4+
import {
5+
addIntegrationTestProjectUnavailable,
6+
addIntegrationTestProjectUnsupported,
7+
noAppHostInWorkspace,
8+
} from '../loc/strings';
9+
import { aspireTestAppHostCapability, type CapabilityStatus } from '../types/configInfo';
10+
import { AspireTerminalProvider, shellArg } from '../utils/AspireTerminalProvider';
11+
import { ConfigInfoProvider } from '../utils/configInfoProvider';
12+
import { extensionLogOutputChannel } from '../utils/logging';
13+
import { getCliPathTargetForUri, type CliPathResolutionTarget } from '../utils/cliPathVariables';
14+
15+
export const addIntegrationTestProjectSupportedContext = 'aspire.addIntegrationTestProjectSupported';
16+
17+
const aspireTestAppHostFallback = {
18+
args: ['new', 'aspire-test', '--help'],
19+
option: '--apphost',
20+
} as const;
21+
22+
interface AspireTestAppHostCapabilityOptions {
23+
readonly cancellationToken?: vscode.CancellationToken;
24+
readonly forceRefresh?: boolean;
25+
}
26+
27+
export type AddIntegrationTestProjectRefreshSubscription =
28+
(listener: () => void) => vscode.Disposable;
29+
30+
export async function getAspireTestAppHostCapabilityStatus(
31+
configInfoProvider: ConfigInfoProvider,
32+
cliPath: string,
33+
target: CliPathResolutionTarget,
34+
options?: AspireTestAppHostCapabilityOptions,
35+
): Promise<CapabilityStatus> {
36+
return await configInfoProvider.getCapabilityStatus(
37+
aspireTestAppHostCapability,
38+
{
39+
cliPath,
40+
target,
41+
cancellationToken: options?.cancellationToken,
42+
fallbackCliCommandOption: aspireTestAppHostFallback,
43+
forceRefresh: options?.forceRefresh,
44+
suppressErrors: true,
45+
});
46+
}
47+
48+
export class AddIntegrationTestProjectAvailability implements vscode.Disposable {
49+
private _refreshGeneration = 0;
50+
private _refreshCancellation: vscode.CancellationTokenSource | undefined;
51+
private _disposed = false;
52+
private readonly _refreshSubscription: vscode.Disposable;
53+
54+
constructor(
55+
private readonly _editorCommandProvider: AspireEditorCommandProvider,
56+
private readonly _terminalProvider: AspireTerminalProvider,
57+
private readonly _configInfoProvider: ConfigInfoProvider,
58+
subscribeToRefresh: AddIntegrationTestProjectRefreshSubscription,
59+
) {
60+
this._refreshSubscription = subscribeToRefresh(() => void this.refresh());
61+
}
62+
63+
async refresh(): Promise<void> {
64+
const generation = ++this._refreshGeneration;
65+
this._refreshCancellation?.cancel();
66+
this._refreshCancellation?.dispose();
67+
const cancellation = new vscode.CancellationTokenSource();
68+
this._refreshCancellation = cancellation;
69+
70+
await this._publish(false, generation);
71+
if (this._isStale(generation)) {
72+
return;
73+
}
74+
75+
try {
76+
const appHostPath = await this._editorCommandProvider.getAppHostPath();
77+
if (!appHostPath
78+
|| path.extname(appHostPath).toLowerCase() !== '.csproj'
79+
|| this._isStale(generation)) {
80+
return;
81+
}
82+
83+
const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath));
84+
const cliPath = await this._terminalProvider.getAspireCliExecutablePath(target);
85+
if (this._isStale(generation)) {
86+
return;
87+
}
88+
89+
const capabilityStatus = await getAspireTestAppHostCapabilityStatus(
90+
this._configInfoProvider,
91+
cliPath,
92+
target,
93+
{ cancellationToken: cancellation.token });
94+
await this._publish(capabilityStatus === 'supported', generation);
95+
}
96+
catch (error) {
97+
if (!cancellation.token.isCancellationRequested && !this._isStale(generation)) {
98+
extensionLogOutputChannel.warn(`Unable to determine integration test scaffolding availability: ${String(error)}`);
99+
}
100+
}
101+
finally {
102+
if (this._refreshCancellation === cancellation) {
103+
this._refreshCancellation = undefined;
104+
}
105+
cancellation.dispose();
106+
}
107+
}
108+
109+
dispose(): void {
110+
this._disposed = true;
111+
this._refreshGeneration++;
112+
this._refreshCancellation?.cancel();
113+
this._refreshCancellation?.dispose();
114+
this._refreshCancellation = undefined;
115+
this._refreshSubscription.dispose();
116+
void vscode.commands.executeCommand('setContext', addIntegrationTestProjectSupportedContext, false);
117+
}
118+
119+
private _isStale(generation: number): boolean {
120+
return this._disposed || generation !== this._refreshGeneration;
121+
}
122+
123+
private async _publish(supported: boolean, generation: number): Promise<void> {
124+
if (this._isStale(generation)) {
125+
return;
126+
}
127+
128+
await vscode.commands.executeCommand(
129+
'setContext',
130+
addIntegrationTestProjectSupportedContext,
131+
supported);
132+
}
133+
}
134+
135+
export async function addIntegrationTestProject(
136+
editorCommandProvider: AspireEditorCommandProvider,
137+
terminalProvider: AspireTerminalProvider,
138+
configInfoProvider: ConfigInfoProvider,
139+
): Promise<void> {
140+
const appHostPath = await editorCommandProvider.getAppHostPath();
141+
if (!appHostPath) {
142+
await vscode.window.showErrorMessage(noAppHostInWorkspace);
143+
return;
144+
}
145+
146+
const target = getCliPathTargetForUri(vscode.Uri.file(appHostPath));
147+
let cliPath: string;
148+
try {
149+
cliPath = await terminalProvider.getAspireCliExecutablePath(target);
150+
} catch (error) {
151+
extensionLogOutputChannel.warn(`Unable to resolve the Aspire CLI for integration test scaffolding: ${String(error)}`);
152+
await vscode.window.showErrorMessage(addIntegrationTestProjectUnavailable);
153+
return;
154+
}
155+
156+
const capabilityStatus = await getAspireTestAppHostCapabilityStatus(
157+
configInfoProvider,
158+
cliPath,
159+
target,
160+
{ forceRefresh: true });
161+
if (capabilityStatus === 'unsupported') {
162+
await vscode.window.showErrorMessage(addIntegrationTestProjectUnsupported);
163+
return;
164+
}
165+
if (capabilityStatus === 'unavailable') {
166+
await vscode.window.showErrorMessage(addIntegrationTestProjectUnavailable);
167+
return;
168+
}
169+
170+
await terminalProvider.sendAspireCommandToAspireTerminal(
171+
['new', 'aspire-test', '--apphost', shellArg(appHostPath)],
172+
true,
173+
undefined,
174+
{ cliPath, target });
175+
}

extension/src/extension.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { registerInstrumentedCommand } from './activation/instrumentedCommand';
3737
import { registerCliCommands } from './activation/registerCliCommands';
3838
import { registerTreeViewCommands } from './activation/registerTreeViewCommands';
3939
import { registerCodeLensCommands } from './activation/registerCodeLensCommands';
40+
import { AddIntegrationTestProjectAvailability, addIntegrationTestProjectSupportedContext } from './commands/addIntegrationTestProject';
4041

4142
let aspireExtensionContext = new AspireExtensionContext();
4243

@@ -158,6 +159,24 @@ export async function activate(context: vscode.ExtensionContext) {
158159
vscode.commands.executeCommand('setContext', 'aspire.noAppHosts', true);
159160
vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', true);
160161
vscode.commands.executeCommand('setContext', 'aspire.loading', true);
162+
vscode.commands.executeCommand('setContext', addIntegrationTestProjectSupportedContext, false);
163+
164+
const addIntegrationTestProjectAvailability = new AddIntegrationTestProjectAvailability(
165+
editorCommandProvider,
166+
terminalProvider,
167+
configInfoProvider,
168+
listener => vscode.Disposable.from(
169+
vscode.window.onDidChangeActiveTextEditor(listener),
170+
vscode.workspace.onDidChangeWorkspaceFolders(listener),
171+
appHostDiscoveryService.onDidChangeCandidates(listener),
172+
cliPathResolver.onDidChangeForwarding(listener),
173+
vscode.workspace.onDidChangeConfiguration(event => {
174+
if (event.affectsConfiguration('aspire.aspireCliExecutablePath')) {
175+
listener();
176+
}
177+
})));
178+
context.subscriptions.push(addIntegrationTestProjectAvailability);
179+
void addIntegrationTestProjectAvailability.refresh();
161180

162181
// Activate the data repository. Workspace describe watching and global polling begin when the panel is visible.
163182
dataRepository.activate();

extension/src/loc/strings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as vscode from 'vscode';
22

33
export const noCsprojFound = vscode.l10n.t('No AppHost found in the current workspace.');
4+
export const addIntegrationTestProjectCommandTitle = vscode.l10n.t('Add integration test project');
5+
export const addIntegrationTestProjectUnsupported = vscode.l10n.t('The selected Aspire CLI does not support integration test project scaffolding. Update the Aspire CLI and try again.');
6+
export const addIntegrationTestProjectUnavailable = vscode.l10n.t('The selected Aspire CLI could not be verified for integration test project scaffolding. Verify the CLI installation and try again.');
47
// l10n.t only substitutes primitives, so passing an Error left the message as the literal "Error: {0}".
58
export const errorMessage = (error: unknown) => vscode.l10n.t('Error: {0}', error instanceof Error ? error.message : String(error));
69
export const yesLabel = vscode.l10n.t('Yes');

extension/src/test-e2e/packageSurface.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ function createExpectedLanguageModelTools(strings: {
595595

596596
const expectedCommandIds = [
597597
'aspire-vscode.add',
598+
'aspire-vscode.addIntegrationTestProject',
598599
'aspire-vscode.codeLensDebugPipelineStep',
599600
'aspire-vscode.codeLensOpenDashboard',
600601
'aspire-vscode.codeLensResourceAction',

0 commit comments

Comments
 (0)