Skip to content

Commit 6128717

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 9364115 commit 6128717

90 files changed

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

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)