Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 6 additions & 6 deletions extensions/vscode-containers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -438,27 +438,27 @@
},
{
"command": "vscode-containers.containers.composeGroup.logs",
"when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i",
"when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)",
"group": "composeGroup_1_general@1"
},
{
"command": "vscode-containers.containers.composeGroup.start",
"when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i",
"when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)",
"group": "composeGroup_1_general@2"
},
{
"command": "vscode-containers.containers.composeGroup.stop",
"when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i",
"when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)",
"group": "composeGroup_1_general@3"
},
{
"command": "vscode-containers.containers.composeGroup.restart",
"when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i",
"when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)",
"group": "composeGroup_2_destructive@1"
},
{
"command": "vscode-containers.containers.composeGroup.down",
"when": "view == vscode-containers.views.containers && viewItem =~ /composeGroup$/i",
"when": "view == vscode-containers.views.containers && (viewItem =~ /composeGroup$/i || viewItem == composeProfileGroup)",
"group": "composeGroup_2_destructive@2"
},
{
Expand Down Expand Up @@ -1846,7 +1846,7 @@
"default": [
{
"label": "Compose Logs",
"template": "${composeCommand} ${configurationFile} ${projectName} ${environmentFile} logs --tail 1000 -f"
"template": "${composeCommand} ${profileList} ${configurationFile} ${projectName} ${environmentFile} logs --tail 1000 -f ${serviceList}"
}
],
"description": "%vscode-containers.config.template.composeLogs.description%",
Expand Down
373 changes: 228 additions & 145 deletions extensions/vscode-containers/src/commands/containers/composeGroup.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export async function selectLogsCommand(context: IActionContext, containerName:
);
}

export async function selectComposeLogsCommand(context: IActionContext, folder: vscode.WorkspaceFolder | vscode.Uri, configurationFile?: string, projectName?: string, envFile?: string): Promise<VoidCommandResponse> {
export async function selectComposeLogsCommand(context: IActionContext, folder: vscode.WorkspaceFolder | vscode.Uri, configurationFile?: string, projectName?: string, envFile?: string, profileList?: string, serviceList?: string): Promise<VoidCommandResponse> {
const orchestratorClient = await ext.orchestratorManager.getClient();
let fullComposeCommand: string;
if (isComposeV2ableOrchestratorClient(orchestratorClient) && orchestratorClient.composeV2) {
Expand All @@ -99,7 +99,9 @@ export async function selectComposeLogsCommand(context: IActionContext, folder:
'configurationFile': configurationFile || '',
'projectName': projectName ? `-p "${projectName}"` : '',
'environmentFile': envFile ? `--env-file "${envFile}"` : '',
'composeCommand': fullComposeCommand
'composeCommand': fullComposeCommand,
'profileList': profileList || '',
'serviceList': serviceList || ''
}
);
}
Expand Down
199 changes: 111 additions & 88 deletions extensions/vscode-containers/src/test/commands/composeGroup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,124 +3,147 @@
* Licensed under the MIT License. See LICENSE.md in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils';
import { expect } from 'chai';
import { getComposeEnvFile, getComposeFiles, getComposeProjectName, getComposeWorkingDirectory } from '../../commands/containers/composeGroup';
import { findContainerWithComposeConfig, getProjectLabel, pickComposeProfileCommandScope, resolveComposeProfileArguments } from '../../commands/containers/composeGroup';
import { ComposeProfileGroupTreeItem } from '../../tree/containers/ComposeProfileGroupTreeItem';
import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTreeItem';
import { ContainerTreeItem } from '../../tree/containers/ContainerTreeItem';
import { ComposeConfigFilesLabel } from '../../utils/composeLabels';

function createMockProfileGroup(profileName: string | undefined, services: string[], exclusiveServices: string[]): ComposeProfileGroupTreeItem {
const node = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem;
Object.defineProperty(node, 'profileName', { value: profileName });
Object.defineProperty(node, 'label', { value: profileName ?? 'Default' });
node.getServiceNames = () => services;
node.getExclusiveServiceNames = () => exclusiveServices;
return node;
}

function createMockContext(pickIndex: number, verifyPicks?: (picks: IAzureQuickPickItem<'profile' | 'services' | 'exclusive'>[]) => void): IActionContext {
return {
errorHandling: {},
ui: {
showQuickPick: async (picks: IAzureQuickPickItem<'profile' | 'services' | 'exclusive'>[]) => {
if (verifyPicks) {
verifyPicks(picks);
}
return picks[pickIndex];
}
}
} as unknown as IActionContext;
}

suite("(unit) composeGroup", () => {
suite("getComposeFiles", () => {
test("Returns all files when multiple absolute config files are present", () => {
// Regression test for https://github.com/microsoft/vscode-containers/issues/522
const labels = {
'com.docker.compose.project.config_files': '/abs/path/docker-compose.base.yml,/abs/path/docker-compose.local.yml',
};

const result = getComposeFiles(labels);

expect(result).to.deep.equal([
'/abs/path/docker-compose.base.yml',
'/abs/path/docker-compose.local.yml',
]);
});
suite("profile sub-group utilities", () => {
test("getProjectLabel returns parent label for ComposeProfileGroupTreeItem", () => {
const parent = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem;
Object.defineProperty(parent, 'label', { value: 'my-compose-project' });

test("Returns three files when three absolute config files are present", () => {
const labels = {
'com.docker.compose.project.config_files': '/a/one.yml,/a/two.yml,/a/three.yml',
};
const profileNode = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem;
Object.defineProperty(profileNode, 'label', { value: 'dev-profile' });
Object.defineProperty(profileNode, 'parent', { value: parent });

const result = getComposeFiles(labels);

expect(result).to.deep.equal(['/a/one.yml', '/a/two.yml', '/a/three.yml']);
expect(getProjectLabel(profileNode)).to.equal('my-compose-project');
expect(getProjectLabel(parent)).to.equal('my-compose-project');
});

test("Returns a single absolute file unchanged", () => {
const labels = {
'com.docker.compose.project.config_files': '/abs/path/docker-compose.yml',
};

const result = getComposeFiles(labels);
test("findContainerWithComposeConfig searches direct children and profile sub-groups", () => {
const containerWithLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem;
Object.defineProperty(containerWithLabels, 'labels', {
value: { [ComposeConfigFilesLabel]: '/path/to/docker-compose.yml' }
});

expect(result).to.deep.equal(['/abs/path/docker-compose.yml']);
});
const containerWithoutLabels = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem;
Object.defineProperty(containerWithoutLabels, 'labels', { value: {} });

test("Reduces relative paths to their basename", () => {
const labels = {
'com.docker.compose.project.config_files': 'subdir/docker-compose.base.yml,subdir/docker-compose.local.yml',
};
const profileGroup = Object.create(ComposeProfileGroupTreeItem.prototype) as ComposeProfileGroupTreeItem;
Object.defineProperty(profileGroup, 'ChildTreeItems', { value: [containerWithoutLabels, containerWithLabels] });

const result = getComposeFiles(labels);
const rootGroup = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem;
Object.defineProperty(rootGroup, 'ChildTreeItems', { value: [profileGroup] });

expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']);
const result = findContainerWithComposeConfig(rootGroup);
expect(result).to.equal(containerWithLabels);
});
});

// Node's `path` resolves to `path.win32` only on Windows, so absolute/relative
// Windows-path handling in getComposeFiles is only correct on a Windows host
// (which is also the only host where a Windows docker engine emits such labels).
if (process.platform === 'win32') {
test("Returns all files when multiple absolute Windows config files are present", () => {
// Regression test for https://github.com/microsoft/vscode-containers/issues/522
const labels = {
'com.docker.compose.project.config_files': 'C:\\path\\docker-compose.base.yml,C:\\path\\docker-compose.local.yml',
};

const result = getComposeFiles(labels);

expect(result).to.deep.equal([
'C:\\path\\docker-compose.base.yml',
'C:\\path\\docker-compose.local.yml',
]);
suite("profile scoping and argument resolution", () => {
test("pickComposeProfileCommandScope presents correct picks and descriptions with exclusive services", async () => {
const node = createMockProfileGroup('backend', ['api', 'worker', 'db'], ['worker', 'db']);
const context = createMockContext(0, (picks) => {
expect(picks.length).to.equal(3);
expect(picks[0].data).to.equal('profile');
expect(picks[0].description).to.include('--profile backend');
expect(picks[1].data).to.equal('services');
expect(picks[1].description).to.include('api worker db');
expect(picks[2].data).to.equal('exclusive');
expect(picks[2].description).to.include('worker db');
});

test("Returns a single absolute Windows file unchanged", () => {
const labels = {
'com.docker.compose.project.config_files': 'C:\\path\\docker-compose.yml',
};

const result = getComposeFiles(labels);
const scope = await pickComposeProfileCommandScope(context, node, 'down');
expect(scope).to.equal('profile');
});

expect(result).to.deep.equal(['C:\\path\\docker-compose.yml']);
test("pickComposeProfileCommandScope shows warning description when no exclusive services exist", async () => {
const node = createMockProfileGroup('frontend', ['web', 'proxy'], []);
const context = createMockContext(2, (picks) => {
expect(picks[2].description).to.include('No services are exclusive to this profile');
});

test("Reduces relative Windows paths to their basename", () => {
const labels = {
'com.docker.compose.project.config_files': 'subdir\\docker-compose.base.yml,subdir\\docker-compose.local.yml',
};

const result = getComposeFiles(labels);
const scope = await pickComposeProfileCommandScope(context, node, 'start');
expect(scope).to.equal('exclusive');
});

expect(result).to.deep.equal(['docker-compose.base.yml', 'docker-compose.local.yml']);
test("resolveComposeProfileArguments bypasses prompt for standard container groups", async () => {
const rootGroup = Object.create(ContainerGroupTreeItem.prototype) as ContainerGroupTreeItem;
const context = createMockContext(0, () => {
expect.fail("Should not invoke QuickPick for standard container group");
});
}

test("Returns undefined when the config files label is absent", () => {
const result = getComposeFiles({});

expect(result).to.be.undefined;
const result = await resolveComposeProfileArguments(context, rootGroup, 'down');
expect(result.profileArg).to.be.undefined;
expect(result.servicesArg).to.be.undefined;
});
});

suite("other label accessors", () => {
const labels = {
'com.docker.compose.project': 'myproject',
'com.docker.compose.project.working_dir': '/abs/path',
'com.docker.compose.project.environment_file': '/abs/path/.env.local',
};
test("resolveComposeProfileArguments resolves --profile scope", async () => {
const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']);
const context = createMockContext(0); // Pick 0: 'profile'

test("getComposeProjectName returns the project name", () => {
expect(getComposeProjectName(labels)).to.equal('myproject');
const result = await resolveComposeProfileArguments(context, node, 'up');
expect(result.profileArg).to.deep.equal(['debug']);
expect(result.servicesArg).to.be.undefined;
});

test("getComposeWorkingDirectory returns the working directory", () => {
expect(getComposeWorkingDirectory(labels)).to.equal('/abs/path');
test("resolveComposeProfileArguments resolves specific profile services scope", async () => {
const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']);
const context = createMockContext(1); // Pick 1: 'services'

const result = await resolveComposeProfileArguments(context, node, 'restart');
expect(result.profileArg).to.be.undefined;
expect(result.servicesArg).to.deep.equal(['app', 'tester']);
});

test("getComposeEnvFile returns the environment file", () => {
expect(getComposeEnvFile(labels)).to.equal('/abs/path/.env.local');
test("resolveComposeProfileArguments resolves exclusive services scope", async () => {
const node = createMockProfileGroup('debug', ['app', 'tester'], ['tester']);
const context = createMockContext(2); // Pick 2: 'exclusive'

const result = await resolveComposeProfileArguments(context, node, 'stop');
expect(result.profileArg).to.be.undefined;
expect(result.servicesArg).to.deep.equal(['tester']);
});

test("Accessors return undefined when their label is absent", () => {
expect(getComposeProjectName({})).to.be.undefined;
expect(getComposeWorkingDirectory({})).to.be.undefined;
expect(getComposeEnvFile({})).to.be.undefined;
test("resolveComposeProfileArguments throws error when picking exclusive scope with no exclusive services", async () => {
const node = createMockProfileGroup('shared-only', ['redis'], []);
const context = createMockContext(2); // Pick 2: 'exclusive'

try {
await resolveComposeProfileArguments(context, node, 'down');
expect.fail("Expected an error to be thrown for empty exclusive services");
} catch (err: unknown) {
expect((err as Error).message).to.include('There are no services exclusive to the "shared-only" profile.');
expect(context.errorHandling.suppressReportIssue).to.be.true;
}
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE.md in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { expect } from 'chai';
import { getComposeProfilesForContainer } from '../../../tree/containers/composeProfiles';
import { ContainerTreeItem } from '../../../tree/containers/ContainerTreeItem';
import { ComposeServiceLabel } from '../../../utils/composeLabels';

suite("(unit) composeProfiles", () => {
suite("getComposeProfilesForContainer", () => {
test("Returns profiles assigned to the container service", () => {
const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem;
Object.defineProperty(container, 'labels', {
value: { [ComposeServiceLabel]: 'web' }
});

const serviceProfiles = new Map<string, string[]>([
['web', ['frontend', 'debug']],
['db', ['backend']]
]);

const profiles = getComposeProfilesForContainer(container, serviceProfiles);
expect(profiles).to.deep.equal(['frontend', 'debug']);
});

test("Returns empty array when service has no assigned profiles", () => {
const container = Object.create(ContainerTreeItem.prototype) as ContainerTreeItem;
Object.defineProperty(container, 'labels', {
value: { [ComposeServiceLabel]: 'cache' }
});

const serviceProfiles = new Map<string, string[]>([
['web', ['frontend']]
]);

const profiles = getComposeProfilesForContainer(container, serviceProfiles);
expect(profiles).to.deep.equal([]);
});
});
});
Loading