diff --git a/packages/amplify-category-custom/src/__tests__/utils/build-custom-resources.test.ts b/packages/amplify-category-custom/src/__tests__/utils/build-custom-resources.test.ts index 0eb9e5d2cfe..f63241ce2a7 100644 --- a/packages/amplify-category-custom/src/__tests__/utils/build-custom-resources.test.ts +++ b/packages/amplify-category-custom/src/__tests__/utils/build-custom-resources.test.ts @@ -1,5 +1,6 @@ -import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { $TSContext, JSONUtilities } from '@aws-amplify/amplify-cli-core'; import execa from 'execa'; +import * as fs from 'fs-extra'; import { buildCustomResources } from '../../utils/build-custom-resources'; jest.mock('@aws-amplify/amplify-cli-core'); @@ -8,15 +9,10 @@ jest.mock('../../utils/dependency-management-utils'); jest.mock('../../utils/generate-cfn-from-cdk'); jest.mock('execa'); jest.mock('ora'); +jest.mock('fs-extra'); -jest.mock('fs-extra', () => ({ - readFileSync: jest.fn().mockReturnValue('mockCode'), - existsSync: jest.fn().mockReturnValue(true), - ensureDirSync: jest.fn().mockReturnValue(true), - ensureDir: jest.fn(), - writeFileSync: jest.fn().mockReturnValue(true), - writeFile: jest.fn(), -})); +const fs_mock = fs as jest.Mocked; +const JSONUtilities_mock = JSONUtilities as jest.Mocked; jest.mock('ora', () => () => ({ start: jest.fn(), @@ -34,7 +30,15 @@ jest.mock('../../utils/generate-cfn-from-cdk', () => ({ })); jest.mock('@aws-amplify/amplify-cli-core', () => ({ - getPackageManager: jest.fn().mockResolvedValue('npm'), + getPackageManager: jest.fn().mockResolvedValue({ + packageManager: 'npm', + executable: 'npm', + runner: 'npx', + lockFile: 'package-lock.json', + displayValue: 'NPM', + getRunScriptArgs: jest.fn(), + getInstallArgs: jest.fn(), + }), pathManager: { getBackendDirPath: jest.fn().mockReturnValue('mockTargetDir'), }, @@ -44,6 +48,12 @@ jest.mock('@aws-amplify/amplify-cli-core', () => ({ stringify: jest.fn(), }, skipHooks: jest.fn().mockReturnValue(false), + AmplifyError: class AmplifyError extends Error { + constructor(name: string, options: { message: string }) { + super(options.message); + this.name = name; + } + }, })); describe('build custom resources scenarios', () => { @@ -51,6 +61,18 @@ describe('build custom resources scenarios', () => { beforeEach(() => { jest.clearAllMocks(); + + // Default fs mocks + (fs_mock.existsSync as jest.Mock).mockReturnValue(true); + (fs_mock.readFileSync as jest.Mock).mockReturnValue('mockCode'); + (fs_mock.ensureDirSync as jest.Mock).mockReturnValue(undefined); + (fs_mock.ensureDir as jest.Mock).mockResolvedValue(undefined); + (fs_mock.writeFileSync as jest.Mock).mockReturnValue(undefined); + (fs_mock.writeFile as jest.Mock).mockResolvedValue(undefined); + + // Default: no build script in package.json + JSONUtilities_mock.readJson.mockReturnValue({}); + mockContext = { amplify: { openEditor: jest.fn(), @@ -72,10 +94,93 @@ describe('build custom resources scenarios', () => { } as unknown as $TSContext; }); - it('build all resources', async () => { - await buildCustomResources(mockContext); + describe('default behavior (no build script)', () => { + it('should run install and tsc separately when no build script defined', async () => { + // No build script in package.json + JSONUtilities_mock.readJson.mockReturnValue({}); + + await buildCustomResources(mockContext); + + // 2 for npm install and 2 for tsc build (1 per resource) + expect(execa.sync).toBeCalledTimes(4); + + // First resource: install then tsc + expect(execa.sync).toHaveBeenNthCalledWith(1, 'npm', ['install'], expect.objectContaining({ stdio: 'pipe' })); + expect(execa.sync).toHaveBeenNthCalledWith(2, 'npx', ['tsc'], expect.objectContaining({ stdio: 'pipe' })); + + // Second resource: install then tsc + expect(execa.sync).toHaveBeenNthCalledWith(3, 'npm', ['install'], expect.objectContaining({ stdio: 'pipe' })); + expect(execa.sync).toHaveBeenNthCalledWith(4, 'npx', ['tsc'], expect.objectContaining({ stdio: 'pipe' })); + }); + + it('should run install and tsc when package.json has scripts but no build script', async () => { + // package.json with scripts but no build script + JSONUtilities_mock.readJson.mockReturnValue({ + scripts: { + test: 'jest', + lint: 'eslint .', + }, + }); + + await buildCustomResources(mockContext); + + // Should still run install + tsc for each resource + expect(execa.sync).toBeCalledTimes(4); + expect(execa.sync).toHaveBeenNthCalledWith(1, 'npm', ['install'], expect.objectContaining({ stdio: 'pipe' })); + }); + }); + + describe('custom build script behavior', () => { + it('should run build script when package.json has scripts.build defined', async () => { + // package.json with build script + JSONUtilities_mock.readJson.mockReturnValue({ + scripts: { + build: 'pnpm install --ignore-workspace && tsc', + }, + }); + + await buildCustomResources(mockContext); + + // Only 2 calls (1 per resource) for 'npm run build' + expect(execa.sync).toBeCalledTimes(2); + + expect(execa.sync).toHaveBeenNthCalledWith(1, 'npm', ['run', 'build'], expect.objectContaining({ stdio: 'pipe' })); + expect(execa.sync).toHaveBeenNthCalledWith(2, 'npm', ['run', 'build'], expect.objectContaining({ stdio: 'pipe' })); + }); + + it('should not call install or tsc separately when build script is defined', async () => { + JSONUtilities_mock.readJson.mockReturnValue({ + scripts: { + build: 'custom-build-command', + }, + }); + + await buildCustomResources(mockContext); + + // Verify install and tsc are NOT called + const calls = (execa.sync as jest.Mock).mock.calls; + const hasInstallCall = calls.some((call) => call[1]?.[0] === 'install'); + const hasTscCall = calls.some((call) => call[1]?.[0] === 'tsc'); + + expect(hasInstallCall).toBe(false); + expect(hasTscCall).toBe(false); + }); + }); + + describe('hasBuildScript edge cases', () => { + it('should fall back to install+tsc when package.json does not exist', async () => { + // package.json doesn't exist for the custom resource + (fs_mock.existsSync as jest.Mock).mockImplementation((filePath: unknown) => { + if (typeof filePath === 'string' && filePath.includes('package.json')) { + return false; + } + return true; + }); + + await buildCustomResources(mockContext); - // 2 for npm install and 2 for tsc build (1 per resource) - expect(execa.sync).toBeCalledTimes(4); + // Should run install + tsc (default behavior) + expect(execa.sync).toBeCalledTimes(4); + }); }); }); diff --git a/packages/amplify-category-custom/src/utils/build-custom-resources.ts b/packages/amplify-category-custom/src/utils/build-custom-resources.ts index 9c7eeeaafa8..e9f8c1db4bc 100644 --- a/packages/amplify-category-custom/src/utils/build-custom-resources.ts +++ b/packages/amplify-category-custom/src/utils/build-custom-resources.ts @@ -68,6 +68,20 @@ export const generateDependentResourcesType = async (): Promise => { await fs.writeFile(target, dependentResourceAttributesFileContent); }; +/** + * Check if the package.json in the given directory has a build script defined. + * @param targetDir - The directory containing the package.json + * @returns true if a build script is defined, false otherwise + */ +const hasBuildScript = (targetDir: string): boolean => { + const packageJsonPath = path.join(targetDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + const packageJson = JSONUtilities.readJson<$TSAny>(packageJsonPath); + return !!packageJson?.scripts?.build; +}; + const buildResource = async (resource: ResourceMeta): Promise => { const targetDir = path.resolve(path.join(pathManager.getBackendDirPath(), categoryName, resource.resourceName)); if (skipHooks()) { @@ -84,29 +98,50 @@ const buildResource = async (resource: ResourceMeta): Promise => { throw new Error('No package manager found. Please install npm, yarn, or pnpm to compile overrides for this project.'); } - try { - execa.sync(packageManager.executable, ['install'], { - cwd: targetDir, - stdio: 'pipe', - encoding: 'utf-8', - }); - } catch (error: $TSAny) { - if ((error as $TSAny).code === 'ENOENT') { - throw new Error(`Packaging overrides failed. Could not find ${packageManager} executable in the PATH.`); - } else { - throw new Error(`Packaging overrides failed with the error \n${error.message}`); + // If the custom resource has a build script in package.json, use it. + // This allows projects to customize their build process (e.g., adding --ignore-workspace for pnpm). + // Otherwise, fall back to the default behavior of running install + tsc separately. + if (hasBuildScript(targetDir)) { + try { + execa.sync(packageManager.executable, ['run', 'build'], { + cwd: targetDir, + stdio: 'pipe', + encoding: 'utf-8', + }); + } catch (error: $TSAny) { + if ((error as $TSAny).code === 'ENOENT') { + throw new Error(`Building custom resource failed. Could not find ${packageManager.executable} executable in the PATH.`); + } else { + printer.error(`Failed building resource ${resource.resourceName}`); + throw error; + } + } + } else { + // Default behavior: run install and tsc separately + try { + execa.sync(packageManager.executable, ['install'], { + cwd: targetDir, + stdio: 'pipe', + encoding: 'utf-8', + }); + } catch (error: $TSAny) { + if ((error as $TSAny).code === 'ENOENT') { + throw new Error(`Packaging overrides failed. Could not find ${packageManager.executable} executable in the PATH.`); + } else { + throw new Error(`Packaging overrides failed with the error \n${error.message}`); + } } - } - try { - execa.sync(packageManager.runner, ['tsc'], { - cwd: targetDir, - stdio: 'pipe', - encoding: 'utf-8', - }); - } catch (error: $TSAny) { - printer.error(`Failed building resource ${resource.resourceName}`); - throw error; + try { + execa.sync(packageManager.runner, ['tsc'], { + cwd: targetDir, + stdio: 'pipe', + encoding: 'utf-8', + }); + } catch (error: $TSAny) { + printer.error(`Failed building resource ${resource.resourceName}`); + throw error; + } } await generateCloudFormationFromCDK(resource.resourceName);