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 <fbenoit@redhat.com>
This commit is contained in:
Florent BENOIT 2026-01-30 14:45:47 +01:00 committed by GitHub
parent cfc879fbf4
commit 1bc01dc186
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 208 additions and 170 deletions

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ dist
yarn.lock
extensions/podman/assets/
extensions-extra/
__mocks__/@podman-desktop/api.ts

View file

@ -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;

69
__mocks__/api.mustache Normal file
View file

@ -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<string, Record<string, unknown>>;
{{#namespaces}}
namespaceMocks.{{name}} = {};
{{#functions}}
namespaceMocks.{{name}}.{{.}} = vi.fn();
{{/functions}}
{{/namespaces}}
const classesMocks = {} as Record<string, unknown>;
{{#classes}}
const {{name}}Instance: Record<string, unknown> = {};
{{#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;

View file

@ -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<void> {
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.');
}

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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'),
},

View file

@ -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"
},

View file

@ -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