diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md
index bbe2393303a..1872557e34c 100644
--- a/docs/specs/cli-output-formats.md
+++ b/docs/specs/cli-output-formats.md
@@ -95,32 +95,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream
]
```
-Use `aspire ps --format json --resources` to include each AppHost's current resources:
-
-```json
-[
- {
- "appHostPath": "/path/to/MyApp.AppHost/MyApp.AppHost.csproj",
- "appHostPid": 12345,
- "status": "running",
- "resources": [
- {
- "name": "api",
- "displayName": "api",
- "resourceType": "Project",
- "state": "Running",
- "healthStatus": "Healthy",
- "urls": [
- {
- "name": "https",
- "url": "https://localhost:5001"
- }
- ]
- }
- ]
- }
-]
-```
+`aspire ps` returns only AppHost-level information. Use [`aspire describe`](#aspire-describe) to inspect or stream the resources that belong to an AppHost.
`aspire ps --follow --format json` streams newline-delimited AppHost objects. New or changed AppHosts are emitted with `"status": "running"`. When an AppHost stops, it is emitted one last time with `"status": "stopped"` so consumers can remove it from their state:
@@ -172,7 +147,7 @@ Use `aspire ps --format json --resources` to include each AppHost's current reso
#### Resource fields
-`aspire describe`, `aspire describe --follow`, and `aspire ps --resources` share the resource object shape:
+`aspire describe` and `aspire describe --follow` share the resource object shape:
| Field | Description |
| ----- | ----------- |
diff --git a/extension/Extension.proj b/extension/Extension.proj
index ff8c1048973..5436af60c54 100644
--- a/extension/Extension.proj
+++ b/extension/Extension.proj
@@ -31,13 +31,13 @@
-
-
-
+
+
@@ -88,12 +88,12 @@
-
+
-
+
diff --git a/extension/build.ps1 b/extension/build.ps1
index 21069d7ff13..ccd4be4305b 100644
--- a/extension/build.ps1
+++ b/extension/build.ps1
@@ -10,10 +10,9 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
exit 1
}
-# Check for yarn
-if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) {
- Write-Error "Error: yarn is not installed. Please install yarn first."
- Write-Host "You can install yarn by running: npm install -g yarn"
+# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock.
+if (-not (Get-Command corepack -ErrorAction SilentlyContinue)) {
+ Write-Error "Error: Corepack is not installed. Please install a Node.js version that includes Corepack."
exit 1
}
@@ -41,7 +40,7 @@ Set-Location $PSScriptRoot
Write-Host ""
Write-Host "Running yarn install..."
-yarn install --frozen-lockfile --non-interactive
+corepack yarn@1.22.22 install --frozen-lockfile --non-interactive
if ($LASTEXITCODE -ne 0) {
Write-Error "yarn install failed with exit code $LASTEXITCODE"
@@ -50,7 +49,7 @@ if ($LASTEXITCODE -ne 0) {
Write-Host ""
Write-Host "Running yarn compile..."
-yarn compile
+corepack yarn@1.22.22 compile
if ($LASTEXITCODE -ne 0) {
Write-Error "yarn compile failed with exit code $LASTEXITCODE"
diff --git a/extension/build.sh b/extension/build.sh
index 4b7c7e79bac..110967f4215 100755
--- a/extension/build.sh
+++ b/extension/build.sh
@@ -9,10 +9,9 @@ if ! command -v node &> /dev/null; then
exit 1
fi
-# Check for yarn
-if ! command -v yarn &> /dev/null; then
- echo "Error: yarn is not installed. Please install yarn first."
- echo "You can install yarn by running: npm install -g yarn"
+# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock.
+if ! command -v corepack &> /dev/null; then
+ echo "Error: Corepack is not installed. Please install a Node.js version that includes Corepack."
exit 1
fi
@@ -38,11 +37,11 @@ cd "$SCRIPT_DIR"
echo ""
echo "Running yarn install..."
-yarn install --frozen-lockfile --non-interactive
+corepack yarn@1.22.22 install --frozen-lockfile --non-interactive
echo ""
echo "Running yarn compile..."
-yarn compile
+corepack yarn@1.22.22 compile
echo ""
echo "Building Aspire CLI..."
diff --git a/extension/package.json b/extension/package.json
index e3106820e0d..095c39616c3 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -3,7 +3,7 @@
"displayName": "Aspire",
"description": "%extension.description%",
"publisher": "microsoft-aspire",
- "version": "1.0.9",
+ "version": "1.10.0",
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
"icon": "dotnet-aspire-logo-128.png",
"license": "SEE LICENSE IN LICENSE.TXT",
diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts
index dd1b6031a6d..145f7f5ba27 100644
--- a/extension/src/test/appHostDataRepository.test.ts
+++ b/extension/src/test/appHostDataRepository.test.ts
@@ -260,7 +260,6 @@ suite('AppHostDataRepository', () => {
appHostPid: 1234,
cliPid: null,
dashboardUrl: null,
- resources: null,
}]));
assert.ok(repository.errorMessage?.includes('describe failed'), repository.errorMessage);
@@ -274,7 +273,7 @@ suite('AppHostDataRepository', () => {
let getAppHostsLineCallback: ((line: string) => void) | undefined;
const getAppHostsProcess = new TestChildProcess();
const describeProcess = new TestChildProcess();
- const psResourcesProcess = new TestChildProcess();
+ const psFollowProcess = new TestChildProcess();
const psFallbackProcess = new TestChildProcess();
const replacementDescribeProcess = new TestChildProcess();
const psSuccessProcess = new TestChildProcess();
@@ -283,7 +282,7 @@ suite('AppHostDataRepository', () => {
return getAppHostsProcess;
});
spawnStub.onSecondCall().returns(describeProcess);
- spawnStub.onThirdCall().returns(psResourcesProcess);
+ spawnStub.onThirdCall().returns(psFollowProcess);
spawnStub.onCall(3).returns(psFallbackProcess);
spawnStub.onCall(4).returns(replacementDescribeProcess);
spawnStub.onCall(5).returns(psSuccessProcess);
@@ -312,12 +311,7 @@ suite('AppHostDataRepository', () => {
psFollowOptions.exitCallback(1);
await waitForAppHostDiscovery();
- const psResourcesOptions = spawnStub.getCall(3).args[3];
- psResourcesOptions.stderrCallback('resources unavailable');
- psResourcesOptions.exitCallback(1);
- await waitForAppHostDiscovery();
-
- const psFallbackOptions = spawnStub.getCall(4).args[3];
+ const psFallbackOptions = spawnStub.getCall(3).args[3];
psFallbackOptions.stderrCallback('ps failed');
psFallbackOptions.exitCallback(1);
assert.ok(repository.errorMessage?.includes('ps failed'), repository.errorMessage);
@@ -375,7 +369,7 @@ suite('AppHostDataRepository', () => {
assert.strictEqual(repository.viewMode, 'workspace');
assert.strictEqual(spawnStub.callCount, 2);
- assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']);
+ assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json']);
} finally {
repository.dispose();
workspaceFoldersStub.restore();
@@ -421,7 +415,241 @@ suite('AppHostDataRepository', () => {
assert.strictEqual(describeProcess.killed, false);
assert.strictEqual(spawnStub.callCount, 3);
assert.deepStrictEqual(spawnStub.secondCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']);
- assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']);
+ assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json']);
+ } finally {
+ repository.dispose();
+ workspaceFoldersStub.restore();
+ }
+ });
+
+ test('multi-AppHost workspace retargets describe to the only running AppHost', async () => {
+ const workspaceFoldersStub = stubWorkspaceFolders([{
+ uri: vscode.Uri.file('/workspace'),
+ name: 'workspace',
+ index: 0,
+ }]);
+ let getAppHostsLineCallback: ((line: string) => void) | undefined;
+ const describeProcesses: TestChildProcess[] = [];
+ const describeCalls: { args: string[]; options: any }[] = [];
+ let psOptions: any;
+ spawnStub.callsFake((_terminalProvider, _command, args, options) => {
+ if (args[0] === 'ls') {
+ getAppHostsLineCallback = createLsLineCallback(options);
+ }
+ if (args[0] === 'describe') {
+ describeCalls.push({ args, options });
+ const process = new TestChildProcess();
+ describeProcesses.push(process);
+ return process;
+ }
+ if (args[0] === 'ps') {
+ psOptions = options;
+ }
+ return new TestChildProcess();
+ });
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ try {
+ repository.activate();
+ repository.setPanelVisible(true);
+ await waitForAppHostDiscovery();
+ assert.ok(getAppHostsLineCallback);
+
+ getAppHostsLineCallback(JSON.stringify({
+ selected_project_file: '/workspace/apps/Store/AppHost.csproj',
+ all_project_file_candidates: [
+ '/workspace/apps/Store/AppHost.csproj',
+ '/workspace/samples/Store/AppHost.csproj',
+ ],
+ }));
+ await waitForAppHostDiscovery();
+
+ assert.strictEqual(describeCalls.length, 1);
+ assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']);
+ assert.ok(psOptions);
+
+ psOptions.lineCallback(JSON.stringify([
+ {
+ appHostPath: '/workspace/samples/Store/AppHost.csproj',
+ appHostPid: 125881,
+ cliPid: 125738,
+ dashboardUrl: 'https://localhost:17193/login?t=061212',
+ },
+ ]));
+ await waitForMicrotasks();
+
+ assert.strictEqual(repository.workspaceAppHostPath, '/workspace/samples/Store/AppHost.csproj');
+ assert.strictEqual(repository.workspaceAppHostName, 'samples/Store/AppHost.csproj');
+ assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881);
+ assert.strictEqual(describeProcesses[0].killed, true);
+ assert.strictEqual(describeCalls.length, 2);
+ assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']);
+
+ describeCalls[1].options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' }));
+ assert.strictEqual(repository.workspaceResources.length, 1);
+ assert.strictEqual(repository.workspaceResources[0].name, 'api');
+ } finally {
+ repository.dispose();
+ workspaceFoldersStub.restore();
+ }
+ });
+
+ test('multi-AppHost workspace does not retarget describe when multiple candidate AppHosts are running', async () => {
+ const workspaceFoldersStub = stubWorkspaceFolders([{
+ uri: vscode.Uri.file('/workspace'),
+ name: 'workspace',
+ index: 0,
+ }]);
+ let getAppHostsLineCallback: ((line: string) => void) | undefined;
+ const describeProcesses: TestChildProcess[] = [];
+ const describeCalls: { args: string[]; options: any }[] = [];
+ let psOptions: any;
+ spawnStub.callsFake((_terminalProvider, _command, args, options) => {
+ if (args[0] === 'ls') {
+ getAppHostsLineCallback = createLsLineCallback(options);
+ }
+ if (args[0] === 'describe') {
+ describeCalls.push({ args, options });
+ const process = new TestChildProcess();
+ describeProcesses.push(process);
+ return process;
+ }
+ if (args[0] === 'ps') {
+ psOptions = options;
+ }
+ return new TestChildProcess();
+ });
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ try {
+ repository.activate();
+ repository.setPanelVisible(true);
+ await waitForAppHostDiscovery();
+ assert.ok(getAppHostsLineCallback);
+
+ getAppHostsLineCallback(JSON.stringify({
+ selected_project_file: '/workspace/apps/Store/AppHost.csproj',
+ all_project_file_candidates: [
+ '/workspace/apps/Store/AppHost.csproj',
+ '/workspace/samples/Store/AppHost.csproj',
+ '/workspace/tools/Admin/AppHost.csproj',
+ ],
+ }));
+ await waitForAppHostDiscovery();
+
+ assert.strictEqual(describeCalls.length, 1);
+ assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']);
+ assert.ok(psOptions);
+
+ psOptions.lineCallback(JSON.stringify([
+ {
+ appHostPath: '/workspace/samples/Store/AppHost.csproj',
+ appHostPid: 125881,
+ cliPid: 125738,
+ dashboardUrl: 'https://localhost:17193/login?t=061212',
+ },
+ {
+ appHostPath: '/workspace/tools/Admin/AppHost.csproj',
+ appHostPid: 125882,
+ cliPid: 125739,
+ dashboardUrl: 'https://localhost:17194/login?t=061213',
+ },
+ ]));
+ await waitForMicrotasks();
+
+ assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj');
+ assert.strictEqual(repository.workspaceAppHostName, 'apps/Store/AppHost.csproj');
+ assert.strictEqual(repository.workspaceAppHost, undefined);
+ // No retarget, but global describe streams start for the non-selected running AppHosts
+ // so their resources appear in the workspace tree.
+ assert.strictEqual(describeCalls.length, 3);
+ assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']);
+ assert.deepStrictEqual(describeCalls[2].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/tools/Admin/AppHost.csproj']);
+ } finally {
+ repository.dispose();
+ workspaceFoldersStub.restore();
+ }
+ });
+
+ test('non-selected running AppHosts in workspace get resources from per-AppHost describe streams', async () => {
+ const workspaceFoldersStub = stubWorkspaceFolders([{
+ uri: vscode.Uri.file('/workspace'),
+ name: 'workspace',
+ index: 0,
+ }]);
+ let getAppHostsLineCallback: ((line: string) => void) | undefined;
+ const describeProcesses: TestChildProcess[] = [];
+ const describeCalls: { args: string[]; options: any }[] = [];
+ let psOptions: any;
+ spawnStub.callsFake((_terminalProvider: any, _command: any, args: string[], options: any) => {
+ if (args[0] === 'ls') {
+ getAppHostsLineCallback = createLsLineCallback(options);
+ }
+ if (args[0] === 'describe') {
+ describeCalls.push({ args, options });
+ const process = new TestChildProcess();
+ describeProcesses.push(process);
+ return process;
+ }
+ if (args[0] === 'ps') {
+ psOptions = options;
+ }
+ return new TestChildProcess();
+ });
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ try {
+ repository.activate();
+ repository.setPanelVisible(true);
+ await waitForAppHostDiscovery();
+ assert.ok(getAppHostsLineCallback);
+
+ getAppHostsLineCallback(JSON.stringify({
+ selected_project_file: '/workspace/apps/Store/AppHost.csproj',
+ all_project_file_candidates: [
+ '/workspace/apps/Store/AppHost.csproj',
+ '/workspace/samples/Store/AppHost.csproj',
+ ],
+ }));
+ await waitForAppHostDiscovery();
+ assert.strictEqual(describeCalls.length, 1);
+ assert.ok(psOptions);
+
+ // Simulate both AppHosts running
+ psOptions.lineCallback(JSON.stringify([
+ {
+ appHostPath: '/workspace/apps/Store/AppHost.csproj',
+ appHostPid: 125880,
+ cliPid: 125737,
+ dashboardUrl: 'https://localhost:17192/login?t=061211',
+ },
+ {
+ appHostPath: '/workspace/samples/Store/AppHost.csproj',
+ appHostPid: 125881,
+ cliPid: 125738,
+ dashboardUrl: 'https://localhost:17193/login?t=061212',
+ },
+ ]));
+ await waitForMicrotasks();
+
+ // Global describe for non-selected AppHost spawns asynchronously after resolving CLI path
+ await waitForCondition(() => describeCalls.length >= 2, 'global describe for non-selected AppHost should start');
+
+ // Initial workspace describe + global describe for the non-selected AppHost
+ // (workspace describe restart is still pending on a timer)
+ assert.strictEqual(describeCalls.length, 2);
+ assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']);
+
+ // Simulate resource data arriving on the non-selected AppHost's describe stream (NDJSON format)
+ describeCalls[1].options.lineCallback(JSON.stringify({ name: 'redis', resourceType: 'Container', state: 'Running' }));
+ await waitForMicrotasks();
+
+ // The non-selected AppHost should have its resources populated
+ const nonSelectedAppHost = repository.appHosts.find((a: any) => a.appHostPath === '/workspace/samples/Store/AppHost.csproj');
+ assert.ok(nonSelectedAppHost);
+ assert.ok(nonSelectedAppHost.resources);
+ assert.strictEqual(nonSelectedAppHost.resources!.length, 1);
+ assert.strictEqual(nonSelectedAppHost.resources![0].name, 'redis');
} finally {
repository.dispose();
workspaceFoldersStub.restore();
@@ -499,7 +727,6 @@ suite('AppHostDataRepository', () => {
{
appHostPath: configuredAppHostPath,
appHostPid: 125881,
- resources: [],
},
]));
assert.strictEqual(repository.workspaceAppHost?.appHostPath, configuredAppHostPath);
@@ -577,6 +804,10 @@ suite('AppHostDataRepository', () => {
status: 'possibly-unbuildable',
},
]));
+ // aspire ls exit handler awaits getConfiguredAppHostPathFromWorkspaceRoot, which
+ // probes for aspire.config.json / .aspire/settings.json via vscode workspace fs.
+ // That probe can take more than one macrotask on Windows, so poll for completion
+ // instead of relying on a single setTimeout(0) tick.
await waitForCondition(
() => repository.workspaceAppHostPath === '/workspace/apps/Store/AppHost.csproj',
'buildable AppHost discovery did not finish');
@@ -689,13 +920,12 @@ suite('AppHostDataRepository', () => {
await waitForAppHostDiscovery();
assert.ok(psOptions);
- assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']);
+ assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json']);
psOptions.lineCallback(JSON.stringify([{
appHostPath: '/workspace/apphost/apphost.cs',
appHostPid: 125881,
cliPid: 125738,
dashboardUrl: 'https://localhost:17193/login?t=061212',
- resources: [],
}]));
assert.strictEqual(repository.workspaceResources.length, 0);
@@ -769,7 +999,6 @@ suite('AppHostDataRepository', () => {
appHostPid: 125881,
cliPid: 125738,
dashboardUrl: 'https://localhost:17193/login?t=061212',
- resources: [],
},
]));
@@ -895,7 +1124,6 @@ suite('AppHostDataRepository', () => {
{
appHostPath: '/workspace/labs/ops/apphost.cs',
appHostPid: 125881,
- resources: [],
},
]));
@@ -913,6 +1141,143 @@ suite('AppHostDataRepository', () => {
}
});
+ test('workspace describe exit clears stale running AppHost before ps stop snapshot', async () => {
+ const workspaceFoldersStub = stubWorkspaceFolders([{
+ uri: vscode.Uri.file('/workspace'),
+ name: 'workspace',
+ index: 0,
+ }]);
+ const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined);
+ let getAppHostsLineCallback: ((line: string) => void) | undefined;
+ const describeProcess = new TestChildProcess();
+ let describeOptions: any;
+ let psOptions: any;
+ spawnStub.callsFake((_terminalProvider, _command, args, options) => {
+ if (args[0] === 'ls') {
+ getAppHostsLineCallback = createLsLineCallback(options);
+ }
+ if (args[0] === 'describe') {
+ describeOptions = options;
+ return describeProcess;
+ }
+ if (args[0] === 'ps') {
+ psOptions = options;
+ }
+ return new TestChildProcess();
+ });
+
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ try {
+ repository.activate();
+ repository.setPanelVisible(true);
+ await waitForMicrotasks();
+
+ assert.ok(getAppHostsLineCallback);
+ getAppHostsLineCallback(JSON.stringify({
+ selected_project_file: '/workspace/labs/ops/apphost.cs',
+ all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'],
+ }));
+ await waitForAppHostDiscovery();
+
+ assert.ok(describeOptions);
+ assert.ok(psOptions);
+ describeOptions.lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' }));
+ psOptions.lineCallback(JSON.stringify([
+ {
+ appHostPath: '/workspace/labs/ops/apphost.cs',
+ appHostPid: 125881,
+ },
+ ]));
+
+ assert.strictEqual(repository.workspaceResources.length, 1);
+ assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881);
+ assert.strictEqual(repository.appHosts.length, 1);
+
+ describeOptions.exitCallback(0);
+
+ assert.strictEqual(repository.workspaceResources.length, 0);
+ assert.strictEqual(repository.workspaceAppHost, undefined);
+ assert.strictEqual(repository.appHosts.length, 0);
+
+ const noRunningContextCalls = executeCommandStub.getCalls().filter(call =>
+ call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts');
+ assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true);
+ } finally {
+ repository.dispose();
+ executeCommandStub.restore();
+ workspaceFoldersStub.restore();
+ }
+ });
+
+ test('workspace ps start restarts describe after earlier empty describe exit', async () => {
+ const workspaceFoldersStub = stubWorkspaceFolders([{
+ uri: vscode.Uri.file('/workspace'),
+ name: 'workspace',
+ index: 0,
+ }]);
+ let getAppHostsLineCallback: ((line: string) => void) | undefined;
+ let psOptions: any;
+ const describeProcesses: TestChildProcess[] = [];
+ const describeOptions: any[] = [];
+ spawnStub.callsFake((_terminalProvider, _command, args, options) => {
+ if (args[0] === 'ls') {
+ getAppHostsLineCallback = createLsLineCallback(options);
+ }
+ if (args[0] === 'describe') {
+ describeOptions.push(options);
+ const process = new TestChildProcess();
+ describeProcesses.push(process);
+ return process;
+ }
+ if (args[0] === 'ps') {
+ psOptions = options;
+ }
+ return new TestChildProcess();
+ });
+
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ try {
+ repository.activate();
+ repository.setPanelVisible(true);
+ await waitForMicrotasks();
+
+ assert.ok(getAppHostsLineCallback);
+ getAppHostsLineCallback(JSON.stringify({
+ selected_project_file: '/workspace/labs/ops/apphost.cs',
+ all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'],
+ }));
+ await waitForAppHostDiscovery();
+
+ assert.strictEqual(describeOptions.length, 1);
+ describeOptions[0].exitCallback(0);
+ assert.strictEqual(repository.workspaceResources.length, 0);
+ assert.strictEqual(Boolean(repository.workspaceAppHost), false);
+
+ assert.ok(psOptions);
+ psOptions.lineCallback(JSON.stringify([
+ {
+ appHostPath: '/workspace/labs/ops/apphost.cs',
+ appHostPid: 125881,
+ },
+ ]));
+ await waitForMicrotasks();
+
+ assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881);
+ assert.strictEqual(describeOptions.length, 2);
+
+ describeOptions[1].lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' }));
+ assert.strictEqual(repository.workspaceResources.length, 1);
+ assert.strictEqual(repository.workspaceResources[0].name, 'worker');
+ assert.strictEqual(describeProcesses[0].killed, false);
+ assert.strictEqual(describeProcesses[1].killed, false);
+ } finally {
+ repository.dispose();
+ workspaceFoldersStub.restore();
+ }
+ });
+
test('late close from stopped describe watch does not orphan replacement watch', async () => {
const firstChildProcess = new TestChildProcess();
const secondChildProcess = new TestChildProcess();
@@ -1012,7 +1377,7 @@ suite('AppHostDataRepository global polling', () => {
repository.setPanelVisible(true);
await waitForMicrotasks();
- assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']);
+ assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']);
repository.setPanelVisible(false);
@@ -1031,52 +1396,58 @@ suite('AppHostDataRepository global polling', () => {
repository.setPanelVisible(true);
await waitForMicrotasks();
- assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']);
+ assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']);
- const lineCallback = spawnStub.firstCall.args[3].lineCallback;
- lineCallback(JSON.stringify({
+ const psLineCallback = spawnStub.firstCall.args[3].lineCallback;
+ psLineCallback(JSON.stringify({
appHostPath: '/workspace/AppHost.csproj',
appHostPid: 1234,
status: 'running',
- resources: [
- { name: 'api', resourceType: 'Project', state: 'Running' }
- ]
}));
+ await waitForMicrotasks();
assert.strictEqual(repository.appHosts.length, 1);
assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/AppHost.csproj');
+
+ // The repository should now have spawned `aspire describe --follow --apphost `
+ // for the discovered AppHost so the global tree can show resources.
+ const describeCall = spawnStub.getCalls().find(call =>
+ Array.isArray(call.args[2]) && call.args[2][0] === 'describe' && call.args[2].includes('/workspace/AppHost.csproj'));
+ assert.ok(describeCall, 'expected aspire describe --follow to spawn for the discovered AppHost');
+ assert.deepStrictEqual(describeCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/AppHost.csproj']);
+
+ const describeLineCallback = describeCall.args[3].lineCallback;
+ describeLineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' }));
assert.strictEqual(repository.appHosts[0].resources?.[0].name, 'api');
- lineCallback(JSON.stringify({
+ psLineCallback(JSON.stringify({
appHostPath: '/workspace/OtherAppHost.csproj',
appHostPid: 5678,
status: 'running',
- resources: []
}));
+ await waitForMicrotasks();
assert.strictEqual(repository.appHosts.length, 2);
assert.strictEqual(repository.appHosts[1].appHostPath, '/workspace/OtherAppHost.csproj');
assert.deepStrictEqual(repository.appHosts[1].resources, []);
- lineCallback(JSON.stringify({
+ psLineCallback(JSON.stringify({
appHostPath: '/workspace/AppHost.csproj',
appHostPid: 9999,
status: 'running',
- resources: []
}));
+ await waitForMicrotasks();
assert.strictEqual(repository.appHosts.length, 3);
assert.strictEqual(repository.appHosts[2].appHostPath, '/workspace/AppHost.csproj');
assert.strictEqual(repository.appHosts[2].appHostPid, 9999);
- lineCallback(JSON.stringify({
+ psLineCallback(JSON.stringify({
appHostPath: '/workspace/AppHost.csproj',
appHostPid: 1234,
status: 'stopped',
- resources: [
- { name: 'api', resourceType: 'Project', state: 'Running' }
- ]
}));
+ await waitForMicrotasks();
assert.strictEqual(repository.appHosts.length, 2);
assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj');
@@ -1107,7 +1478,7 @@ suite('AppHostDataRepository global polling', () => {
repository.dispose();
});
- test('cli path failure does not disable resources polling', async () => {
+ test('cli path failure does not disable ps polling', async () => {
const clock = sinon.useFakeTimers();
getCliPathStub.onFirstCall().rejects(new Error('CLI path unavailable'));
getCliPathStub.onSecondCall().resolves('aspire');
@@ -1125,7 +1496,7 @@ suite('AppHostDataRepository global polling', () => {
await waitForMicrotasks();
assert.strictEqual(spawnStub.calledOnce, true);
- assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json', '--resources']);
+ assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json']);
} finally {
repository.dispose();
clock.restore();
@@ -1157,7 +1528,7 @@ suite('AppHostDataRepository global polling', () => {
}
});
- test('stopped ps does not start fallback after resources failure', async () => {
+ test('stopped ps does not start fallback after exit', async () => {
const childProcess = new TestChildProcess();
spawnStub.returns(childProcess);
const repository = new AppHostDataRepository(terminalProvider);
@@ -1221,6 +1592,99 @@ suite('AppHostDataRepository global polling', () => {
repository.dispose();
});
+
+ test('global mode spawns describe per AppHost and tears down on AppHost removal', async () => {
+ const spawned: { args: string[]; process: TestChildProcess; options: any }[] = [];
+ spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => {
+ const process = new TestChildProcess();
+ spawned.push({ args, process, options });
+ return process;
+ });
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ repository.activate();
+ repository.setViewMode('global');
+ repository.setPanelVisible(true);
+ await waitForMicrotasks();
+
+ const psCall = spawned.find(call => call.args[0] === 'ps');
+ assert.ok(psCall);
+
+ psCall.options.lineCallback(JSON.stringify({
+ appHostPath: '/workspace/AppHost.csproj',
+ appHostPid: 1234,
+ status: 'running',
+ }));
+ psCall.options.lineCallback(JSON.stringify({
+ appHostPath: '/workspace/OtherAppHost.csproj',
+ appHostPid: 5678,
+ status: 'running',
+ }));
+ await waitForMicrotasks();
+
+ const describeCalls = spawned.filter(call => call.args[0] === 'describe');
+ assert.strictEqual(describeCalls.length, 2);
+ const paths = describeCalls.map(call => call.args[call.args.indexOf('--apphost') + 1]).sort();
+ assert.deepStrictEqual(paths, ['/workspace/AppHost.csproj', '/workspace/OtherAppHost.csproj']);
+
+ const firstDescribe = describeCalls.find(call => call.args.includes('/workspace/AppHost.csproj'))!;
+ firstDescribe.options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' }));
+ firstDescribe.options.lineCallback(JSON.stringify({ name: 'db', resourceType: 'Container', state: 'Running' }));
+
+ const first = repository.appHosts.find(a => a.appHostPath === '/workspace/AppHost.csproj');
+ assert.ok(first);
+ assert.strictEqual(first.resources?.length, 2);
+ assert.deepStrictEqual(first.resources?.map(r => r.name).sort(), ['api', 'db']);
+
+ // Stop the first AppHost — its describe stream should be torn down.
+ psCall.options.lineCallback(JSON.stringify({
+ appHostPath: '/workspace/AppHost.csproj',
+ appHostPid: 1234,
+ status: 'stopped',
+ }));
+ await waitForMicrotasks();
+
+ assert.strictEqual(firstDescribe.process.killed, true);
+ assert.strictEqual(repository.appHosts.length, 1);
+ assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj');
+
+ repository.dispose();
+ });
+
+ test('global describe streams are stopped when switching to workspace mode', async () => {
+ const spawned: { args: string[]; process: TestChildProcess; options: any }[] = [];
+ spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => {
+ const process = new TestChildProcess();
+ spawned.push({ args, process, options });
+ return process;
+ });
+ const repository = new AppHostDataRepository(terminalProvider);
+
+ repository.activate();
+ repository.setViewMode('global');
+ repository.setPanelVisible(true);
+ await waitForMicrotasks();
+
+ const psCall = spawned.find(call => call.args[0] === 'ps');
+ assert.ok(psCall);
+ psCall.options.lineCallback(JSON.stringify({
+ appHostPath: '/workspace/AppHost.csproj',
+ appHostPid: 1234,
+ status: 'running',
+ }));
+ await waitForMicrotasks();
+
+ const describeCall = spawned.find(call => call.args[0] === 'describe');
+ assert.ok(describeCall);
+ assert.strictEqual(describeCall.process.killed, false);
+
+ repository.setViewMode('workspace');
+ await waitForMicrotasks();
+
+ assert.strictEqual(describeCall.process.killed, true);
+
+ repository.dispose();
+ });
});
suite('AppHostDataRepository AppHost-file gate', () => {
diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts
index 42cd419fe54..4bb6b4e351a 100644
--- a/extension/src/views/AppHostDataRepository.ts
+++ b/extension/src/views/AppHostDataRepository.ts
@@ -90,13 +90,26 @@ export interface AppHostDisplayInfo {
export type ViewMode = 'workspace' | 'global';
+interface GlobalDescribeStream {
+ appHostPath: string;
+ process: ChildProcessWithoutNullStreams | undefined;
+ resources: Map;
+ restartTimer: ReturnType | undefined;
+ restartDelay: number;
+ version: number;
+}
+
/**
* Central data repository for app host and resource information.
*
- * Owns two independent data sources:
+ * Owns three independent data sources:
* - `aspire describe --follow` (workspace mode) — streams resource updates
* via NDJSON for the selected workspace AppHost. Only active while the
* tree-view panel is visible **and** workspace mode is selected.
+ * - `aspire describe --follow --apphost ` (global mode fan-out) — one
+ * stream per AppHost discovered by `ps`, merged into `appHost.resources`
+ * so the global multi-AppHost tree can show nested resources. `ps` itself
+ * only emits AppHost-level data.
* - `aspire ps` polling — periodically fetches running app hosts. In global
* mode this backs the full tree; in workspace mode it confirms whether the
* selected workspace AppHost is running when the resource stream is empty.
@@ -123,14 +136,25 @@ export class AppHostDataRepository {
// ── Running AppHost state (ps polling) ──
private _appHosts: AppHostDisplayInfo[] = [];
+ // Cached JSON serialization of `_appHosts` after the most recent reconcile so
+ // _handlePsOutput can detect real changes. We can't compare raw `ps` output to
+ // `_appHosts` directly because the in-memory state has merged resources, while
+ // `ps` no longer emits them (#17479) — see _handlePsOutput for the rationale.
+ private _appHostsSnapshot = '[]';
private _workspaceAppHost: AppHostDisplayInfo | undefined;
private _pollingInterval: ReturnType | undefined;
private _psProcesses = new Set();
private _psFetchVersion = 0;
- private _supportsResources = true;
private _supportsPsFollow = true;
private _fetchInProgress = false;
+ // ── Global mode per-AppHost describe streams ──
+ // In global mode `ps` only returns AppHost-level data, so to populate
+ // `appHost.resources` for the multi-AppHost tree we fan out one
+ // `aspire describe --follow --apphost ` per discovered AppHost and
+ // merge the streams. Keyed by appHostPath.
+ private _globalDescribeStreams = new Map();
+
// ── Workspace app host (from aspire ls) ──
// The singular fields track a selected/default workspace AppHost. The candidate
// paths track every buildable AppHost found by `aspire ls`, so workspace-mode
@@ -255,6 +279,7 @@ export class AppHostDataRepository {
refresh(): void {
this._stopDescribeWatch();
+ this._stopAllGlobalDescribes();
this._workspaceResources.clear();
this._clearErrors();
this._updateWorkspaceContext();
@@ -276,6 +301,7 @@ export class AppHostDataRepository {
this._disposed = true;
this._stopPolling();
this._stopDescribeWatch();
+ this._stopAllGlobalDescribes();
this._configChangeDisposable.dispose();
this._appHostDiscoveryChangeDisposable.dispose();
this._onDidChangeData.dispose();
@@ -329,6 +355,12 @@ export class AppHostDataRepository {
} else {
this._stopPolling();
}
+
+ // Global describe fan-out is only active while in global mode with the
+ // panel/editor showing. _reconcileGlobalDescribes handles both starting
+ // streams (when there are AppHosts to follow) and tearing them down
+ // (when we leave global mode or hide the panel).
+ this._reconcileGlobalDescribes();
}
// ── Workspace app host (from aspire ls) ──
@@ -400,6 +432,13 @@ export class AppHostDataRepository {
this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath);
}
+ private _setWorkspaceAppHostPathFromCurrentCandidates(appHostPath: string): void {
+ this._workspaceAppHostPath = appHostPath;
+ const appHostLabels = shortenPaths(this._workspaceAppHostCandidatePaths);
+ const candidateIndex = this._workspaceAppHostCandidatePaths.findIndex(candidatePath => isMatchingAppHostPath(candidatePath, appHostPath));
+ this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath);
+ }
+
private _setWorkspaceAppHostCandidatePaths(appHostCandidates: readonly AppHostCandidate[]): void {
this._workspaceAppHostCandidatePaths = appHostCandidates.map(candidate => candidate.path);
}
@@ -489,6 +528,7 @@ export class AppHostDataRepository {
// once more with backoff in case the apphost is restarting; if that
// attempt also produces no data we'll fall into the branch above.
this._workspaceResources.clear();
+ this._clearStoppedWorkspaceAppHost();
this._setDescribeError(undefined);
this._updateWorkspaceContext();
@@ -572,6 +612,14 @@ export class AppHostDataRepository {
}
}
+ private _clearStoppedWorkspaceAppHost(): void {
+ const appHostPath = this._workspaceAppHost?.appHostPath ?? this._workspaceAppHostPath;
+ this._workspaceAppHost = undefined;
+ this._appHosts = appHostPath
+ ? this._appHosts.filter(appHost => !isMatchingAppHostPath(appHost.appHostPath, appHostPath))
+ : [];
+ }
+
private _handleDescribeLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) {
@@ -607,6 +655,184 @@ export class AppHostDataRepository {
return undefined;
}
+ // ── Global mode: per-AppHost describe fan-out ──
+ // `ps` is AppHost-level only, so to keep the global multi-AppHost tree
+ // populated with resources we spin up one `aspire describe --follow --apphost `
+ // per AppHost in `_appHosts` and merge the streams into appHost.resources.
+
+ private _reconcileGlobalDescribes(): void {
+ if (this._disposed || this._viewMode !== 'global' || !this._dataActive) {
+ this._stopAllGlobalDescribes();
+ return;
+ }
+
+ const currentPaths = new Set(this._appHosts.map(a => a.appHostPath));
+ for (const path of Array.from(this._globalDescribeStreams.keys())) {
+ if (!currentPaths.has(path)) {
+ this._stopGlobalDescribe(path);
+ }
+ }
+ for (const appHost of this._appHosts) {
+ if (!this._globalDescribeStreams.has(appHost.appHostPath)) {
+ this._startGlobalDescribe(appHost.appHostPath);
+ }
+ }
+ this._attachGlobalResourcesToAppHosts();
+ }
+
+ private _attachGlobalResourcesToAppHosts(): void {
+ for (const appHost of this._appHosts) {
+ const stream = this._globalDescribeStreams.get(appHost.appHostPath);
+ appHost.resources = stream ? Array.from(stream.resources.values()) : null;
+ }
+ }
+
+ private _startGlobalDescribe(appHostPath: string): void {
+ const stream: GlobalDescribeStream = {
+ appHostPath,
+ process: undefined,
+ resources: new Map(),
+ restartTimer: undefined,
+ restartDelay: 5000,
+ version: 0,
+ };
+ this._globalDescribeStreams.set(appHostPath, stream);
+ const startVersion = ++stream.version;
+
+ this._terminalProvider.getAspireCliExecutablePath().then(cliPath => {
+ // Bail if we were stopped, replaced, or torn down while resolving the cli path.
+ if (this._disposed || this._globalDescribeStreams.get(appHostPath) !== stream || startVersion !== stream.version) {
+ return;
+ }
+
+ const args = ['describe', '--follow', '--format', 'json', '--apphost', appHostPath];
+ extensionLogOutputChannel.info(`Starting aspire describe --follow for AppHost ${appHostPath}`);
+
+ const childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, {
+ noExtensionVariables: true,
+ lineCallback: (line) => {
+ if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) {
+ return;
+ }
+ this._handleGlobalDescribeLine(stream, line);
+ },
+ stderrCallback: (data) => {
+ // Per-AppHost describe errors should not pollute the global error banner,
+ // but they MUST be logged so users can diagnose missing resources for
+ // non-selected AppHosts (e.g., CLI too old to support `describe --apphost`).
+ extensionLogOutputChannel.warn(`aspire describe --follow stderr for ${appHostPath}: ${data}`);
+ },
+ exitCallback: (code) => {
+ if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) {
+ return;
+ }
+ extensionLogOutputChannel.info(`aspire describe --follow for ${appHostPath} exited with code ${code}`);
+ stream.process = undefined;
+ if (this._disposed) {
+ return;
+ }
+
+ // AppHost is no longer running — drop the stream entirely; the
+ // next ps reconcile will recreate it if the AppHost comes back.
+ if (!this._appHosts.some(a => a.appHostPath === appHostPath)) {
+ this._globalDescribeStreams.delete(appHostPath);
+ return;
+ }
+
+ stream.resources.clear();
+ this._attachGlobalResourcesToAppHosts();
+ this._onDidChangeData.fire();
+
+ const delay = stream.restartDelay;
+ stream.restartDelay = Math.min(stream.restartDelay * 2, this._getPollingIntervalMs());
+ stream.restartTimer = setTimeout(() => {
+ stream.restartTimer = undefined;
+ if (this._disposed) {
+ return;
+ }
+ if (this._globalDescribeStreams.get(appHostPath) !== stream) {
+ return;
+ }
+ if (!this._appHosts.some(a => a.appHostPath === appHostPath)) {
+ this._globalDescribeStreams.delete(appHostPath);
+ return;
+ }
+ this._globalDescribeStreams.delete(appHostPath);
+ this._startGlobalDescribe(appHostPath);
+ }, delay);
+ },
+ errorCallback: (error) => {
+ if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) {
+ return;
+ }
+ extensionLogOutputChannel.warn(`aspire describe --follow for ${appHostPath} error: ${error.message}`);
+ stream.process = undefined;
+ // Node's `spawn` can fire `error` (e.g., ENOENT when the CLI binary is missing)
+ // without a subsequent `exit`, which would normally drive the restart loop.
+ // Drop the dead entry so the next ps reconcile recreates it instead of leaving
+ // a zombie that blocks reconcile from re-starting the stream.
+ this._globalDescribeStreams.delete(appHostPath);
+ stream.resources.clear();
+ this._attachGlobalResourcesToAppHosts();
+ this._onDidChangeData.fire();
+ }
+ });
+ stream.process = childProcess;
+ }).catch(error => {
+ extensionLogOutputChannel.warn(`Failed to start describe for ${appHostPath}: ${error}`);
+ // Same hazard as errorCallback above: getAspireCliExecutablePath() can reject
+ // (CLI missing, permission denied, etc.) without ever firing the spawn error/exit
+ // callbacks that would normally clean up. Drop the dead entry so the next
+ // reconcile recreates it instead of leaving a zombie that blocks reconcile
+ // from re-starting the stream.
+ if (this._globalDescribeStreams.get(appHostPath) === stream) {
+ this._globalDescribeStreams.delete(appHostPath);
+ }
+ });
+ }
+
+ private _handleGlobalDescribeLine(stream: GlobalDescribeStream, line: string): void {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return;
+ }
+ try {
+ const resource: ResourceJson = JSON.parse(trimmed);
+ if (resource.name) {
+ stream.resources.set(resource.name, resource);
+ stream.restartDelay = 5000;
+ this._attachGlobalResourcesToAppHosts();
+ this._onDidChangeData.fire();
+ }
+ } catch (e) {
+ extensionLogOutputChannel.warn(`Failed to parse describe NDJSON line for ${stream.appHostPath}: ${e}`);
+ }
+ }
+
+ private _stopGlobalDescribe(appHostPath: string): void {
+ const stream = this._globalDescribeStreams.get(appHostPath);
+ if (!stream) {
+ return;
+ }
+ this._globalDescribeStreams.delete(appHostPath);
+ stream.version++;
+ if (stream.restartTimer) {
+ clearTimeout(stream.restartTimer);
+ stream.restartTimer = undefined;
+ }
+ if (stream.process) {
+ const childProcess = stream.process;
+ stream.process = undefined;
+ this._terminateProcess(childProcess, `aspire describe --follow (${appHostPath})`);
+ }
+ }
+
+ private _stopAllGlobalDescribes(): void {
+ for (const path of Array.from(this._globalDescribeStreams.keys())) {
+ this._stopGlobalDescribe(path);
+ }
+ }
+
private _updateWorkspaceContext(options?: { clearLoading?: boolean }): void {
const hasWorkspaceAppHost = this._workspaceAppHost !== undefined;
const hasResources = this._workspaceResources.size > 0;
@@ -705,9 +931,6 @@ export class AppHostDataRepository {
};
const args = ['ps', '--follow', '--format', 'json'];
- if (this._supportsResources) {
- args.push('--resources');
- }
psProcess = spawnCliProcess(this._terminalProvider, cliPath, args, {
noExtensionVariables: true,
@@ -772,34 +995,16 @@ export class AppHostDataRepository {
const fetchVersion = ++this._psFetchVersion;
const args = ['ps', '--format', 'json'];
- if (this._supportsResources) {
- args.push('--resources');
- }
this._runPsCommand(args, fetchVersion, (code, stdout, stderr) => {
if (code === 0) {
this._setPsError(undefined);
this._handlePsOutput(stdout);
- this._fetchInProgress = false;
- } else if (this._supportsResources) {
- this._supportsResources = false;
- extensionLogOutputChannel.info('aspire ps --resources failed, falling back to aspire ps without --resources');
- this._runPsCommand(['ps', '--format', 'json'], fetchVersion, (retryCode, retryStdout, retryStderr) => {
- if (retryCode === 0) {
- this._setPsError(undefined);
- this._handlePsOutput(retryStdout);
- } else {
- this._loadingGlobal = false;
- this._updateLoadingContext();
- this._setPsError(errorFetchingAppHosts(retryStderr || `exit code ${retryCode}`));
- }
- this._fetchInProgress = false;
- });
} else {
this._loadingGlobal = false;
this._updateLoadingContext();
this._setPsError(errorFetchingAppHosts(stderr || `exit code ${code}`));
- this._fetchInProgress = false;
}
+ this._fetchInProgress = false;
});
}
@@ -859,8 +1064,18 @@ export class AppHostDataRepository {
return;
}
- const changed = JSON.stringify(appHosts) !== JSON.stringify(this._appHosts);
+ // Compare against the previous post-reconcile snapshot rather than the
+ // raw ps payload. `appHosts` here lacks the `resources` field (ps no longer
+ // emits it after #17479), while `this._appHosts` was mutated by the prior
+ // _attachGlobalResourcesToAppHosts call to include resources — a direct
+ // JSON.stringify compare would always report `changed` once any stream
+ // produced resources, triggering spurious _onDidChangeData.fire() calls.
+ const previousSnapshot = this._appHostsSnapshot;
this._appHosts = appHosts;
+ this._reconcileGlobalDescribes();
+ const nextSnapshot = JSON.stringify(this._appHosts);
+ const changed = nextSnapshot !== previousSnapshot;
+ this._appHostsSnapshot = nextSnapshot;
if (this._loadingGlobal) {
this._loadingGlobal = false;
@@ -888,13 +1103,29 @@ export class AppHostDataRepository {
}
private _handleWorkspacePsOutput(appHosts: readonly AppHostDisplayInfo[]): void {
- const workspaceAppHostPath = this._workspaceAppHostPath;
+ let workspaceAppHostPath = this._workspaceAppHostPath;
const workspaceAppHosts = this._workspaceAppHostCandidatePaths.length > 0
? appHosts.filter(appHost => this._workspaceAppHostCandidatePaths.some(candidatePath => isMatchingAppHostPath(appHost.appHostPath, candidatePath)))
: [];
- const workspaceAppHost = workspaceAppHostPath
+ let workspaceAppHost = workspaceAppHostPath
? workspaceAppHosts.find(appHost => isMatchingAppHostPath(appHost.appHostPath, workspaceAppHostPath))
: undefined;
+ let workspaceAppHostPathChanged = false;
+
+ if (!workspaceAppHost && workspaceAppHosts.length === 1) {
+ workspaceAppHost = workspaceAppHosts[0];
+ workspaceAppHostPathChanged = !isMatchingAppHostPath(workspaceAppHostPath, workspaceAppHost.appHostPath);
+ if (workspaceAppHostPathChanged) {
+ extensionLogOutputChannel.info(`Retargeting workspace AppHost describe to running AppHost ${workspaceAppHost.appHostPath}`);
+ this._stopDescribeWatch({ clearWorkspaceResources: true });
+ this._setWorkspaceAppHostPathFromCurrentCandidates(workspaceAppHost.appHostPath);
+ workspaceAppHostPath = this._workspaceAppHostPath;
+ this._setDescribeError(undefined);
+ this._describeRestartDelay = 5000;
+ }
+ }
+
+ const workspaceAppHostStarted = workspaceAppHost !== undefined && (this._workspaceAppHost === undefined || workspaceAppHostPathChanged);
const changed = JSON.stringify(workspaceAppHosts) !== JSON.stringify(this._appHosts)
|| JSON.stringify(workspaceAppHost) !== JSON.stringify(this._workspaceAppHost);
@@ -905,11 +1136,64 @@ export class AppHostDataRepository {
this._appHosts = workspaceAppHosts;
this._workspaceAppHost = workspaceAppHost;
+ // When multiple workspace AppHost candidates exist, start per-AppHost describe
+ // streams for running AppHosts that are NOT the selected one (the workspace
+ // describe stream already handles the selected AppHost). This ensures every
+ // running AppHost displayed in the multi-AppHost workspace tree has resources.
+ if (this._workspaceAppHostCandidatePaths.length > 1) {
+ this._reconcileWorkspaceDescribes(workspaceAppHosts);
+ }
+
+ if (workspaceAppHostStarted
+ && this._shouldWatchWorkspace
+ && !this._describeProcess
+ && !this._describeStartPending
+ && !this._describeRestartTimer) {
+ this._startDescribeWatch();
+ }
+
if (changed || this._loadingWorkspace) {
this._updateWorkspaceContext({ clearLoading: true });
}
}
+ /**
+ * In multi-candidate workspace mode, start/stop per-AppHost describe streams for
+ * running workspace AppHosts that are NOT the currently selected one. The workspace
+ * describe stream (via `_startDescribeWatch`) handles the selected AppHost; this
+ * method fans out global describe streams for the remaining running AppHosts so that
+ * each one displayed in the workspace tree has its resources populated.
+ */
+ private _reconcileWorkspaceDescribes(workspaceAppHosts: readonly AppHostDisplayInfo[]): void {
+ const selectedPath = this._workspaceAppHostPath;
+
+ // Determine which non-selected workspace AppHosts need a describe stream.
+ const desiredPaths = new Set(
+ workspaceAppHosts
+ .filter(a => !selectedPath || !isMatchingAppHostPath(a.appHostPath, selectedPath))
+ .map(a => a.appHostPath)
+ );
+
+ // Stop streams for AppHosts that are no longer running (or became selected).
+ for (const path of Array.from(this._globalDescribeStreams.keys())) {
+ if (!desiredPaths.has(path)) {
+ this._stopGlobalDescribe(path);
+ }
+ }
+
+ // Start streams for newly running non-selected AppHosts.
+ for (const appHost of workspaceAppHosts) {
+ if (selectedPath && isMatchingAppHostPath(appHost.appHostPath, selectedPath)) {
+ continue;
+ }
+ if (!this._globalDescribeStreams.has(appHost.appHostPath)) {
+ this._startGlobalDescribe(appHost.appHostPath);
+ }
+ }
+
+ this._attachGlobalResourcesToAppHosts();
+ }
+
private async _runPsCommand(args: string[], fetchVersion: number, callback: (code: number, stdout: string, stderr: string) => void): Promise {
let cliPath: string;
try {
diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs
index d36e128e336..76082600915 100644
--- a/src/Aspire.Cli/Commands/PsCommand.cs
+++ b/src/Aspire.Cli/Commands/PsCommand.cs
@@ -4,7 +4,6 @@
using System.CommandLine;
using System.Globalization;
using System.Text.Json;
-using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using Aspire.Cli.Backchannel;
@@ -13,7 +12,6 @@
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
-using Aspire.Shared.Model.Serialization;
using Microsoft.Extensions.Logging;
using Spectre.Console;
@@ -35,9 +33,6 @@ internal sealed class AppHostDisplayInfo
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LogFilePath { get; init; }
-
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public List? Resources { get; set; }
}
internal static class AppHostDisplayStatus
@@ -48,18 +43,6 @@ internal static class AppHostDisplayStatus
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(AppHostDisplayInfo))]
-[JsonSerializable(typeof(ResourceJson))]
-[JsonSerializable(typeof(ResourceUrlJson))]
-[JsonSerializable(typeof(ResourceVolumeJson))]
-[JsonSerializable(typeof(ResourceRelationshipJson))]
-[JsonSerializable(typeof(ResourceHealthReportJson))]
-[JsonSerializable(typeof(ResourceCommandJson))]
-[JsonSerializable(typeof(ResourceCommandArgumentJson[]))]
-[JsonSerializable(typeof(JsonNode))]
-[JsonSerializable(typeof(Dictionary))]
-[JsonSerializable(typeof(Dictionary))]
-[JsonSerializable(typeof(Dictionary))]
-[JsonSerializable(typeof(Dictionary))]
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class PsCommandJsonContext : JsonSerializerContext
{
@@ -98,16 +81,6 @@ internal sealed partial class PsCommand : BaseCommand
Description = PsCommandStrings.JsonOptionDescription
};
- private static readonly Option s_resourcesOption = new("--resources")
- {
- Description = PsCommandStrings.ResourcesOptionDescription
- };
-
- private static readonly Option s_includeHiddenOption = new("--include-hidden")
- {
- Description = SharedCommandStrings.IncludeHiddenOptionDescription
- };
-
private static readonly Option s_followOption = new("--follow", "-f")
{
Description = PsCommandStrings.FollowOptionDescription
@@ -128,8 +101,6 @@ public PsCommand(
_logger = logger;
Options.Add(s_formatOption);
- Options.Add(s_resourcesOption);
- Options.Add(s_includeHiddenOption);
Options.Add(s_followOption);
}
@@ -138,12 +109,10 @@ protected override async Task ExecuteAsync(ParseResult parseResul
using var activity = Telemetry.StartDiagnosticActivity(Name);
var format = parseResult.GetValue(s_formatOption);
- var includeResources = parseResult.GetValue(s_resourcesOption);
- var includeHidden = parseResult.GetValue(s_includeHiddenOption);
if (parseResult.GetValue(s_followOption))
{
- return await ExecuteFollowAsync(format, includeResources, includeHidden, cancellationToken).ConfigureAwait(false);
+ return await ExecuteFollowAsync(format, cancellationToken).ConfigureAwait(false);
}
// Scan for running AppHosts (same as ListAppHostsTool). JSON output must not go
@@ -174,7 +143,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul
.ToList();
// Gather info for each AppHost
- var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, includeResources && format == OutputFormat.Json, includeHidden, cancellationToken).ConfigureAwait(false);
+ var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, cancellationToken).ConfigureAwait(false);
if (format == OutputFormat.Json)
{
@@ -201,9 +170,7 @@ private abstract record PsFollowUpdate;
private sealed record ConnectionsUpdate(IReadOnlyList Connections) : PsFollowUpdate;
- private sealed record ResourceUpdate(IAppHostAuxiliaryBackchannel Connection) : PsFollowUpdate;
-
- private async Task ExecuteFollowAsync(OutputFormat format, bool includeResources, bool includeHidden, CancellationToken cancellationToken)
+ private async Task ExecuteFollowAsync(OutputFormat format, CancellationToken cancellationToken)
{
if (format != OutputFormat.Json)
{
@@ -215,12 +182,9 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i
{
SingleReader = true
});
- var currentConnections = new List();
var appHostKeyComparer = GetAppHostKeyComparer();
var activeAppHosts = new Dictionary(appHostKeyComparer);
var lastJsonByAppHost = new Dictionary(appHostKeyComparer);
- CancellationTokenSource? resourceWatchCts = null;
- List resourceWatchTasks = [];
_ = Task.Run(async () =>
{
@@ -251,10 +215,8 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i
{
if (update is ConnectionsUpdate connectionsUpdate)
{
- currentConnections = OrderConnections(connectionsUpdate.Connections);
- await RestartResourceWatchersAsync().ConfigureAwait(false);
-
- var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, includeResources, includeHidden, followCancellationToken).ConfigureAwait(false);
+ var currentConnections = OrderConnections(connectionsUpdate.Connections);
+ var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, followCancellationToken).ConfigureAwait(false);
var nextActiveAppHosts = new Dictionary(appHostKeyComparer);
foreach (var appHost in currentAppHosts)
@@ -277,27 +239,6 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i
activeAppHosts = nextActiveAppHosts;
}
- else if (update is ResourceUpdate resourceUpdate)
- {
- var appHostInfos = await GatherAppHostInfosAsync([resourceUpdate.Connection], includeResources, includeHidden, followCancellationToken).ConfigureAwait(false);
- var appHost = appHostInfos.SingleOrDefault();
- if (appHost is null)
- {
- continue;
- }
-
- var key = GetAppHostKey(appHost);
- if (!activeAppHosts.ContainsKey(key))
- {
- continue;
- }
-
- activeAppHosts[key] = appHost;
- if (!await TryWriteAppHostInfoAsync(appHost).ConfigureAwait(false))
- {
- return CommandResult.Success();
- }
- }
}
}
catch (OperationCanceledException) when (followCancellationToken.IsCancellationRequested)
@@ -312,57 +253,10 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i
finally
{
await followCancellationTokenSource.CancelAsync().ConfigureAwait(false);
- if (resourceWatchCts is not null)
- {
- await resourceWatchCts.CancelAsync().ConfigureAwait(false);
- await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
- resourceWatchCts.Dispose();
- }
}
return CommandResult.Success();
- async Task RestartResourceWatchersAsync()
- {
- if (resourceWatchCts is not null)
- {
- await resourceWatchCts.CancelAsync().ConfigureAwait(false);
- await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
- resourceWatchCts.Dispose();
- resourceWatchCts = null;
- resourceWatchTasks = [];
- }
-
- if (!includeResources || format != OutputFormat.Json)
- {
- return;
- }
-
- resourceWatchCts = CancellationTokenSource.CreateLinkedTokenSource(followCancellationToken);
- var resourceCancellationToken = resourceWatchCts.Token;
- foreach (var connection in currentConnections)
- {
- resourceWatchTasks.Add(Task.Run(async () =>
- {
- try
- {
- await foreach (var _ in connection.WatchResourceSnapshotsAsync(includeHidden, resourceCancellationToken).WithCancellation(resourceCancellationToken).ConfigureAwait(false))
- {
- await updates.Writer.WriteAsync(new ResourceUpdate(connection), resourceCancellationToken).ConfigureAwait(false);
- }
- }
- catch (OperationCanceledException) when (resourceCancellationToken.IsCancellationRequested)
- {
- // Expected when the connection list changes or the command stops.
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Failed while watching resource snapshots for {AppHostPath}.", connection.AppHostInfo?.AppHostPath);
- }
- }, CancellationToken.None));
- }
- }
-
async Task TryWriteAppHostInfoAsync(AppHostDisplayInfo appHost)
{
var key = GetAppHostKey(appHost);
@@ -414,8 +308,7 @@ private static AppHostDisplayInfo CopyWithStatus(AppHostDisplayInfo appHost, str
SdkVersion = appHost.SdkVersion,
CliPid = appHost.CliPid,
DashboardUrl = appHost.DashboardUrl,
- LogFilePath = appHost.LogFilePath,
- Resources = appHost.Resources
+ LogFilePath = appHost.LogFilePath
};
}
@@ -426,7 +319,7 @@ private static List OrderConnections(IEnumerable> GatherAppHostInfosAsync(List connections, bool includeResources, bool includeHidden, CancellationToken cancellationToken)
+ private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken)
{
var appHostInfos = new List();
@@ -480,20 +373,6 @@ private async Task> GatherAppHostInfosAsync(List? resources = null;
- if (includeResources)
- {
- try
- {
- var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false);
- resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardUrl, includeEnvironmentVariableValues: false);
- }
- catch (Exception ex)
- {
- _logger.LogDebug(ex, "Failed to get resource snapshots for {AppHostPath}", info.AppHostPath);
- }
- }
-
appHostInfos.Add(new AppHostDisplayInfo
{
AppHostPath = appHostPath ?? PsCommandStrings.UnknownPath,
@@ -502,8 +381,7 @@ private async Task> GatherAppHostInfosAsync(List
Unknown
-
- Include resource details for each running AppHost. Only applies to JSON output.
-
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
The --follow option only supports JSON output. Use --format json.
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf
index 3caecff13d0..d16e817de7d 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Výstup ve formátu JSON
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Neznámé
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf
index b72fb405eaa..302c834e8f2 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Ausgabe im JSON-Format.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Unbekannt
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf
index ce2f85d6b41..0c4df433772 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Salida en formato JSON.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Desconocido
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf
index f10c909806d..8aa4be8f43d 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Sortie au format JSON.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Inconnu
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf
index cec06e8f1c7..10349c21b98 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Output in formato JSON.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Sconosciuto
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf
index 398bfc4972b..f59b502a801 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
JSON 形式で出力します。
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
不明
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf
index 0d78643506b..b5eb897baeb 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
JSON 형식의 출력입니다.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
알 수 없음
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf
index f078f83fe46..ac3b47aad3e 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Wynik w formacie JSON.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Nieznane
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf
index dc6d95aab59..7ca49e6015d 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Saída no formato JSON.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Desconhecido
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf
index 6a8d805b322..e726bd4836c 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Вывод в формате JSON.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Неизвестно
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf
index 613a8aaec81..8e8c7059cc5 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
Çıkışı JSON biçiminde oluşturun.
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
Bilinmiyor
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf
index d9bf1dea07b..f63554406d8 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
以 JSON 格式输出。
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
未知
diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf
index 0ce98849920..bda1455aa72 100644
--- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf
+++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf
@@ -8,8 +8,8 @@
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
- Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
+ Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots.
@@ -47,11 +47,6 @@
以 JSON 格式輸出。
-
- Include resource details for each running AppHost. Only applies to JSON output.
- Include resource details for each running AppHost. Only applies to JSON output.
-
-
Unknown
未知
diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs
index 5212484429e..352d11a6c73 100644
--- a/src/Shared/Model/Serialization/ResourceJson.cs
+++ b/src/Shared/Model/Serialization/ResourceJson.cs
@@ -10,8 +10,8 @@ namespace Aspire.Shared.Model.Serialization;
/// Represents a resource in JSON format.
/// This is a shared representation used by both the Dashboard and CLI.
///
-// CLI commands such as `aspire describe --format json` and `aspire ps --format json --resources`
-// use this shape and the nested resource shapes below; keep docs/specs/cli-output-formats.md in sync when changing them.
+// `aspire describe --format json` uses this shape and the nested resource shapes below;
+// keep docs/specs/cli-output-formats.md in sync when changing them.
internal sealed class ResourceJson
{
///
diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs
index f4fc2c0919a..31ff6ec3e68 100644
--- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs
+++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs
@@ -3,7 +3,6 @@
using System.Net;
using System.Net.Sockets;
-using System.Runtime.CompilerServices;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
@@ -501,7 +500,7 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus()
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService();
- var result = command.Parse("ps --format json --resources");
+ var result = command.Parse("ps --format json");
var exitCode = await result.InvokeAsync().DefaultTimeout();
@@ -513,311 +512,6 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus()
Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind);
}
- [Fact]
- public async Task PsCommand_ResourcesOption_IncludesResourcesInJsonOutput()
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- var textWriter = new TestOutputTextWriter(outputHelper);
-
- var monitor = new TestAuxiliaryBackchannelMonitor();
- var connection = new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234,
- CliProcessId = 5678
- },
- DashboardUrlsState = new DashboardUrlsState
- {
- BaseUrlWithLoginToken = "http://localhost:18888/login?t=abc123"
- },
- ResourceSnapshots =
- [
- new ResourceSnapshot
- {
- Name = "apiservice",
- DisplayName = "apiservice",
- ResourceType = "Project",
- State = "Running",
- StateStyle = "success",
- Urls =
- [
- new ResourceSnapshotUrl { Name = "https", Url = "https://localhost:7001" }
- ]
- },
- new ResourceSnapshot
- {
- Name = "redis",
- DisplayName = "redis",
- ResourceType = "Container",
- State = "Running",
- StateStyle = "success"
- },
- new ResourceSnapshot
- {
- Name = "aspire-dashboard",
- DisplayName = "aspire-dashboard",
- ResourceType = "Project",
- State = "Hidden",
- IsHidden = true
- }
- ]
- };
- monitor.AddConnection("hash1", "socket.hash1", connection);
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- var result = command.Parse("ps --format json --resources");
-
- var exitCode = await result.InvokeAsync().DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
-
- var jsonOutput = string.Join(string.Empty, textWriter.Logs);
- var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo);
- Assert.NotNull(appHosts);
- Assert.Single(appHosts);
-
- var appHost = appHosts[0];
- Assert.NotNull(appHost.Resources);
- Assert.Equal(2, appHost.Resources.Count);
-
- var apiService = appHost.Resources.First(r => r.Name == "apiservice");
- Assert.Equal("Project", apiService.ResourceType);
- Assert.Equal("Running", apiService.State);
- Assert.NotNull(apiService.Urls);
- Assert.Single(apiService.Urls);
- Assert.Equal("https://localhost:7001", apiService.Urls[0].Url);
-
- var redis = appHost.Resources.First(r => r.Name == "redis");
- Assert.Equal("Container", redis.ResourceType);
- Assert.Equal("Running", redis.State);
- Assert.DoesNotContain(appHost.Resources, resource => resource.Name == "aspire-dashboard");
- }
-
- [Fact]
- public async Task PsCommand_ResourcesOption_WithIncludeHidden_IncludesHiddenResources()
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- var textWriter = new TestOutputTextWriter(outputHelper);
-
- var monitor = new TestAuxiliaryBackchannelMonitor();
- var connection = new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234
- },
- ResourceSnapshots =
- [
- new ResourceSnapshot
- {
- Name = "apiservice",
- DisplayName = "apiservice",
- ResourceType = "Project",
- State = "Running"
- },
- new ResourceSnapshot
- {
- Name = "aspire-dashboard",
- DisplayName = "aspire-dashboard",
- ResourceType = "Project",
- State = "Hidden",
- IsHidden = true
- }
- ]
- };
- monitor.AddConnection("hash1", "socket.hash1", connection);
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- var result = command.Parse("ps --format json --resources --include-hidden");
-
- var exitCode = await result.InvokeAsync().DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
-
- var jsonOutput = string.Join(string.Empty, textWriter.Logs);
- var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo);
- Assert.NotNull(appHosts);
- var resources = Assert.Single(appHosts).Resources;
- Assert.NotNull(resources);
- Assert.Contains(resources, resource => resource.Name == "apiservice");
- Assert.Contains(resources, resource => resource.Name == "aspire-dashboard");
- }
-
- [Theory]
- [InlineData(false, 1)]
- [InlineData(true, 2)]
- public async Task PsCommand_FollowResourcesOption_HandlesHiddenResources(bool includeHidden, int expectedResourceCount)
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- using var cancellationTokenSource = new CancellationTokenSource();
- var outputLines = new List();
- var textWriter = new TestOutputTextWriter(outputHelper, line =>
- {
- outputLines.Add(line);
- cancellationTokenSource.Cancel();
- });
-
- var monitor = new TestAuxiliaryBackchannelMonitor();
- var connection = new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234
- },
- ResourceSnapshots =
- [
- new ResourceSnapshot
- {
- Name = "apiservice",
- DisplayName = "apiservice",
- ResourceType = "Project",
- State = "Running"
- },
- new ResourceSnapshot
- {
- Name = "aspire-dashboard",
- DisplayName = "aspire-dashboard",
- ResourceType = "Project",
- State = "Hidden",
- IsHidden = true
- }
- ]
- };
- monitor.AddConnection("hash1", "socket.hash1", connection);
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- var includeHiddenArg = includeHidden ? " --include-hidden" : string.Empty;
- var result = command.Parse($"ps --format json --resources --follow{includeHiddenArg}");
-
- var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
- var outputLine = Assert.Single(outputLines);
- var appHost = JsonSerializer.Deserialize(outputLine, PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo);
- Assert.NotNull(appHost);
- Assert.Equal(AppHostDisplayStatus.Running, appHost.Status);
- var resources = appHost.Resources;
- Assert.NotNull(resources);
- Assert.Equal(expectedResourceCount, resources.Count);
- Assert.Contains(resources, resource => resource.Name == "apiservice");
- Assert.Equal(includeHidden, resources.Any(resource => resource.Name == "aspire-dashboard"));
- }
-
- [Fact]
- public async Task PsCommand_FollowJsonFormat_StreamsChangedAppHostForResourceUpdates()
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- using var cancellationTokenSource = new CancellationTokenSource();
- var outputLines = new List();
- var updateResource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var textWriter = new TestOutputTextWriter(outputHelper, line =>
- {
- outputLines.Add(line);
- if (outputLines.Count == 1)
- {
- updateResource.SetResult();
- }
- else if (outputLines.Count == 2)
- {
- cancellationTokenSource.Cancel();
- }
- });
-
- var snapshots = new List
- {
- new()
- {
- Name = "apiservice",
- DisplayName = "apiservice",
- ResourceType = "Project",
- State = "Starting",
- StateStyle = "info"
- }
- };
- var monitor = new TestAuxiliaryBackchannelMonitor();
- var connection = new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234,
- CliProcessId = 5678
- },
- GetResourceSnapshotsHandler = _ => Task.FromResult(snapshots.ToList()),
- WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync
- };
- monitor.AddConnection("hash1", "socket.hash1", connection);
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- var result = command.Parse("ps --format json --resources --follow");
-
- var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
- Assert.Equal(2, outputLines.Count);
-
- var initialAppHost = JsonSerializer.Deserialize(outputLines[0], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo);
- var updatedAppHost = JsonSerializer.Deserialize(outputLines[1], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo);
- Assert.NotNull(initialAppHost);
- Assert.NotNull(updatedAppHost);
- Assert.Equal(AppHostDisplayStatus.Running, initialAppHost.Status);
- Assert.Equal(AppHostDisplayStatus.Running, updatedAppHost.Status);
- Assert.Equal("Starting", Assert.Single(initialAppHost.Resources!).State);
- Assert.Equal("Running", Assert.Single(updatedAppHost.Resources!).State);
-
- async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- Assert.False(includeHidden);
- await updateResource.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
- snapshots[0] = new ResourceSnapshot
- {
- Name = "apiservice",
- DisplayName = "apiservice",
- ResourceType = "Project",
- State = "Running",
- StateStyle = "success"
- };
- yield return snapshots[0];
- await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
- }
- }
-
[Fact]
public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses()
{
@@ -854,79 +548,6 @@ public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses()
Assert.Single(interactionService.DisplayedRawText);
}
- [Fact]
- public async Task PsCommand_FollowJsonFormat_WaitsForResourceWatchersBeforeReturning()
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- using var cancellationTokenSource = new CancellationTokenSource();
- var watcherCleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var allowWatcherCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var textWriter = new TestOutputTextWriter(outputHelper, _ => cancellationTokenSource.Cancel());
- var monitor = new TestAuxiliaryBackchannelMonitor();
- monitor.AddConnection("hash1", "socket.hash1", new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234,
- CliProcessId = 5678
- },
- ResourceSnapshots =
- [
- new ResourceSnapshot
- {
- Name = "apiservice",
- DisplayName = "apiservice",
- ResourceType = "Project",
- State = "Running"
- }
- ],
- WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync
- });
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- var result = command.Parse("ps --format json --resources --follow");
-
- var invokeTask = result.InvokeAsync(cancellationToken: cancellationTokenSource.Token);
-
- await watcherCleanupStarted.Task.DefaultTimeout();
- try
- {
- Assert.False(invokeTask.IsCompleted, "The command returned before its resource watcher task completed cleanup.");
- }
- finally
- {
- allowWatcherCleanup.TrySetResult();
- }
-
- var exitCode = await invokeTask.DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
-
- async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- try
- {
- await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
- }
- finally
- {
- watcherCleanupStarted.TrySetResult();
- await allowWatcherCleanup.Task.ConfigureAwait(false);
- }
-
- yield break;
- }
- }
-
[Fact]
public async Task PsCommand_FollowWithoutJsonFormat_ReturnsInvalidCommand()
{
@@ -1001,103 +622,6 @@ public async Task PsCommand_FollowJsonFormat_StreamsStoppedAppHostWhenConnection
Assert.Equal(initialAppHost.AppHostPid, stoppedAppHost.AppHostPid);
}
- [Fact]
- public async Task PsCommand_WithoutResourcesOption_OmitsResourcesFromJsonOutput()
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- var textWriter = new TestOutputTextWriter(outputHelper);
-
- var monitor = new TestAuxiliaryBackchannelMonitor();
- var connection = new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234
- },
- ResourceSnapshots =
- [
- new ResourceSnapshot
- {
- Name = "apiservice",
- ResourceType = "Project",
- State = "Running"
- }
- ]
- };
- monitor.AddConnection("hash1", "socket.hash1", connection);
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- var result = command.Parse("ps --format json");
-
- var exitCode = await result.InvokeAsync().DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
-
- var jsonOutput = string.Join(string.Empty, textWriter.Logs);
- var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo);
- Assert.NotNull(appHosts);
- Assert.Single(appHosts);
- Assert.Null(appHosts[0].Resources);
-
- // Also verify the raw JSON doesn't contain a "resources" key
- var document = JsonDocument.Parse(jsonOutput);
- var firstElement = document.RootElement[0];
- Assert.False(firstElement.TryGetProperty("resources", out _));
- }
-
- [Fact]
- public async Task PsCommand_ResourcesOption_TableFormat_DoesNotFetchResources()
- {
- using var workspace = TemporaryWorkspace.Create(outputHelper);
- var textWriter = new TestOutputTextWriter(outputHelper);
-
- var resourcesFetched = false;
- var monitor = new TestAuxiliaryBackchannelMonitor();
- var connection = new TestAppHostAuxiliaryBackchannel
- {
- IsInScope = true,
- AppHostInfo = new AppHostInformation
- {
- AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"),
- ProcessId = 1234
- },
- GetResourceSnapshotsHandler = _ =>
- {
- resourcesFetched = true;
- return Task.FromResult(new List
- {
- new ResourceSnapshot { Name = "apiservice", ResourceType = "Project", State = "Running" }
- });
- }
- };
- monitor.AddConnection("hash1", "socket.hash1", connection);
-
- var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
- {
- options.OutputTextWriter = textWriter;
- options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
- });
- using var provider = services.BuildServiceProvider();
-
- var command = provider.GetRequiredService();
- // --resources with table format should not fetch resources
- var result = command.Parse("ps --resources");
-
- var exitCode = await result.InvokeAsync().DefaultTimeout();
-
- Assert.Equal(CliExitCodes.Success, exitCode);
- Assert.False(resourcesFetched, "Resources should not be fetched when output format is table");
- }
-
[Fact]
public async Task PsCommand_JsonFormat_IncludesLogFilePath()
{