Skip to content

Commit 4f8c822

Browse files
committed
test: add comprehensive tests for builder.ts (100% coverage)
- Extract executeDeploy() function for testability - Add 11 tests covering: - browserTarget rejection with error messages - Missing context.target error - Deploy error handling (Error and non-Error values) - Build target resolution precedence
1 parent 7c8a68f commit 4f8c822

File tree

2 files changed

+277
-46
lines changed

2 files changed

+277
-46
lines changed

src/deploy/builder.spec.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* Tests for builder.ts - specifically testing executeDeploy function
3+
* which contains the browserTarget rejection and error handling logic.
4+
*/
5+
6+
import {
7+
BuilderContext,
8+
BuilderRun,
9+
ScheduleOptions,
10+
Target
11+
} from '@angular-devkit/architect/src';
12+
import { JsonObject, logging } from '@angular-devkit/core';
13+
import { Schema } from './schema';
14+
15+
// Mock the deploy function to prevent actual deployment
16+
jest.mock('./actions', () => ({
17+
__esModule: true,
18+
default: jest.fn().mockResolvedValue(undefined)
19+
}));
20+
21+
// Mock the engine module
22+
jest.mock('../engine/engine', () => ({
23+
run: jest.fn().mockResolvedValue(undefined),
24+
prepareOptions: jest.fn().mockImplementation((options) => Promise.resolve(options))
25+
}));
26+
27+
// Import after mocking dependencies
28+
import { executeDeploy } from './builder';
29+
import deployMock from './actions';
30+
31+
describe('builder.ts executeDeploy', () => {
32+
let mockContext: BuilderContext;
33+
let errorSpy: jest.Mock;
34+
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
errorSpy = jest.fn();
38+
39+
mockContext = {
40+
target: {
41+
configuration: 'production',
42+
project: 'test-project',
43+
target: 'deploy'
44+
},
45+
builder: {
46+
builderName: 'angular-cli-ghpages:deploy',
47+
description: 'Deploy to GitHub Pages',
48+
optionSchema: false
49+
},
50+
currentDirectory: '/test',
51+
id: 1,
52+
logger: {
53+
error: errorSpy,
54+
info: jest.fn(),
55+
warn: jest.fn(),
56+
debug: jest.fn(),
57+
fatal: jest.fn(),
58+
log: jest.fn(),
59+
createChild: jest.fn()
60+
} as unknown as logging.LoggerApi,
61+
workspaceRoot: '/test',
62+
addTeardown: jest.fn(),
63+
validateOptions: jest.fn().mockResolvedValue({}),
64+
getBuilderNameForTarget: jest.fn().mockResolvedValue(''),
65+
getTargetOptions: jest.fn().mockResolvedValue({ outputPath: 'dist/test' } as JsonObject),
66+
reportProgress: jest.fn(),
67+
reportStatus: jest.fn(),
68+
reportRunning: jest.fn(),
69+
scheduleBuilder: jest.fn().mockResolvedValue({} as BuilderRun),
70+
scheduleTarget: jest.fn().mockResolvedValue({
71+
result: Promise.resolve({ success: true })
72+
} as BuilderRun)
73+
} as unknown as BuilderContext;
74+
});
75+
76+
describe('browserTarget rejection', () => {
77+
it('should reject browserTarget option with clear error message and return success: false', async () => {
78+
const options = { browserTarget: 'test-project:build:production' } as unknown as Schema;
79+
80+
const result = await executeDeploy(options, mockContext);
81+
82+
expect(result.success).toBe(false);
83+
expect(errorSpy).toHaveBeenCalledWith('❌ The "browserTarget" option is not supported.');
84+
expect(errorSpy).toHaveBeenCalledWith(' Use "buildTarget" instead.');
85+
});
86+
87+
it('should not call deploy when browserTarget is provided', async () => {
88+
const options = { browserTarget: 'test-project:build:production' } as unknown as Schema;
89+
90+
await executeDeploy(options, mockContext);
91+
92+
expect(deployMock).not.toHaveBeenCalled();
93+
});
94+
95+
it('should proceed normally when buildTarget is used instead of browserTarget', async () => {
96+
const options: Schema = {
97+
buildTarget: 'test-project:build:production',
98+
noBuild: true
99+
};
100+
101+
const result = await executeDeploy(options, mockContext);
102+
103+
expect(result.success).toBe(true);
104+
expect(errorSpy).not.toHaveBeenCalled();
105+
expect(deployMock).toHaveBeenCalled();
106+
});
107+
});
108+
109+
describe('missing context.target', () => {
110+
it('should throw error when context.target is undefined', async () => {
111+
const contextWithoutTarget = {
112+
...mockContext,
113+
target: undefined
114+
} as unknown as BuilderContext;
115+
116+
const options: Schema = { noBuild: true };
117+
118+
await expect(executeDeploy(options, contextWithoutTarget)).rejects.toThrow(
119+
'Cannot deploy the application without a target'
120+
);
121+
});
122+
});
123+
124+
describe('deploy error handling', () => {
125+
it('should catch deploy errors and return success: false with error message', async () => {
126+
(deployMock as jest.Mock).mockRejectedValueOnce(new Error('Deployment failed'));
127+
128+
const options: Schema = { noBuild: true };
129+
130+
const result = await executeDeploy(options, mockContext);
131+
132+
expect(result.success).toBe(false);
133+
expect(errorSpy).toHaveBeenCalledWith('❌ An error occurred when trying to deploy:');
134+
expect(errorSpy).toHaveBeenCalledWith('Deployment failed');
135+
});
136+
137+
it('should handle non-Error thrown values using String()', async () => {
138+
(deployMock as jest.Mock).mockRejectedValueOnce('String error');
139+
140+
const options: Schema = { noBuild: true };
141+
142+
const result = await executeDeploy(options, mockContext);
143+
144+
expect(result.success).toBe(false);
145+
expect(errorSpy).toHaveBeenCalledWith('❌ An error occurred when trying to deploy:');
146+
expect(errorSpy).toHaveBeenCalledWith('String error');
147+
});
148+
149+
it('should handle object thrown values using String()', async () => {
150+
(deployMock as jest.Mock).mockRejectedValueOnce({ code: 500, msg: 'Server error' });
151+
152+
const options: Schema = { noBuild: true };
153+
154+
const result = await executeDeploy(options, mockContext);
155+
156+
expect(result.success).toBe(false);
157+
expect(errorSpy).toHaveBeenCalledWith('[object Object]');
158+
});
159+
});
160+
161+
describe('build target resolution', () => {
162+
it('should use prerenderTarget when provided', async () => {
163+
const options: Schema = {
164+
prerenderTarget: 'test-project:prerender:production',
165+
noBuild: true
166+
};
167+
168+
await executeDeploy(options, mockContext);
169+
170+
expect(deployMock).toHaveBeenCalledWith(
171+
expect.anything(),
172+
mockContext,
173+
{ name: 'test-project:prerender:production' },
174+
options
175+
);
176+
});
177+
178+
it('should use buildTarget when provided (no prerenderTarget)', async () => {
179+
const options: Schema = {
180+
buildTarget: 'test-project:build:staging',
181+
noBuild: true
182+
};
183+
184+
await executeDeploy(options, mockContext);
185+
186+
expect(deployMock).toHaveBeenCalledWith(
187+
expect.anything(),
188+
mockContext,
189+
{ name: 'test-project:build:staging' },
190+
options
191+
);
192+
});
193+
194+
it('should use default build target when neither prerenderTarget nor buildTarget provided', async () => {
195+
const options: Schema = { noBuild: true };
196+
197+
await executeDeploy(options, mockContext);
198+
199+
expect(deployMock).toHaveBeenCalledWith(
200+
expect.anything(),
201+
mockContext,
202+
{ name: 'test-project:build:production' },
203+
options
204+
);
205+
});
206+
207+
it('should prefer prerenderTarget over buildTarget when both provided', async () => {
208+
const options: Schema = {
209+
buildTarget: 'test-project:build:production',
210+
prerenderTarget: 'test-project:prerender:production',
211+
noBuild: true
212+
};
213+
214+
await executeDeploy(options, mockContext);
215+
216+
expect(deployMock).toHaveBeenCalledWith(
217+
expect.anything(),
218+
mockContext,
219+
{ name: 'test-project:prerender:production' },
220+
options
221+
);
222+
});
223+
});
224+
});

src/deploy/builder.ts

Lines changed: 53 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,57 +9,64 @@ import deploy from './actions';
99
import { Schema } from './schema';
1010
import { BuildTarget } from '../interfaces';
1111

12-
// Call the createBuilder() function to create a builder. This mirrors
13-
// createJobHandler() but add typings specific to Architect Builders.
14-
//
15-
// if something breaks here, see how angularfire has fixed it:
16-
// https://github.com/angular/angularfire/blob/master/src/schematics/deploy/builder.ts
17-
export default createBuilder(
18-
async (options: Schema, context: BuilderContext): Promise<BuilderOutput> => {
19-
// browserTarget is not supported - use buildTarget instead
20-
if ((options as Record<string, unknown>).browserTarget) {
21-
context.logger.error('❌ The "browserTarget" option is not supported.');
22-
context.logger.error(' Use "buildTarget" instead.');
23-
return { success: false };
24-
}
12+
/**
13+
* The core builder handler function.
14+
* Exported separately for testing purposes.
15+
*/
16+
export async function executeDeploy(
17+
options: Schema,
18+
context: BuilderContext
19+
): Promise<BuilderOutput> {
20+
// browserTarget is not supported - use buildTarget instead
21+
if ((options as Record<string, unknown>).browserTarget) {
22+
context.logger.error('❌ The "browserTarget" option is not supported.');
23+
context.logger.error(' Use "buildTarget" instead.');
24+
return { success: false };
25+
}
26+
27+
if (!context.target) {
28+
throw new Error('Cannot deploy the application without a target');
29+
}
2530

26-
if (!context.target) {
27-
throw new Error('Cannot deploy the application without a target');
28-
}
31+
const staticBuildTarget: BuildTarget = {
32+
name:
33+
options.buildTarget || `${context.target.project}:build:production`
34+
};
2935

30-
const staticBuildTarget: BuildTarget = {
31-
name:
32-
options.buildTarget || `${context.target.project}:build:production`
36+
let prerenderBuildTarget: BuildTarget | undefined;
37+
if (options.prerenderTarget) {
38+
prerenderBuildTarget = {
39+
name: options.prerenderTarget
3340
};
41+
}
3442

35-
let prerenderBuildTarget: BuildTarget | undefined;
36-
if (options.prerenderTarget) {
37-
prerenderBuildTarget = {
38-
name: options.prerenderTarget
39-
};
40-
}
43+
// serverBuildTarget is not supported and is completely ignored
44+
// let serverBuildTarget: BuildTarget | undefined;
45+
// if (options.ssr) {
46+
// serverBuildTarget = {
47+
// name: options.serverTarget || options.universalBuildTarget || `${context.target.project}:server:production`
48+
// };
49+
// }
4150

42-
// serverBuildTarget is not supported and is completely ignored
43-
// let serverBuildTarget: BuildTarget | undefined;
44-
// if (options.ssr) {
45-
// serverBuildTarget = {
46-
// name: options.serverTarget || options.universalBuildTarget || `${context.target.project}:server:production`
47-
// };
48-
// }
51+
const finalBuildTarget = prerenderBuildTarget
52+
? prerenderBuildTarget
53+
: staticBuildTarget;
4954

50-
const finalBuildTarget = prerenderBuildTarget
51-
? prerenderBuildTarget
52-
: staticBuildTarget;
55+
try {
56+
await deploy(engine, context, finalBuildTarget, options);
57+
} catch (e) {
58+
context.logger.error('❌ An error occurred when trying to deploy:');
59+
const message = e instanceof Error ? e.message : String(e);
60+
context.logger.error(message);
61+
return { success: false };
62+
}
5363

54-
try {
55-
await deploy(engine, context, finalBuildTarget, options);
56-
} catch (e) {
57-
context.logger.error('❌ An error occurred when trying to deploy:');
58-
const message = e instanceof Error ? e.message : String(e);
59-
context.logger.error(message);
60-
return { success: false };
61-
}
64+
return { success: true };
65+
}
6266

63-
return { success: true };
64-
}
65-
);
67+
// Call the createBuilder() function to create a builder. This mirrors
68+
// createJobHandler() but add typings specific to Architect Builders.
69+
//
70+
// if something breaks here, see how angularfire has fixed it:
71+
// https://github.com/angular/angularfire/blob/master/src/schematics/deploy/builder.ts
72+
export default createBuilder(executeDeploy);

0 commit comments

Comments
 (0)