From 41798a0402b234e4c4c1dbcd97dfe491fda38264 Mon Sep 17 00:00:00 2001 From: Federico Bozzini Date: Mon, 27 Jul 2026 16:32:07 +0100 Subject: [PATCH 1/3] feat: support `topo configure` operation Signed-off-by: Federico Bozzini --- README.md | 10 ++-- package.json | 46 +++++++++++++-- src/actions/configure.test.ts | 102 ++++++++++++++++++++++++++++++++++ src/actions/configure.ts | 77 +++++++++++++++++++++++++ src/commands.test.ts | 34 ++++++++++++ src/commands.ts | 10 ++++ src/extension.ts | 3 + 7 files changed, 271 insertions(+), 11 deletions(-) create mode 100644 src/actions/configure.test.ts create mode 100644 src/actions/configure.ts diff --git a/README.md b/README.md index b00c2efe..515ca250 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,13 @@ Use the inline buttons on a service in the Projects tree to manage individual co | **Open Container Shell** | Open a VS Code terminal connected to a running Linux Host container. | | **Open in Browser** | Open a running container's web endpoint in your browser. | -## Deploy and Stop +## Configure, Deploy, and Stop -Deploy or stop a project on the selected target. A deploy operation builds container images, transfers them to the target, and starts services. You can trigger either operation from: +Configure a project's parameters, or deploy or stop it on the selected target. A deploy operation builds container images, transfers them to the target, and starts services. You can trigger these operations from: - Running **Topo: Deploy** from the Command Palette, then selecting a `compose.yaml` file from the workspace. -- Right-clicking `compose.yaml` in the Explorer or editor tab and selecting **Topo Deploy** or **Topo Stop**. -- Using the inline **Deploy** or **Stop** buttons on a project in the **Projects** view. +- Right-clicking `compose.yaml` in the Explorer or editor tab and selecting **Topo Configure**, **Topo Deploy**, or **Topo Stop**. +- Using the inline **Configure**, **Deploy**, or **Stop** buttons on a project in the **Projects** view. ## Project Management @@ -114,7 +114,7 @@ The **Projects** view appears in the **Topo** activity bar container and lists w When a target is selected, each project can expand to show deployed containers for that project, grouped by processing domain. -Use the inline buttons on a project row to deploy or stop that project's compose file on the selected target. These actions behave the same as running **Topo Deploy** or **Topo Stop** from the context menu of the project's compose file. +Use the inline buttons on a project row to configure, deploy, or stop that project's compose file. These actions behave the same as running **Topo Configure**, **Topo Deploy**, or **Topo Stop** from the context menu of the project's compose file. ### Initialize a Project diff --git a/package.json b/package.json index 4cd590b2..1757e6cf 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,11 @@ "title": "Deploy", "category": "Topo" }, + { + "command": "topo.configure.context", + "title": "Topo Configure", + "icon": "$(gear)" + }, { "command": "topo.deploy.context", "title": "Topo Deploy", @@ -163,6 +168,12 @@ "title": "Topo Stop", "icon": "$(stop)" }, + { + "command": "topo.configureProject", + "title": "Configure", + "icon": "$(gear)", + "category": "Topo" + }, { "command": "topo.deployProject", "title": "Deploy", @@ -222,26 +233,36 @@ "menus": { "editor/title/context": [ { - "command": "topo.deploy.context", + "command": "topo.configure.context", "group": "topo@1", "when": "resourceFilename =~ /^compose\\.ya?ml$/" }, { - "command": "topo.stop.context", + "command": "topo.deploy.context", "group": "topo@2", "when": "resourceFilename =~ /^compose\\.ya?ml$/" + }, + { + "command": "topo.stop.context", + "group": "topo@3", + "when": "resourceFilename =~ /^compose\\.ya?ml$/" } ], "explorer/context": [ { - "command": "topo.deploy.context", + "command": "topo.configure.context", "group": "topo@1", "when": "resourceFilename =~ /^compose\\.ya?ml$/" }, { - "command": "topo.stop.context", + "command": "topo.deploy.context", "group": "topo@2", "when": "resourceFilename =~ /^compose\\.ya?ml$/" + }, + { + "command": "topo.stop.context", + "group": "topo@3", + "when": "resourceFilename =~ /^compose\\.ya?ml$/" } ], "commandPalette": [ @@ -293,6 +314,10 @@ "command": "topo.openContainerInBrowser", "when": "false" }, + { + "command": "topo.configure.context", + "when": "false" + }, { "command": "topo.deploy.context", "when": "false" @@ -301,6 +326,10 @@ "command": "topo.stop.context", "when": "false" }, + { + "command": "topo.configureProject", + "when": "false" + }, { "command": "topo.deployProject", "when": "false" @@ -373,14 +402,19 @@ "group": "inline@5" }, { - "command": "topo.deployProject", + "command": "topo.configureProject", "when": "view === topo.projects && viewItem =~ /Project/", "group": "inline@1" }, { - "command": "topo.stopProject", + "command": "topo.deployProject", "when": "view === topo.projects && viewItem =~ /Project/", "group": "inline@2" + }, + { + "command": "topo.stopProject", + "when": "view === topo.projects && viewItem =~ /Project/", + "group": "inline@3" } ], "view/title": [ diff --git a/src/actions/configure.test.ts b/src/actions/configure.test.ts new file mode 100644 index 00000000..66daafde --- /dev/null +++ b/src/actions/configure.test.ts @@ -0,0 +1,102 @@ +import os from 'node:os'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import { mock, MockProxy } from 'vitest-mock-extended'; +import { unloaded } from '../util/loadable'; +import { TaskExecutor } from '../util/taskExecutor'; +import { ProjectTreeItem } from '../views/treeItems/projectTreeItem'; +import { Configure, configure } from './configure'; + +describe('Configure', () => { + const projectPath = path.join(os.tmpdir(), 'demo'); + const composeFileUri = vscode.Uri.file( + path.join(projectPath, 'compose.yml'), + ); + let taskExecutor: MockProxy; + let configureAction: Configure; + + function projectTreeItem(): ProjectTreeItem { + return new ProjectTreeItem( + { + name: 'demo', + uri: vscode.Uri.file(projectPath), + composeFileUri, + workspaceIndex: 0, + workspaceName: 'workspace', + }, + false, + unloaded(), + ); + } + + function expectConfigureTask(task: vscode.Task): void { + expect(task.name).toBe('Configure demo'); + expect(task.execution).toMatchObject({ + process: 'topo', + args: ['configure', '--file', 'compose.yml'], + options: { cwd: projectPath }, + }); + } + + beforeEach(() => { + taskExecutor = mock(); + configureAction = new Configure(taskExecutor); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('configures the selected compose file', async () => { + await configureAction.configureContextCommandHandler(composeFileUri); + + expect(taskExecutor.run).toHaveBeenCalledOnce(); + expectConfigureTask(taskExecutor.run.mock.calls[0][0]); + }); + + it('configures the project tree item compose file', async () => { + await configureAction.configureProjectCommandHandler(projectTreeItem()); + + expect(taskExecutor.run).toHaveBeenCalledOnce(); + expectConfigureTask(taskExecutor.run.mock.calls[0][0]); + }); + + it('throws when the context command has no compose file', async () => { + await expect( + configureAction.configureContextCommandHandler(), + ).rejects.toThrow( + 'No compose.yaml or compose.yml selected for configuration', + ); + + expect(taskExecutor.run).not.toHaveBeenCalled(); + }); + + it('throws when the project command has no project tree item', async () => { + await expect( + configureAction.configureProjectCommandHandler(undefined), + ).rejects.toThrow( + 'No compose.yaml or compose.yml selected for configuration', + ); + + expect(taskExecutor.run).not.toHaveBeenCalled(); + }); + + it('reports successful configuration', async () => { + await configure(taskExecutor, composeFileUri.fsPath); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + 'demo configured successfully.', + ); + }); + + it('reports task failure', async () => { + taskExecutor.run.mockRejectedValueOnce(new Error('configure failed')); + + await configure(taskExecutor, composeFileUri.fsPath); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'Configuring demo failed: configure failed', + ); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/configure.ts b/src/actions/configure.ts new file mode 100644 index 00000000..14f4b339 --- /dev/null +++ b/src/actions/configure.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; +import * as vscode from 'vscode'; +import { getErrorMessage } from '../util/getErrorMessage'; +import { createProcessTask } from '../util/task'; +import { TaskExecutor } from '../util/taskExecutor'; +import { ProjectTreeItem } from '../views/treeItems/projectTreeItem'; + +const viewLogsItem: vscode.MessageItem = { + title: 'View Logs', +}; + +export class Configure { + constructor(private readonly taskExecutor: TaskExecutor) {} + + public async configureContextCommandHandler( + resource?: vscode.Uri, + ): Promise { + if (!resource) { + throw new Error( + 'No compose.yaml or compose.yml selected for configuration', + ); + } + + await configure(this.taskExecutor, resource.fsPath); + } + + public async configureProjectCommandHandler( + treeNode: unknown, + ): Promise { + if (!(treeNode instanceof ProjectTreeItem)) { + throw new Error( + 'No compose.yaml or compose.yml selected for configuration', + ); + } + + await this.configureContextCommandHandler(treeNode.composeFileUri); + } +} + +export async function configure( + taskExecutor: TaskExecutor, + composeFilePath: string, +): Promise { + const projectName = path.basename(path.dirname(composeFilePath)); + const task = createProcessTask( + `Configure ${projectName}`, + ['topo', 'configure', '--file', path.basename(composeFilePath)], + { + cwd: path.dirname(composeFilePath), + }, + ); + const taskName = task.name; + + try { + await taskExecutor.run(task); + } catch (error: unknown) { + const terminal = vscode.window.terminals.find( + (candidate) => candidate.name === taskName, + ); + const actions: vscode.MessageItem[] = []; + if (terminal) { + actions.push(viewLogsItem); + } + const choice = await vscode.window.showErrorMessage( + `Configuring ${projectName} failed: ${getErrorMessage(error)}`, + ...actions, + ); + if (choice?.title === viewLogsItem.title) { + terminal?.show(); + } + return; + } + + vscode.window.showInformationMessage( + `${projectName} configured successfully.`, + ); +} diff --git a/src/commands.test.ts b/src/commands.test.ts index 16dbacc8..5ce769f6 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -19,6 +19,7 @@ import { ProjectController } from './controllers/projectController'; import { ConnectViaSSH } from './actions/connectViaSSH'; import { OpenContainerInBrowser } from './actions/openContainerInBrowser'; import { OpenSettings } from './actions/openSettings'; +import { Configure } from './actions/configure'; vi.mock('./util/logger'); @@ -29,6 +30,7 @@ describe('commands', () => { targetController: mock(), projectInit: mock(), projectClone: mock(), + configure: mock(), deploy: mock(), stop: mock(), openContainerShell: mock(), @@ -95,6 +97,14 @@ describe('commands', () => { handlers.projectInit.initProjectCommandHandler, ], [commands.cloneProject, handlers.projectClone.cloneCommandHandler], + [ + commands.configure, + handlers.configure.configureContextCommandHandler, + ], + [ + commands.configureProject, + handlers.configure.configureProjectCommandHandler, + ], [commands.deploy, handlers.deploy.deployCommandHandler], [ commands.deployContext, @@ -178,6 +188,30 @@ describe('commands', () => { ).toHaveBeenCalledWith(); }); + it('configure project calls the project configure handler with the tree node', async () => { + const composeFileUri = vscode.Uri.file( + '/fake/workspace/demo/compose.yaml', + ); + const projectItem = new ProjectTreeItem( + { + name: 'demo', + uri: vscode.Uri.file('/fake/workspace/demo'), + composeFileUri, + workspaceIndex: 0, + workspaceName: 'workspace', + }, + false, + unloaded(), + ); + commands.register(handlers); + + await executeCommand(commands.configureProject, projectItem); + + expect( + handlers.configure.configureProjectCommandHandler, + ).toHaveBeenCalledWith(projectItem); + }); + it('deploy project calls the project deploy handler with the tree node', async () => { const composeFileUri = vscode.Uri.file( '/fake/workspace/demo/compose.yaml', diff --git a/src/commands.ts b/src/commands.ts index af4acdb7..05fdc4de 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -15,6 +15,7 @@ import { ProjectController } from './controllers/projectController'; import { ConnectViaSSH } from './actions/connectViaSSH'; import { OpenContainerInBrowser } from './actions/openContainerInBrowser'; import { OpenSettings } from './actions/openSettings'; +import { Configure } from './actions/configure'; function command(id: string): string { return `${PACKAGE_NAME}.${id}`; @@ -33,6 +34,8 @@ export const cloneProject = command('cloneProject'); export const deploy = command('deploy'); export const deployContext = command('deploy.context'); export const deployProject = command('deployProject'); +export const configure = command('configure.context'); +export const configureProject = command('configureProject'); export const stop = command('stop.context'); export const stopProject = command('stopProject'); export const openContainerShell = command('openContainerShell'); @@ -52,6 +55,7 @@ export interface CommandHandlers { targetController: TargetController; projectInit: ProjectInit; projectClone: ProjectClone; + configure: Configure; deploy: Deploy; stop: Stop; openContainerShell: OpenContainerShell; @@ -93,6 +97,12 @@ export function register(handlers: CommandHandlers): vscode.Disposable { vscode.commands.registerCommand(cloneProject, () => handlers.projectClone.cloneCommandHandler(), ), + vscode.commands.registerCommand(configure, (resource?: vscode.Uri) => + handlers.configure.configureContextCommandHandler(resource), + ), + vscode.commands.registerCommand(configureProject, (treeNode) => + handlers.configure.configureProjectCommandHandler(treeNode), + ), vscode.commands.registerCommand(deploy, () => handlers.deploy.deployCommandHandler(), ), diff --git a/src/extension.ts b/src/extension.ts index 96634cf9..aa41c209 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { ConnectViaSSH } from './actions/connectViaSSH'; import { OpenContainerInBrowser } from './actions/openContainerInBrowser'; import { OpenSettings } from './actions/openSettings'; import { Config } from './services/config'; +import { Configure } from './actions/configure'; const SELECTED_TARGET_REFRESH_INTERVAL_MS = 60_000; @@ -107,6 +108,7 @@ export async function activate( const projectInit = new ProjectInit(topoCli); const taskExecutor = new TaskExecutor(topoCli); const projectClone = new ProjectClone(topoCli, targetModel, taskExecutor); + const configure = new Configure(taskExecutor); const deploy = new Deploy( taskExecutor, targetModel, @@ -131,6 +133,7 @@ export async function activate( projectController, targetController, projectInit, + configure, deploy, stop, openContainerShell, From 787192f8aca9ccbfca1c655c6b490097ee758e67 Mon Sep 17 00:00:00 2001 From: Federico Bozzini Date: Mon, 27 Jul 2026 16:45:20 +0100 Subject: [PATCH 2/3] Windows tests Signed-off-by: Federico Bozzini --- src/actions/configure.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions/configure.test.ts b/src/actions/configure.test.ts index 66daafde..ebb5c5a3 100644 --- a/src/actions/configure.test.ts +++ b/src/actions/configure.test.ts @@ -8,10 +8,10 @@ import { ProjectTreeItem } from '../views/treeItems/projectTreeItem'; import { Configure, configure } from './configure'; describe('Configure', () => { - const projectPath = path.join(os.tmpdir(), 'demo'); const composeFileUri = vscode.Uri.file( - path.join(projectPath, 'compose.yml'), + path.join(os.tmpdir(), 'demo', 'compose.yml'), ); + const projectPath = path.dirname(composeFileUri.fsPath); let taskExecutor: MockProxy; let configureAction: Configure; From d042e001e1f1fe2551c9a7f0c4f59df202f0fb47 Mon Sep 17 00:00:00 2001 From: Federico Bozzini Date: Tue, 28 Jul 2026 10:46:14 +0100 Subject: [PATCH 3/3] Refactor assertion Signed-off-by: Federico Bozzini --- src/actions/configure.test.ts | 4 +- src/actions/configure.ts | 9 +--- src/actions/containerLifecycle.ts | 2 +- src/actions/deploy.test.ts | 4 +- src/actions/deploy.ts | 9 +--- src/actions/openContainerInBrowser.ts | 2 +- src/actions/openContainerShell.ts | 2 +- src/actions/stop.test.ts | 2 +- src/actions/stop.ts | 7 +-- .../util}/assertContainerTreeItem.test.ts | 2 +- .../util}/assertContainerTreeItem.ts | 2 +- .../util/assertProjectTreeItem.test.ts | 54 +++++++++++++++++++ src/actions/util/assertProjectTreeItem.ts | 17 ++++++ 13 files changed, 85 insertions(+), 31 deletions(-) rename src/{views/treeItems => actions/util}/assertContainerTreeItem.test.ts (96%) rename src/{views/treeItems => actions/util}/assertContainerTreeItem.ts (89%) create mode 100644 src/actions/util/assertProjectTreeItem.test.ts create mode 100644 src/actions/util/assertProjectTreeItem.ts diff --git a/src/actions/configure.test.ts b/src/actions/configure.test.ts index ebb5c5a3..8065a1a9 100644 --- a/src/actions/configure.test.ts +++ b/src/actions/configure.test.ts @@ -74,9 +74,7 @@ describe('Configure', () => { it('throws when the project command has no project tree item', async () => { await expect( configureAction.configureProjectCommandHandler(undefined), - ).rejects.toThrow( - 'No compose.yaml or compose.yml selected for configuration', - ); + ).rejects.toThrow('This operation cannot be performed on this item'); expect(taskExecutor.run).not.toHaveBeenCalled(); }); diff --git a/src/actions/configure.ts b/src/actions/configure.ts index 14f4b339..33ab727f 100644 --- a/src/actions/configure.ts +++ b/src/actions/configure.ts @@ -3,7 +3,7 @@ import * as vscode from 'vscode'; import { getErrorMessage } from '../util/getErrorMessage'; import { createProcessTask } from '../util/task'; import { TaskExecutor } from '../util/taskExecutor'; -import { ProjectTreeItem } from '../views/treeItems/projectTreeItem'; +import { assertProjectTreeItem } from './util/assertProjectTreeItem'; const viewLogsItem: vscode.MessageItem = { title: 'View Logs', @@ -27,12 +27,7 @@ export class Configure { public async configureProjectCommandHandler( treeNode: unknown, ): Promise { - if (!(treeNode instanceof ProjectTreeItem)) { - throw new Error( - 'No compose.yaml or compose.yml selected for configuration', - ); - } - + assertProjectTreeItem(treeNode); await this.configureContextCommandHandler(treeNode.composeFileUri); } } diff --git a/src/actions/containerLifecycle.ts b/src/actions/containerLifecycle.ts index 2114ec33..1a21989a 100644 --- a/src/actions/containerLifecycle.ts +++ b/src/actions/containerLifecycle.ts @@ -2,7 +2,7 @@ import { ProjectController } from '../controllers/projectController'; import { isWrappedError } from '../errors/wrappedError'; import { ContainerCommands } from '../services/containerCommands'; import { showAndLogError } from '../util/showAndLog'; -import { assertContainerTreeItem } from '../views/treeItems/assertContainerTreeItem'; +import { assertContainerTreeItem } from './util/assertContainerTreeItem'; const containerOperationMethods = { start: 'startContainer', diff --git a/src/actions/deploy.test.ts b/src/actions/deploy.test.ts index f9b49dbd..63d41008 100644 --- a/src/actions/deploy.test.ts +++ b/src/actions/deploy.test.ts @@ -335,9 +335,7 @@ describe('Deploy', () => { it('throws when project command is called without a project tree item', async () => { await expect( deployAction.deployProjectCommandHandler(undefined), - ).rejects.toThrow( - 'No compose.yaml or compose.yml selected for deployment', - ); + ).rejects.toThrow('This operation cannot be performed on this item'); expect(taskExecutor.run).not.toHaveBeenCalled(); expect( diff --git a/src/actions/deploy.ts b/src/actions/deploy.ts index d3bb9b69..1145c9bd 100644 --- a/src/actions/deploy.ts +++ b/src/actions/deploy.ts @@ -14,7 +14,7 @@ import { getPreferredComposeFiles, type ComposeFileMetadata, } from '../util/composeFile'; -import { ProjectTreeItem } from '../views/treeItems/projectTreeItem'; +import { assertProjectTreeItem } from './util/assertProjectTreeItem'; import { assertTargetConnected, assertTargetSelected, @@ -93,12 +93,7 @@ export class Deploy { } public async deployProjectCommandHandler(treeNode: unknown): Promise { - if (!(treeNode instanceof ProjectTreeItem)) { - throw new Error( - 'No compose.yaml or compose.yml selected for deployment', - ); - } - + assertProjectTreeItem(treeNode); await this.deployContextCommandHandler(treeNode.composeFileUri); } diff --git a/src/actions/openContainerInBrowser.ts b/src/actions/openContainerInBrowser.ts index 89eb7d9a..8911c590 100644 --- a/src/actions/openContainerInBrowser.ts +++ b/src/actions/openContainerInBrowser.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { getContainerWebEndpoints } from '../util/getContainerWebEndpoints'; import type { ContainerWebEndpoint } from '../util/getContainerWebEndpoints'; -import { assertContainerTreeItem } from '../views/treeItems/assertContainerTreeItem'; +import { assertContainerTreeItem } from './util/assertContainerTreeItem'; type EndpointQuickPickItem = vscode.QuickPickItem & { endpoint: ContainerWebEndpoint; diff --git a/src/actions/openContainerShell.ts b/src/actions/openContainerShell.ts index 5df3875f..4b965e56 100644 --- a/src/actions/openContainerShell.ts +++ b/src/actions/openContainerShell.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { ContainerItem } from '../util/types'; import { ContainerCommands } from '../services/containerCommands'; -import { assertContainerTreeItem } from '../views/treeItems/assertContainerTreeItem'; +import { assertContainerTreeItem } from './util/assertContainerTreeItem'; export class OpenContainerShell { constructor(private readonly containerCommands: ContainerCommands) {} diff --git a/src/actions/stop.test.ts b/src/actions/stop.test.ts index cbe09ba1..abd2011d 100644 --- a/src/actions/stop.test.ts +++ b/src/actions/stop.test.ts @@ -177,7 +177,7 @@ describe('Stop', () => { it('throws when project command is called without a project tree item', async () => { await expect( stopAction.stopProjectCommandHandler(undefined), - ).rejects.toThrow('No compose.yaml or compose.yml selected for stop'); + ).rejects.toThrow('This operation cannot be performed on this item'); expect(taskExecutor.run).not.toHaveBeenCalled(); expect( diff --git a/src/actions/stop.ts b/src/actions/stop.ts index 03afa298..f6edff7b 100644 --- a/src/actions/stop.ts +++ b/src/actions/stop.ts @@ -6,7 +6,7 @@ import { TaskExecutor } from '../util/taskExecutor'; import { showAndLogWarning } from '../util/showAndLog'; import { TargetModel } from '../models/targetModel'; import { ProjectController } from '../controllers/projectController'; -import { ProjectTreeItem } from '../views/treeItems/projectTreeItem'; +import { assertProjectTreeItem } from './util/assertProjectTreeItem'; import { isWrappedError } from '../errors/wrappedError'; import { assertTargetConnected, @@ -47,10 +47,7 @@ export class Stop { } public async stopProjectCommandHandler(treeNode: unknown): Promise { - if (!(treeNode instanceof ProjectTreeItem)) { - throw new Error('No compose.yaml or compose.yml selected for stop'); - } - + assertProjectTreeItem(treeNode); await this.stopCommandHandler(treeNode.composeFileUri); } } diff --git a/src/views/treeItems/assertContainerTreeItem.test.ts b/src/actions/util/assertContainerTreeItem.test.ts similarity index 96% rename from src/views/treeItems/assertContainerTreeItem.test.ts rename to src/actions/util/assertContainerTreeItem.test.ts index 98378f3e..e5caad68 100644 --- a/src/views/treeItems/assertContainerTreeItem.test.ts +++ b/src/actions/util/assertContainerTreeItem.test.ts @@ -1,5 +1,5 @@ import { assertContainerTreeItem } from './assertContainerTreeItem'; -import { ContainerTreeItem } from './containerTreeItem'; +import { ContainerTreeItem } from '../../views/treeItems/containerTreeItem'; import { logger } from '../../util/logger'; import { ContainerItem } from '../../util/types'; diff --git a/src/views/treeItems/assertContainerTreeItem.ts b/src/actions/util/assertContainerTreeItem.ts similarity index 89% rename from src/views/treeItems/assertContainerTreeItem.ts rename to src/actions/util/assertContainerTreeItem.ts index 07d3099b..54ba6817 100644 --- a/src/views/treeItems/assertContainerTreeItem.ts +++ b/src/actions/util/assertContainerTreeItem.ts @@ -1,5 +1,5 @@ import { logger } from '../../util/logger'; -import { ContainerTreeItem } from './containerTreeItem'; +import { ContainerTreeItem } from '../../views/treeItems/containerTreeItem'; /** * Asserts that the provided treeNode is an instance of ContainerTreeItem. diff --git a/src/actions/util/assertProjectTreeItem.test.ts b/src/actions/util/assertProjectTreeItem.test.ts new file mode 100644 index 00000000..d9631942 --- /dev/null +++ b/src/actions/util/assertProjectTreeItem.test.ts @@ -0,0 +1,54 @@ +import * as vscode from 'vscode'; +import { logger } from '../../util/logger'; +import { unloaded } from '../../util/loadable'; +import { assertProjectTreeItem } from './assertProjectTreeItem'; +import { ProjectTreeItem } from '../../views/treeItems/projectTreeItem'; + +vi.mock('../../util/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})); + +describe('assertProjectTreeItem', () => { + const errMsg = 'This operation cannot be performed on this item'; + const loggerErrMsg = `Expected ProjectTreeItem but received`; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not throw for a ProjectTreeItem', () => { + const projectTreeItem = new ProjectTreeItem( + { + name: 'demo', + uri: vscode.Uri.file('/fake/workspace/demo'), + composeFileUri: vscode.Uri.file( + '/fake/workspace/demo/compose.yaml', + ), + workspaceIndex: 0, + workspaceName: 'workspace', + }, + false, + unloaded(), + ); + + const op = () => assertProjectTreeItem(projectTreeItem); + + expect(op).not.toThrow(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'an object', value: { some: 'object' } }, + { label: 'null', value: null }, + { label: 'undefined', value: undefined }, + ])('throws and logs an error when given $label', ({ value }) => { + const op = () => assertProjectTreeItem(value); + + expect(op).toThrow(errMsg); + expect(logger.error).toHaveBeenCalledWith(errMsg, loggerErrMsg, value); + }); +}); diff --git a/src/actions/util/assertProjectTreeItem.ts b/src/actions/util/assertProjectTreeItem.ts new file mode 100644 index 00000000..33ed726d --- /dev/null +++ b/src/actions/util/assertProjectTreeItem.ts @@ -0,0 +1,17 @@ +import { logger } from '../../util/logger'; +import { ProjectTreeItem } from '../../views/treeItems/projectTreeItem'; + +/** + * Asserts that the provided treeNode is an instance of ProjectTreeItem. + * @param treeNode The tree node to check. + * @throws Will throw an error if the treeNode is not a ProjectTreeItem. + */ +export function assertProjectTreeItem( + treeNode: unknown, +): asserts treeNode is ProjectTreeItem { + if (!(treeNode instanceof ProjectTreeItem)) { + const errMsg = `This operation cannot be performed on this item`; + logger.error(errMsg, `Expected ProjectTreeItem but received`, treeNode); + throw new Error(errMsg); + } +}