Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 2 additions & 27 deletions docs/specs/cli-output-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
mitchdenny marked this conversation as resolved.

Expand Down Expand Up @@ -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 |
| ----- | ----------- |
Expand Down
165 changes: 130 additions & 35 deletions extension/src/test/appHostDataRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,6 @@ suite('AppHostDataRepository', () => {
appHostPid: 1234,
cliPid: null,
dashboardUrl: null,
resources: null,
}]));

assert.ok(repository.errorMessage?.includes('describe failed'), repository.errorMessage);
Expand All @@ -271,7 +270,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();
Expand All @@ -280,7 +279,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);
Expand Down Expand Up @@ -309,12 +308,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);
Expand Down Expand Up @@ -372,7 +366,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();
Expand Down Expand Up @@ -418,7 +412,7 @@ 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();
Expand Down Expand Up @@ -490,7 +484,6 @@ suite('AppHostDataRepository', () => {
{
appHostPath: configuredAppHostPath,
appHostPid: 125881,
resources: [],
},
]));
assert.strictEqual(repository.workspaceAppHost?.appHostPath, configuredAppHostPath);
Expand Down Expand Up @@ -568,7 +561,13 @@ suite('AppHostDataRepository', () => {
status: 'possibly-unbuildable',
},
]));
await waitForAppHostDiscovery();
// 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 !== undefined,
'expected workspace AppHost path to be set after aspire ls discovery');

assert.strictEqual(repository.viewMode, 'workspace');
assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj');
Expand Down Expand Up @@ -678,13 +677,12 @@ suite('AppHostDataRepository', () => {
await waitForMicrotasks();

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',
Comment thread
mitchdenny marked this conversation as resolved.
appHostPid: 125881,
cliPid: 125738,
dashboardUrl: 'https://localhost:17193/login?t=061212',
resources: [],
}]));

assert.strictEqual(repository.workspaceResources.length, 0);
Expand Down Expand Up @@ -758,7 +756,6 @@ suite('AppHostDataRepository', () => {
appHostPid: 125881,
cliPid: 125738,
dashboardUrl: 'https://localhost:17193/login?t=061212',
resources: [],
},
]));

Expand Down Expand Up @@ -884,7 +881,6 @@ suite('AppHostDataRepository', () => {
{
appHostPath: '/workspace/labs/ops/apphost.cs',
appHostPid: 125881,
resources: [],
},
]));

Expand Down Expand Up @@ -1001,7 +997,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);

Expand All @@ -1020,52 +1016,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 <path>`
// 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');
Expand Down Expand Up @@ -1096,7 +1098,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');
Expand All @@ -1114,7 +1116,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();
Expand Down Expand Up @@ -1146,7 +1148,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);
Expand Down Expand Up @@ -1210,6 +1212,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', () => {
Expand Down
Loading
Loading