Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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<typeof fs>;
const JSONUtilities_mock = JSONUtilities as jest.Mocked<typeof JSONUtilities>;

jest.mock('ora', () => () => ({
start: jest.fn(),
Expand All @@ -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'),
},
Expand All @@ -44,13 +48,31 @@ 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', () => {
let mockContext: $TSContext;

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(),
Expand All @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ export const generateDependentResourcesType = async (): Promise<void> => {
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<void> => {
const targetDir = path.resolve(path.join(pathManager.getBackendDirPath(), categoryName, resource.resourceName));
if (skipHooks()) {
Expand All @@ -84,29 +98,50 @@ const buildResource = async (resource: ResourceMeta): Promise<void> => {
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);
Expand Down