From 1bc01dc1865e5d6529867a7fbc10e1f6cbfb8e60 Mon Sep 17 00:00:00 2001 From: Florent BENOIT Date: Fri, 30 Jan 2026 14:45:47 +0100 Subject: [PATCH] refactor(mock): generate @podman-desktop/api mock from its definition (#15670) * refactor(mock): generate @podman-desktop/api mock from its definition instead of writing for each function and for each namespace the content reuse the .d.ts definition and wire automatically vi.fn() customize/override some classes implementation manually but most of it is generated fixes https://github.com/podman-desktop/podman-desktop/issues/14493 Signed-off-by: Florent Benoit --- .gitignore | 1 + __mocks__/@podman-desktop/api.js | 170 ------------------ __mocks__/api.mustache | 69 +++++++ __mocks__/vitest-generate-api-global-setup.ts | 125 +++++++++++++ extensions/compose/vite.config.js | 1 + .../docker/packages/extension/vite.config.js | 1 + extensions/kind/vite.config.js | 1 + extensions/kube-context/vite.config.js | 1 + extensions/kubectl-cli/vite.config.js | 1 + extensions/lima/vite.config.js | 1 + .../podman-docker-context/vite.config.js | 1 + .../podman/packages/extension/vite.config.js | 1 + extensions/registries/vite.config.js | 1 + package.json | 1 + pnpm-lock.yaml | 3 + 15 files changed, 208 insertions(+), 170 deletions(-) delete mode 100644 __mocks__/@podman-desktop/api.js create mode 100644 __mocks__/api.mustache create mode 100644 __mocks__/vitest-generate-api-global-setup.ts diff --git a/.gitignore b/.gitignore index ea7f3c477fc..0c6e0e1af76 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ dist yarn.lock extensions/podman/assets/ extensions-extra/ +__mocks__/@podman-desktop/api.ts diff --git a/__mocks__/@podman-desktop/api.js b/__mocks__/@podman-desktop/api.js deleted file mode 100644 index 279388eb9bc..00000000000 --- a/__mocks__/@podman-desktop/api.js +++ /dev/null @@ -1,170 +0,0 @@ -/********************************************************************** - * Copyright (C) 2022-2025 Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * SPDX-License-Identifier: Apache-2.0 - ***********************************************************************/ - -import { vi, beforeEach } from 'vitest'; -import product from './../../product.json' with { type: 'json' }; - -/** - * Mock the extension API for vitest. - * This file is referenced from vitest.config.js file. - */ - -const cli = { - createCliTool: vi.fn(), -}; - -const commands = { - registerCommand: vi.fn(), -}; - -const configuration = { - onDidChangeConfiguration: vi.fn(), - getConfiguration: vi.fn(), -}; - -const containerEngine = { - info: vi.fn(), - listContainers: vi.fn(), - saveImage: vi.fn(), - onEvent: vi.fn(), -}; - -const context = { - setValue: vi.fn(), -}; - -const env = { - createTelemetryLogger: vi.fn(), - openExternal: vi.fn(), - - appName: product.name, - - isLinux: false, - isWindows: false, - isMac: false, -}; - -const kubernetes = { - createResources: vi.fn(), - getKubeconfig: vi.fn(), - onDidUpdateKubeconfig: vi.fn(), -}; - -const net = { - getFreePort: vi.fn(), -}; - -const process = { - exec: vi.fn(), -}; - -const eventEmitterListeners = []; - -const extensions = { - getExtension: vi.fn(), -}; - -const proxy = { - isEnabled: vi.fn(), - onDidUpdateProxy: vi.fn(), - onDidStateChange: vi.fn(), - getProxySettings: vi.fn(), -}; - -const provider = { - createProvider: vi.fn(), - onDidRegisterContainerConnection: vi.fn(), - onDidUnregisterContainerConnection: vi.fn(), - onDidUpdateProvider: vi.fn(), - onDidUpdateContainerConnection: vi.fn(), - onDidUpdateVersion: vi.fn(), - registerUpdate: vi.fn(), - getContainerConnections: vi.fn(), -}; - -const registry = { - registerRegistryProvider: vi.fn(), - registerRegistry: vi.fn(), - unregisterRegistry: vi.fn(), - onDidRegisterRegistry: vi.fn(), - onDidUnregisterRegistry: vi.fn(), - onDidUpdateRegistry: vi.fn(), - suggestRegistry: vi.fn(), -}; - -const window = { - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - withProgress: vi.fn(), - showNotification: vi.fn(), - showWarningMessage: vi.fn(), - - showQuickPick: vi.fn(), - showInputBox: vi.fn(), - createStatusBarItem: vi.fn(), -}; - -const Disposable = { from: vi.fn(), dispose: vi.fn() }; - -class EventEmitter { - event(callback) { - eventEmitterListeners.push(callback); - } - - fire(data) { - eventEmitterListeners.forEach(listener => listener(data)); - } - - dispose() {} -} - -const ProgressLocation = { - APP_ICON: 1, - TASK_WIDGET: 2, -}; - -const Uri = { - parse: vi.fn(), -}; - -const plugin = { - cli, - commands, - configuration, - containerEngine, - context, - env, - extensions, - kubernetes, - net, - process, - provider, - proxy, - registry, - window, - Disposable, - EventEmitter, - ProgressLocation, - Uri, -}; - -beforeEach(() => { - eventEmitterListeners.length = 0; -}); - -module.exports = plugin; diff --git a/__mocks__/api.mustache b/__mocks__/api.mustache new file mode 100644 index 00000000000..845e78e1cea --- /dev/null +++ b/__mocks__/api.mustache @@ -0,0 +1,69 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/* This file is auto-generated by a vitest globalSetup hook */ +import { beforeEach, vi } from 'vitest'; + +import product from '../../product.json' with { type: 'json' }; + +const namespaceMocks = {} as Record>; +{{#namespaces}} +namespaceMocks.{{name}} = {}; +{{#functions}} +namespaceMocks.{{name}}.{{.}} = vi.fn(); +{{/functions}} +{{/namespaces}} + +const classesMocks = {} as Record; +{{#classes}} + const {{name}}Instance: Record = {}; + {{#methods}} + {{name}}Instance.{{.}} = vi.fn(); + {{/methods}} + classesMocks.{{name}} = {{name}}Instance; +{{/classes}} + +const eventEmitterListeners: Array<(data: unknown) => unknown> = []; +beforeEach(() => { eventEmitterListeners.length = 0; }); +class EventEmitter { + event(callback: (e: unknown) => unknown): void { eventEmitterListeners.push(callback); } + fire(data: unknown): void { for (const listener of eventEmitterListeners) { listener(data); } } + dispose(): void {} +} +EventEmitter.prototype.dispose = vi.fn(); + +// Normalize env mock +if (!namespaceMocks.env) namespaceMocks.env = {}; +namespaceMocks.env.isWindows = false; +namespaceMocks.env.isMac = false; +namespaceMocks.env.isLinux = false; +namespaceMocks.env.appName = product.name; + +const extensionAPI = { + ...namespaceMocks, + ...classesMocks, + EventEmitter, + ProgressLocation: { APP_ICON: 1, TASK_WIDGET: 2 }, + StatusBarAlignLeft: 'LEFT', + StatusBarAlignRight: 'RIGHT', + StatusBarItemDefaultPriority: 0, + InputBoxValidationSeverity: { Info: 1, Warning: 2, Error: 3 }, + QuickPickItemKind: { Separator: -1, Default: 0 }, +}; + +module.exports = extensionAPI; diff --git a/__mocks__/vitest-generate-api-global-setup.ts b/__mocks__/vitest-generate-api-global-setup.ts new file mode 100644 index 00000000000..554e59acb81 --- /dev/null +++ b/__mocks__/vitest-generate-api-global-setup.ts @@ -0,0 +1,125 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import path from 'node:path'; +import fs from 'node:fs/promises'; +import typescript from 'typescript'; +import Mustache from 'mustache'; +import { fileURLToPath } from 'node:url'; + +type Namespaces = { [key: string]: string[] }; +type Classes = { [key: string]: string[] }; + +async function extractNamespacesAndClassesFromAPI( + filePath: string, +): Promise<{ namespaces: Namespaces; classes: Classes }> { + const fileContent = await fs.readFile(filePath, 'utf-8'); + const sourceFile = typescript.createSourceFile(filePath, fileContent, typescript.ScriptTarget.Latest, true); + + const namespaces: Namespaces = {}; + const classes: Classes = {}; + + const visit = (node: typescript.Node): void => { + if (typescript.isModuleDeclaration(node) && node.name.text) { + if (node.name.text === '@podman-desktop/api') { + typescript.forEachChild(node, visit); + return; + } + + const namespaceName = node.name.text; + const functions: string[] = []; + + if (node.body && typescript.isModuleBlock(node.body)) { + for (const statement of node.body.statements) { + if (typescript.isFunctionDeclaration(statement) && statement.name) { + functions.push(statement.name.text); + } else if (typescript.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (typescript.isIdentifier(declaration.name)) { + functions.push(declaration.name.text); + } + } + } + } + } + + if (functions.length > 0) { + // Deduplicate to handle function overloads + namespaces[namespaceName] = Array.from(new Set(functions)); + } + } + + if (typescript.isClassDeclaration(node) && node.name) { + const modifiers = typescript.getModifiers(node); + const isExported = modifiers?.some(m => m.kind === typescript.SyntaxKind.ExportKeyword); + if (isExported) { + const className = node.name.text; + const methods: string[] = []; + for (const member of node.members) { + if (typescript.isMethodDeclaration(member) && member.name && typescript.isIdentifier(member.name)) { + methods.push(member.name.text); + } + } + // Deduplicate to handle method overloads + classes[className] = Array.from(new Set(methods)); + } + } + + typescript.forEachChild(node, visit); + }; + + visit(sourceFile); + return { namespaces, classes }; +} + +function toTemplateData(data: { namespaces: Namespaces; classes: Classes }) { + const namespaces = Object.entries(data.namespaces).map(([name, functions]) => ({ name, functions })); + const classes = Object.entries(data.classes).map(([name, methods]) => ({ name, methods })); + return { namespaces, classes }; +} + +export default async function setup(): Promise { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const repoRoot = path.resolve(__dirname, '..'); + const extensionApiTypePath = path.join(repoRoot, 'packages', 'extension-api', 'src', 'extension-api.d.ts'); + const podmanDesktopApiMocksDir = path.join(repoRoot, '__mocks__', '@podman-desktop'); + const apiGeneratedFile = path.join(podmanDesktopApiMocksDir, 'api.ts'); + const templatePath = path.join(repoRoot, '__mocks__', 'api.mustache'); + + // skip if api.ts is already newer (from template or extension-api.d.ts file) + const extensionApiPathStats = await fs.stat(extensionApiTypePath); + const templatePathStats = await fs.stat(templatePath); + try { + const outputStats = await fs.stat(apiGeneratedFile); + const newestInputMtime = Math.max(extensionApiPathStats.mtimeMs, templatePathStats.mtimeMs); + if (outputStats.mtimeMs >= newestInputMtime) { + console.debug(' 🚀 __mocks__/@podman-desktop/api.ts up-to-date; skipping regeneration'); + return; + } + } catch { + console.debug(' ⚙️ __mocks__/@podman-desktop/api.ts does not exist yet; generating it now…'); + } + const data = await extractNamespacesAndClassesFromAPI(extensionApiTypePath); + const template = await fs.readFile(templatePath, 'utf-8'); + const content = Mustache.render(template, toTemplateData(data)); + + await fs.mkdir(podmanDesktopApiMocksDir, { recursive: true }); + await fs.writeFile(apiGeneratedFile, content, 'utf-8'); + console.debug(' ✅ __mocks__/@podman-desktop/api.ts has been generated.'); +} diff --git a/extensions/compose/vite.config.js b/extensions/compose/vite.config.js index f3df745d5c4..da1223067cf 100644 --- a/extensions/compose/vite.config.js +++ b/extensions/compose/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/docker/packages/extension/vite.config.js b/extensions/docker/packages/extension/vite.config.js index 9e1f1ccde22..e688d16189d 100644 --- a/extensions/docker/packages/extension/vite.config.js +++ b/extensions/docker/packages/extension/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/kind/vite.config.js b/extensions/kind/vite.config.js index f3df745d5c4..da1223067cf 100644 --- a/extensions/kind/vite.config.js +++ b/extensions/kind/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/kube-context/vite.config.js b/extensions/kube-context/vite.config.js index f3df745d5c4..da1223067cf 100644 --- a/extensions/kube-context/vite.config.js +++ b/extensions/kube-context/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/kubectl-cli/vite.config.js b/extensions/kubectl-cli/vite.config.js index 9348d5f8f00..29af8ef6a05 100644 --- a/extensions/kubectl-cli/vite.config.js +++ b/extensions/kubectl-cli/vite.config.js @@ -58,6 +58,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/lima/vite.config.js b/extensions/lima/vite.config.js index f3df745d5c4..da1223067cf 100644 --- a/extensions/lima/vite.config.js +++ b/extensions/lima/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/podman-docker-context/vite.config.js b/extensions/podman-docker-context/vite.config.js index f3df745d5c4..da1223067cf 100644 --- a/extensions/podman-docker-context/vite.config.js +++ b/extensions/podman-docker-context/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/podman/packages/extension/vite.config.js b/extensions/podman/packages/extension/vite.config.js index 893c34e9090..d84f6991c9e 100644 --- a/extensions/podman/packages/extension/vite.config.js +++ b/extensions/podman/packages/extension/vite.config.js @@ -62,6 +62,7 @@ const config = { globals: true, environment: 'node', include: ['{src,scripts}/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/extensions/registries/vite.config.js b/extensions/registries/vite.config.js index f3df745d5c4..da1223067cf 100644 --- a/extensions/registries/vite.config.js +++ b/extensions/registries/vite.config.js @@ -57,6 +57,7 @@ const config = { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + globalSetup: [join(PACKAGE_ROOT, '..', '..', '__mocks__', 'vitest-generate-api-global-setup.ts')], alias: { '@podman-desktop/api': join(PACKAGE_ROOT, '..', '..', '__mocks__/@podman-desktop/api.js'), }, diff --git a/package.json b/package.json index f1413131150..965f660f281 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,7 @@ "typescript-eslint": "^8.54.0", "validator": "^13.15.26", "vite": "^7.3.1", + "mustache": "^4.2.0", "vitest": "^4.0.10", "xvfb-maybe": "^0.2.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5986500ab1..353b22e754e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,9 @@ importers: msw: specifier: ^2.12.7 version: 2.12.7(@types/node@24.6.2)(typescript@5.9.3) + mustache: + specifier: ^4.2.0 + version: 4.2.0 prettier: specifier: ^3.8.1 version: 3.8.1