diff --git a/packages/config/src/main.ts b/packages/config/src/main.ts index e24125e34b..e016bbc719 100644 --- a/packages/config/src/main.ts +++ b/packages/config/src/main.ts @@ -176,7 +176,7 @@ export const resolveConfig = async function (opts): Promise { siteId, accountId, token, - cwd, + buildDir, extensionApiBaseUrl, testOpts, offline, diff --git a/packages/config/src/utils/extensions/auto-install-extensions.ts b/packages/config/src/utils/extensions/auto-install-extensions.ts index 751e62d1a9..a8cbb71ce8 100644 --- a/packages/config/src/utils/extensions/auto-install-extensions.ts +++ b/packages/config/src/utils/extensions/auto-install-extensions.ts @@ -8,8 +8,13 @@ import { type ModeOption } from '../../types/options.js' import { fetchAutoInstallableExtensionsMeta, installExtension } from './utils.js' function getPackageJSON(directory: string) { - const require = createRequire(join(directory, 'package.json')) - return require('./package.json') + try { + const require = createRequire(join(directory, 'package.json')) + return require('./package.json') + } catch { + // Gracefully fail if no package.json found in buildDir + return {} + } } interface AutoInstallOptions { @@ -17,7 +22,7 @@ interface AutoInstallOptions { siteId: string accountId: string token: string - cwd: string + buildDir: string integrations: IntegrationResponse[] offline: boolean testOpts: any @@ -30,7 +35,7 @@ export async function handleAutoInstallExtensions({ siteId, accountId, token, - cwd, + buildDir, integrations, offline, testOpts = {}, @@ -44,7 +49,7 @@ export async function handleAutoInstallExtensions({ console.error("Failed to auto install extension(s): Missing 'accountId'", { accountId, siteId, - cwd, + buildDir, offline, mode, }) @@ -54,7 +59,7 @@ export async function handleAutoInstallExtensions({ console.error("Failed to auto install extension(s): Missing 'siteId'", { accountId, siteId, - cwd, + buildDir, offline, mode, }) @@ -64,17 +69,17 @@ export async function handleAutoInstallExtensions({ console.error("Failed to auto install extension(s): Missing 'token'", { accountId, siteId, - cwd, + buildDir, offline, mode, }) return integrations } - if (!cwd) { - console.error("Failed to auto install extension(s): Missing 'cwd'", { + if (!buildDir) { + console.error("Failed to auto install extension(s): Missing 'buildDir'", { accountId, siteId, - cwd, + buildDir, offline, mode, }) @@ -84,7 +89,7 @@ export async function handleAutoInstallExtensions({ console.error("Failed to auto install extension(s): Running as 'offline'", { accountId, siteId, - cwd, + buildDir, offline, mode, }) @@ -92,7 +97,7 @@ export async function handleAutoInstallExtensions({ } try { - const packageJson = getPackageJSON(cwd) + const packageJson = getPackageJSON(buildDir) if ( !packageJson?.dependencies || typeof packageJson?.dependencies !== 'object' || @@ -102,9 +107,19 @@ export async function handleAutoInstallExtensions({ } const autoInstallableExtensions = await fetchAutoInstallableExtensionsMeta() - const extensionsToInstall = autoInstallableExtensions.filter((ext) => { - return !integrations?.some((integration) => integration.slug === ext.slug) + const enabledExtensionSlugs = new Set((integrations ?? []).map(({ slug }) => slug)) + const extensionsToInstallCandidates = autoInstallableExtensions.filter( + ({ slug }) => !enabledExtensionSlugs.has(slug), + ) + const extensionsToInstall = extensionsToInstallCandidates.filter(({ packages }) => { + for (const pkg of packages) { + if (packageJson?.dependencies && Object.hasOwn(packageJson.dependencies, pkg)) { + return true + } + } + return false }) + if (extensionsToInstall.length === 0) { return integrations } diff --git a/packages/config/tests/extensions/fixtures/no_netlify_toml_with_neon/package.json b/packages/config/tests/extensions/fixtures/no_netlify_toml_with_neon/package.json new file mode 100644 index 0000000000..93e182e00e --- /dev/null +++ b/packages/config/tests/extensions/fixtures/no_netlify_toml_with_neon/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-project-no-netlify-toml-with-neon", + "version": "1.0.0", + "dependencies": { + "@netlify/neon": "^1.0.0", + "react": "^18.0.0" + } +} diff --git a/packages/config/tests/extensions/fixtures/no_package_json/netlify.toml b/packages/config/tests/extensions/fixtures/no_package_json/netlify.toml new file mode 100644 index 0000000000..e1d3d0aaea --- /dev/null +++ b/packages/config/tests/extensions/fixtures/no_package_json/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "echo 'no package.json here'" + publish = "dist" diff --git a/packages/config/tests/extensions/fixtures/with_neon_package/netlify.toml b/packages/config/tests/extensions/fixtures/with_neon_package/netlify.toml new file mode 100644 index 0000000000..a1680b2fff --- /dev/null +++ b/packages/config/tests/extensions/fixtures/with_neon_package/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "npm run build" + publish = "dist" diff --git a/packages/config/tests/extensions/fixtures/with_neon_package/package.json b/packages/config/tests/extensions/fixtures/with_neon_package/package.json new file mode 100644 index 0000000000..2e498df9dc --- /dev/null +++ b/packages/config/tests/extensions/fixtures/with_neon_package/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-project-with-neon", + "version": "1.0.0", + "dependencies": { + "@netlify/neon": "^0.1.0" + } +} diff --git a/packages/config/tests/extensions/fixtures/without_packages/netlify.toml b/packages/config/tests/extensions/fixtures/without_packages/netlify.toml new file mode 100644 index 0000000000..a1680b2fff --- /dev/null +++ b/packages/config/tests/extensions/fixtures/without_packages/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "npm run build" + publish = "dist" diff --git a/packages/config/tests/extensions/fixtures/without_packages/package.json b/packages/config/tests/extensions/fixtures/without_packages/package.json new file mode 100644 index 0000000000..7428d704ac --- /dev/null +++ b/packages/config/tests/extensions/fixtures/without_packages/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-project-without-extension-packages", + "version": "1.0.0", + "dependencies": { + "react": "^18.0.0", + "lodash": "^4.17.21" + } +} diff --git a/packages/config/tests/extensions/tests.js b/packages/config/tests/extensions/tests.js new file mode 100644 index 0000000000..abdf019d3d --- /dev/null +++ b/packages/config/tests/extensions/tests.js @@ -0,0 +1,214 @@ +import { Fixture } from '@netlify/testing' +import test from 'ava' + +// Mock fetch for external extension installation requests +const originalFetch = globalThis.fetch +const mockInstallationResponse = { success: true, mocked: true, testId: 'MOCK_RESPONSE_12345' } + +// Track installation requests for testing +let installationRequests = [] + +const mockFetch = async (url, options) => { + // Convert URL object to string if needed + const urlString = url.toString() + + // If it's an installation request to an external extension URL + if (urlString.includes('/.netlify/functions/handler/on-install')) { + installationRequests.push({ url: urlString, options }) + return { + ok: true, + status: 200, + json: async () => mockInstallationResponse, + text: async () => JSON.stringify(mockInstallationResponse), + } + } + + // If it's a request to the extension API for auto-installable extensions + if (urlString.includes('api.netlifysdk.com/meta/auto-installable')) { + return { + ok: true, + status: 200, + json: async () => AUTO_INSTALLABLE_EXTENSIONS_RESPONSE.response, + text: async () => JSON.stringify(AUTO_INSTALLABLE_EXTENSIONS_RESPONSE.response), + } + } + + // For all other requests, use the original fetch + return originalFetch(url, options) +} + +// Set up global fetch mock +globalThis.fetch = mockFetch + +// Reset installation requests before each test +test.beforeEach(() => { + installationRequests = [] +}) + +const SITE_INFO_DATA = { + path: '/api/v1/sites/test', + response: { id: 'test', name: 'test' }, +} + +const TEAM_INSTALLATIONS_META_RESPONSE = { + path: '/team/account1/integrations/installations/meta/test', + response: [], +} + +const FETCH_INTEGRATIONS_EMPTY_RESPONSE = { + path: '/integrations', + response: [], +} + +// Mock response for auto-installable extensions API +const AUTO_INSTALLABLE_EXTENSIONS_RESPONSE = { + path: '/meta/auto-installable', + response: [ + { + slug: 'neon', + hostSiteUrl: 'https://neon-extension.netlify.app', // Mocked by fetch mock + packages: ['@netlify/neon'], + }, + ], +} + +test('Auto-install extensions: feature flag disabled returns integrations unchanged', async (t) => { + const { output } = await new Fixture('./fixtures/with_neon_package') + .withFlags({ + siteId: 'test', + accountId: 'account1', + token: 'test', + mode: 'dev', + featureFlags: { + auto_install_required_extensions: false, + }, + }) + .runConfigServer([SITE_INFO_DATA, TEAM_INSTALLATIONS_META_RESPONSE, FETCH_INTEGRATIONS_EMPTY_RESPONSE]) + + const config = JSON.parse(output) + + // Should not have attempted to install any extensions + t.false(output.includes('Installing extension')) + t.assert(config.integrations) + t.is(config.integrations.length, 0) +}) + +test('Auto-install extensions: gracefully handles missing package.json', async (t) => { + const { output } = await new Fixture('./fixtures/no_package_json') + .withFlags({ + siteId: 'test', + accountId: 'account1', + token: 'test', + mode: 'dev', + featureFlags: { + auto_install_required_extensions: true, + }, + }) + .runConfigServer([SITE_INFO_DATA, TEAM_INSTALLATIONS_META_RESPONSE, FETCH_INTEGRATIONS_EMPTY_RESPONSE]) + + const config = JSON.parse(output) + + // Should not have attempted to install any extensions + t.false(output.includes('Installing extension')) + t.assert(config.integrations) + t.is(config.integrations.length, 0) +}) + +test('Auto-install extensions: correctly reads package.json from buildDir', async (t) => { + // This test verifies that the function correctly reads package.json from buildDir + const { output } = await new Fixture('./fixtures/with_neon_package') + .withFlags({ + siteId: 'test', + accountId: 'account1', + token: 'test', + mode: 'dev', + featureFlags: { + auto_install_required_extensions: true, + }, + }) + .runConfigServer([SITE_INFO_DATA, TEAM_INSTALLATIONS_META_RESPONSE, FETCH_INTEGRATIONS_EMPTY_RESPONSE]) + + const config = JSON.parse(output) + + // Should have found package.json in buildDir + t.assert(config.integrations) + t.assert(config.buildDir) + t.true(config.buildDir.includes('with_neon_package')) + + // Auto-installable extensions API call is mocked by global fetch mock + // (not visible in requests array since it's intercepted before reaching test server) + + // Should have attempted to install the extension (mocked) + t.assert(installationRequests.length > 0, 'Should have attempted to install extension') + t.assert( + installationRequests[0].url.includes('/.netlify/functions/handler/on-install'), + 'Should have called installation endpoint', + ) + t.assert( + installationRequests[0].url.includes('neon-extension.netlify.app'), + 'Should have called correct external URL', + ) + t.assert(installationRequests[0].options.method === 'POST', 'Should use POST method') + t.assert(installationRequests[0].options.body.includes('account1'), 'Should include team ID in request body') +}) + +test('Auto-install extensions: does not install when required packages are missing', async (t) => { + // This test uses a fixture that has dependencies but not the extension packages + const { output } = await new Fixture('./fixtures/without_packages') + .withFlags({ + siteId: 'test', + accountId: 'account1', + token: 'test', + mode: 'dev', + featureFlags: { + auto_install_required_extensions: true, + }, + }) + .runConfigServer([SITE_INFO_DATA, TEAM_INSTALLATIONS_META_RESPONSE, FETCH_INTEGRATIONS_EMPTY_RESPONSE]) + + const config = JSON.parse(output) + + // Should not attempt to install extensions since required packages are missing + t.false(output.includes('Installing extension')) + t.assert(config.integrations) + t.is(config.integrations.length, 0) + + // Auto-installable extensions API call is mocked by global fetch mock + // (not visible in requests array since it's intercepted before reaching test server) +}) + +test('Auto-install extensions: correctly reads package.json when no netlify.toml exists', async (t) => { + // This test verifies buildDir resolution works correctly when there's no netlify.toml + // but package.json exists with extension packages + const { output } = await new Fixture('./fixtures/no_netlify_toml_with_neon') + .withFlags({ + siteId: 'test', + accountId: 'account1', + token: 'test', + mode: 'dev', + featureFlags: { + auto_install_required_extensions: true, + }, + }) + .runConfigServer([SITE_INFO_DATA, TEAM_INSTALLATIONS_META_RESPONSE, FETCH_INTEGRATIONS_EMPTY_RESPONSE]) + + const config = JSON.parse(output) + + // Should have found package.json in buildDir even without netlify.toml + t.assert(config.integrations) + t.assert(config.buildDir) + t.true(config.buildDir.includes('no_netlify_toml_with_neon')) + + // buildDir should be the repository root since there's no build.base config + t.true(config.buildDir.endsWith('no_netlify_toml_with_neon')) + + // Auto-installable extensions API call is mocked by global fetch mock + // (not visible in requests array since it's intercepted before reaching test server) + + // Should have attempted to install the extension + t.assert(installationRequests.length > 0, 'Should have attempted to install extension') + t.assert( + installationRequests[0].url.includes('/.netlify/functions/handler/on-install'), + 'Should have called installation endpoint', + ) +})