Skip to content
Merged
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
147 changes: 147 additions & 0 deletions packages/core/src/lib/plugin-loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { GeneratorRegistry } from './registry';
import { PluginLoadError, PluginNotFoundError } from './errors';
import { GeneratorPlugin } from './interfaces';
import { loadPlugin } from './plugin-loader';
import * as autoInstaller from './auto-installer';

// Mock the auto-installer module
jest.mock('./auto-installer', () => ({
installPackages: jest.fn(),
detectCi: jest.fn().mockReturnValue(false),
detectPackageManager: jest.fn().mockReturnValue('npm'),
}));

// Mock the dynamic imports
jest.mock(
Expand All @@ -26,6 +34,9 @@ describe('plugin-loader', () => {
registry = GeneratorRegistry.instance();
jest.spyOn(registry, 'has');
jest.spyOn(registry, 'get');
// Reset auto-installer mocks
(autoInstaller.detectCi as jest.Mock).mockReturnValue(false);
(autoInstaller.installPackages as jest.Mock).mockClear();
});

afterEach(() => {
Expand Down Expand Up @@ -249,5 +260,141 @@ describe('plugin-loader', () => {
PluginLoadError
);
});

describe('auto-installation', () => {
it('should attempt auto-installation for missing @nx-plugin-openapi packages', async () => {
const mockPlugin = {
name: 'plugin-test',
generate: jest.fn(),
};

// The module mock will throw on first import, then succeed after "installation"
let isInstalled = false;
jest.doMock(
'@nx-plugin-openapi/plugin-test',
() => {
if (!isInstalled) {
const error = new Error('Cannot find module');
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
throw error;
}
return { default: mockPlugin };
},
{ virtual: true }
);

// Mock successful installation that sets the flag
(autoInstaller.installPackages as jest.Mock).mockImplementation(() => {
isInstalled = true;
});

const result = await loadPlugin('@nx-plugin-openapi/plugin-test');

expect(autoInstaller.installPackages).toHaveBeenCalledWith(
['@nx-plugin-openapi/plugin-test'],
{ dev: true }
);
expect(result).toBe(mockPlugin);
});

it('should not attempt auto-installation in CI environment', async () => {
(autoInstaller.detectCi as jest.Mock).mockReturnValue(true);

jest.doMock(
'@nx-plugin-openapi/plugin-ci-test',
() => {
const error = new Error('Cannot find module');
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
throw error;
},
{ virtual: true }
);

await expect(
loadPlugin('@nx-plugin-openapi/plugin-ci-test')
).rejects.toThrow(PluginNotFoundError);

expect(autoInstaller.installPackages).not.toHaveBeenCalled();
});

it('should not attempt auto-installation for non-nx-plugin-openapi packages', async () => {
jest.doMock(
'external-plugin',
() => {
const error = new Error('Cannot find module');
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
throw error;
},
{ virtual: true }
);

await expect(loadPlugin('external-plugin')).rejects.toThrow(
PluginNotFoundError
);

expect(autoInstaller.installPackages).not.toHaveBeenCalled();
});

it('should handle auto-installation failure gracefully', async () => {
jest.doMock(
'@nx-plugin-openapi/plugin-fail-test',
() => {
const error = new Error('Cannot find module');
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
throw error;
},
{ virtual: true }
);

// Mock installation failure
(autoInstaller.installPackages as jest.Mock).mockImplementation(() => {
throw new Error('Installation failed');
});

await expect(
loadPlugin('@nx-plugin-openapi/plugin-fail-test')
).rejects.toThrow(PluginNotFoundError);

expect(autoInstaller.installPackages).toHaveBeenCalledWith(
['@nx-plugin-openapi/plugin-fail-test'],
{ dev: true }
);
});

it('should use built-in mapping for auto-installation', async () => {
const mockPlugin = {
name: 'hey-openapi',
generate: jest.fn(),
};

// The module mock will throw on first import, then succeed after "installation"
let isInstalled = false;
jest.doMock(
'@nx-plugin-openapi/plugin-hey-openapi',
() => {
if (!isInstalled) {
const error = new Error('Cannot find module');
(error as Error & { code: string }).code = 'ERR_MODULE_NOT_FOUND';
throw error;
}
return { default: mockPlugin };
},
{ virtual: true }
);

// Mock successful installation that sets the flag
(autoInstaller.installPackages as jest.Mock).mockImplementation(() => {
isInstalled = true;
});

const result = await loadPlugin('hey-openapi');

expect(autoInstaller.installPackages).toHaveBeenCalledWith(
['@nx-plugin-openapi/plugin-hey-openapi'],
{ dev: true }
);
expect(result).toBe(mockPlugin);
});
});
});
});
86 changes: 85 additions & 1 deletion packages/core/src/lib/plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { PluginLoadError, PluginNotFoundError } from './errors';
import { GeneratorRegistry } from './registry';
import { isGeneratorPlugin } from './type-guards';
import { logger } from '@nx/devkit';
import { detectCi, installPackages } from './auto-installer';

const BUILTIN_PLUGIN_MAP: Record<string, string> = {
'openapi-tools': '@nx-plugin-openapi/plugin-openapi',
Expand All @@ -11,6 +12,23 @@ const BUILTIN_PLUGIN_MAP: Record<string, string> = {

const cache = new Map<string, GeneratorPlugin>();

/**
* Helper function to determine if auto-installation should be attempted
*/
function shouldTryAutoInstall(error: unknown, packageName: string): boolean {
const msg = String(error);
const code = (error as Record<string, unknown>)?.['code'];

return (
// Only for module not found errors
(code === 'ERR_MODULE_NOT_FOUND' || /Cannot find module/.test(msg)) &&
// Only for known plugin packages
packageName.startsWith('@nx-plugin-openapi/') &&
// Not in CI environment
!detectCi()
);
}

export async function loadPlugin(
name: string,
opts?: { root?: string }
Expand Down Expand Up @@ -89,7 +107,9 @@ export async function loadPlugin(
pkg === '@nx-plugin-openapi/plugin-openapi' ||
pkg === '@nx-plugin-openapi/plugin-hey-openapi'
) {
const pkgName = pkg.split('/').pop()!; // e.g., 'plugin-openapi' or 'plugin-hey-openapi'
const pkgName = pkg.split('/').pop() ?? ''; // e.g., 'plugin-openapi' or 'plugin-hey-openapi'
// TODO remove fallback paths as this is no scenario for published packages.
// for local development we should use another strategy
const fallbackPaths = [
`${root}/dist/packages/${pkgName}/src/index.js`,
`${root}/packages/${pkgName}/src/index.js`,
Expand Down Expand Up @@ -117,6 +137,70 @@ export async function loadPlugin(
}
}

// Auto-installation logic
if (shouldTryAutoInstall(e, pkg)) {
logger.info(`Attempting to auto-install missing plugin: ${pkg}`);
try {
installPackages([pkg], { dev: true });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installPackages() function appears to be asynchronous but is being called synchronously. This could lead to the retry import executing before the installation completes. Consider adding await:

await installPackages([pkg], { dev: true });

This ensures the installation fully completes before attempting to import the newly installed package.

Suggested change
installPackages([pkg], { dev: true });
await installPackages([pkg], { dev: true });

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

logger.info(`Successfully installed ${pkg}, retrying import...`);

// Retry the import after installation
// The module should now be available after installation
const retryMod = (await import(pkg)) as {
default?: unknown;
createPlugin?: unknown;
plugin?: unknown;
Plugin?: unknown;
};

let candidate: unknown = undefined;

// Try different export patterns (same as above)
if (isGeneratorPlugin(retryMod.default)) {
logger.debug(`Found plugin as default export after installation`);
candidate = retryMod.default;
} else if (typeof retryMod.createPlugin === 'function') {
logger.debug(
`Found createPlugin factory function after installation`
);
candidate = (retryMod.createPlugin as () => unknown)();
} else if (isGeneratorPlugin(retryMod.plugin)) {
logger.debug(
`Found plugin as named export 'plugin' after installation`
);
candidate = retryMod.plugin;
} else if (isGeneratorPlugin(retryMod.Plugin)) {
logger.debug(
`Found plugin as named export 'Plugin' after installation`
);
candidate = retryMod.Plugin;
}

if (!isGeneratorPlugin(candidate)) {
const availableExports = Object.keys(retryMod).filter(
(k) => k !== '__esModule'
);
throw new PluginLoadError(
name,
new Error(
`Module does not export a valid plugin after installation. Available exports: ${availableExports.join(
', '
)}`
)
);
}

logger.info(
`Successfully loaded plugin after auto-installation: ${name}`
);
cache.set(name, candidate);
return candidate;
} catch (installError) {
logger.warn(`Auto-installation failed for ${pkg}: ${installError}`);
// Continue to existing error handling
}
}

// Determine appropriate error type
const msg = String(e);
const code = (e as Record<string, unknown>)?.['code'];
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-hey-openapi/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module.exports = [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'],
ignoredDependencies: ['@hey-api/openapi-ts'],
},
],
},
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-openapi/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module.exports = [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'],
ignoredDependencies: ['@openapitools/openapi-generator-cli'],
},
],
},
Expand Down