Skip to content

Commit 4b4e000

Browse files
committed
fix(@schematics/angular): don't show server routing prompt when using browser builder
The new routing APIs don't support `browser` builder, but calling `ng add @angular/ssr` with a `browser` builder would still prompt the user to add them. If the user said "Yes", it would actually ignore that answer and not enable the new APIs. With this change, `ng add @angular/ssr` when using `browser` builder does not show the prompt and assumes the answer is "No". It also throws an error if the user runs `ng add @angular/ssr --server-routing`. I'm not aware of a built-in prompting mechanism in schematics beyond `x-prompt`, which can't be used here, so instead I just called Inquirer directly. Unfortunately testing the prompt is a little awkward, as Inquirier does not provide useful APIs in this space. I evaluated `@inquirer/testing`, but ultimately decided that was more intended for testing custom Inquirer prompts, not mocking usage of standard prompts. Schematics APIs do not provide a useful way to inject additional data like a mock, so instead I had to do this through a `setPrompterForTestOnly` function. I'm not a huge fan of it, but I don't see a more straightforward way of solving the problem. (cherry picked from commit 160dee3)
1 parent 2551df5 commit 4b4e000

File tree

12 files changed

+243
-21
lines changed

12 files changed

+243
-21
lines changed

packages/angular/ssr/schematics/ng-add/index_spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { join } from 'node:path';
1414
describe('@angular/ssr ng-add schematic', () => {
1515
const defaultOptions = {
1616
project: 'test-app',
17+
serverRouting: false,
1718
};
1819

1920
const schematicRunner = new SchematicTestRunner(

packages/schematics/angular/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ ts_library(
8484
"//packages/angular_devkit/schematics",
8585
"//packages/angular_devkit/schematics/tasks",
8686
"//packages/schematics/angular/third_party/github.com/Microsoft/TypeScript",
87+
"@npm//@inquirer/prompts",
8788
"@npm//@types/node",
8889
"@npm//browserslist",
8990
"@npm//jsonc-parser",

packages/schematics/angular/application/index_spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe('Application Schematic', () => {
3232
const defaultOptions: ApplicationOptions = {
3333
name: 'foo',
3434
skipPackageJson: false,
35+
serverRouting: false,
3536
};
3637

3738
let workspaceTree: UnitTestTree;

packages/schematics/angular/ssr/index.ts

+58-5
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ProjectDefinition, getWorkspace } from '../utility/workspace';
3838
import { Builders } from '../utility/workspace-models';
3939

4040
import { Schema as SSROptions } from './schema';
41+
import { isTTY } from './tty';
4142

4243
const SERVE_SSR_TARGET_NAME = 'serve-ssr';
4344
const PRERENDER_TARGET_NAME = 'prerender';
@@ -85,7 +86,7 @@ async function getApplicationBuilderOutputPaths(
8586
const { outputPath } = architectTarget.options;
8687
if (outputPath === null || outputPath === undefined) {
8788
throw new SchematicsException(
88-
`outputPath for ${projectName} ${target} target is undeined or null.`,
89+
`outputPath for ${projectName} ${target} target is undefined or null.`,
8990
);
9091
}
9192

@@ -361,19 +362,20 @@ function addServerFile(
361362
};
362363
}
363364

364-
export default function (options: SSROptions): Rule {
365+
export default function (inputOptions: SSROptions): Rule {
365366
return async (host, context) => {
366-
const browserEntryPoint = await getMainFilePath(host, options.project);
367+
const browserEntryPoint = await getMainFilePath(host, inputOptions.project);
367368
const isStandalone = isStandaloneApp(host, browserEntryPoint);
368369

369370
const workspace = await getWorkspace(host);
370-
const clientProject = workspace.projects.get(options.project);
371+
const clientProject = workspace.projects.get(inputOptions.project);
371372
if (!clientProject) {
372373
throw targetBuildNotFoundError();
373374
}
374375

375376
const isUsingApplicationBuilder = usingApplicationBuilder(clientProject);
376-
377+
const serverRouting = await isServerRoutingEnabled(isUsingApplicationBuilder, inputOptions);
378+
const options = { ...inputOptions, serverRouting };
377379
const sourceRoot = clientProject.sourceRoot ?? posix.join(clientProject.root, 'src');
378380

379381
return chain([
@@ -404,3 +406,54 @@ function usingApplicationBuilder(project: ProjectDefinition) {
404406

405407
return isUsingApplicationBuilder;
406408
}
409+
410+
// Wrap inquirer in a `prompt` function.
411+
export type Prompt = (message: string, defaultValue: boolean) => Promise<boolean>;
412+
const defaultPrompter: Prompt = async (message, defaultValue) => {
413+
const { confirm } = await import('@inquirer/prompts');
414+
415+
return await confirm({
416+
message,
417+
default: defaultValue,
418+
});
419+
};
420+
421+
// Allow the prompt functionality to be overridden to facilitate testing.
422+
let prompt = defaultPrompter;
423+
export function setPrompterForTestOnly(prompter?: Prompt): void {
424+
prompt = prompter ?? defaultPrompter;
425+
}
426+
427+
/** Returns whether or not server routing is enabled, potentially prompting the user if necessary. */
428+
async function isServerRoutingEnabled(
429+
isUsingApplicationBuilder: boolean,
430+
options: SSROptions,
431+
): Promise<boolean> {
432+
if (!isUsingApplicationBuilder) {
433+
if (options.serverRouting) {
434+
throw new SchematicsException(
435+
'Server routing APIs can only be added to a project using `application` builder.',
436+
);
437+
} else {
438+
return false;
439+
}
440+
}
441+
442+
// Use explicit option if provided.
443+
if (options.serverRouting !== undefined) {
444+
return options.serverRouting;
445+
}
446+
447+
const serverRoutingDefault = false;
448+
449+
// Use the default if not in an interactive terminal.
450+
if (!isTTY()) {
451+
return serverRoutingDefault;
452+
}
453+
454+
// Prompt the user if in an interactive terminal and no option was provided.
455+
return await prompt(
456+
'Would you like to use the Server Routing and App Engine APIs (Developer Preview) for this server application?',
457+
/* defaultValue */ serverRoutingDefault,
458+
);
459+
}

packages/schematics/angular/ssr/index_spec.ts

+102
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/te
1010

1111
import { join } from 'node:path';
1212
import { Schema as ServerOptions } from './schema';
13+
import { Prompt, setPrompterForTestOnly } from './index';
1314

1415
describe('SSR Schematic', () => {
1516
const defaultOptions: ServerOptions = {
1617
project: 'test-app',
18+
serverRouting: false,
1719
};
1820

1921
const schematicRunner = new SchematicTestRunner(
@@ -30,6 +32,10 @@ describe('SSR Schematic', () => {
3032
};
3133

3234
beforeEach(async () => {
35+
setPrompterForTestOnly((message) => {
36+
return fail(`Unmocked prompt: ${message}`) as never;
37+
});
38+
3339
appTree = await schematicRunner.runExternalSchematic(
3440
'@schematics/angular',
3541
'workspace',
@@ -81,6 +87,8 @@ describe('SSR Schematic', () => {
8187
});
8288

8389
describe('standalone application', () => {
90+
const originalTty = process.env['NG_FORCE_TTY'];
91+
8492
beforeEach(async () => {
8593
appTree = await schematicRunner.runExternalSchematic(
8694
'@schematics/angular',
@@ -98,6 +106,10 @@ describe('SSR Schematic', () => {
98106
);
99107
});
100108

109+
afterEach(() => {
110+
process.env['NG_FORCE_TTY'] = originalTty;
111+
});
112+
101113
it('should add script section in package.json', async () => {
102114
const tree = await schematicRunner.runSchematic('ssr', defaultOptions, appTree);
103115
const { scripts } = tree.readJson('/package.json') as { scripts: Record<string, string> };
@@ -150,6 +162,74 @@ describe('SSR Schematic', () => {
150162
server: 'node-server',
151163
});
152164
});
165+
166+
it('generates server routing configuration when enabled', async () => {
167+
const tree = await schematicRunner.runSchematic(
168+
'ssr',
169+
{ ...defaultOptions, serverRouting: true },
170+
appTree,
171+
);
172+
173+
expect(tree.exists('/projects/test-app/src/app/app.routes.server.ts')).toBeTrue();
174+
});
175+
176+
it('does not generate server routing configuration when disabled', async () => {
177+
const tree = await schematicRunner.runSchematic(
178+
'ssr',
179+
{ ...defaultOptions, serverRouting: false },
180+
appTree,
181+
);
182+
183+
expect(tree.exists('/projects/test-app/src/app/app.routes.server.ts')).toBeFalse();
184+
});
185+
186+
it('generates server routing configuration when prompt is accepted by the user', async () => {
187+
const prompter = jasmine.createSpy<Prompt>('prompt').and.resolveTo(true);
188+
setPrompterForTestOnly(prompter);
189+
190+
process.env['NG_FORCE_TTY'] = 'TRUE';
191+
const tree = await schematicRunner.runSchematic(
192+
'ssr',
193+
{ ...defaultOptions, serverRouting: undefined },
194+
appTree,
195+
);
196+
197+
expect(prompter).toHaveBeenCalledTimes(1);
198+
199+
expect(tree.exists('/projects/test-app/src/app/app.routes.server.ts')).toBeTrue();
200+
});
201+
202+
it('does not generate server routing configuration when prompt is rejected by the user', async () => {
203+
const prompter = jasmine.createSpy<Prompt>('prompt').and.resolveTo(false);
204+
setPrompterForTestOnly(prompter);
205+
206+
process.env['NG_FORCE_TTY'] = 'TRUE';
207+
const tree = await schematicRunner.runSchematic(
208+
'ssr',
209+
{ ...defaultOptions, serverRouting: undefined },
210+
appTree,
211+
);
212+
213+
expect(prompter).toHaveBeenCalledTimes(1);
214+
215+
expect(tree.exists('/projects/test-app/src/app/app.routes.server.ts')).toBeFalse();
216+
});
217+
218+
it('defaults to skipping server route generation when not in an interactive terminal', async () => {
219+
const prompter = jasmine.createSpy<Prompt>('prompt').and.resolveTo(false);
220+
setPrompterForTestOnly(prompter);
221+
222+
process.env['NG_FORCE_TTY'] = 'FALSE';
223+
const tree = await schematicRunner.runSchematic(
224+
'ssr',
225+
{ ...defaultOptions, serverRouting: undefined },
226+
appTree,
227+
);
228+
229+
expect(prompter).not.toHaveBeenCalled();
230+
231+
expect(tree.exists('/projects/test-app/src/app/app.routes.server.ts')).toBeFalse();
232+
});
153233
});
154234

155235
describe('Legacy browser builder', () => {
@@ -216,5 +296,27 @@ describe('SSR Schematic', () => {
216296
const content = tree.readContent('/projects/test-app/src/server.ts');
217297
expect(content).toContain(`const distFolder = join(process.cwd(), 'dist/test-app/browser');`);
218298
});
299+
300+
it('throws an exception when used with `serverRouting`', async () => {
301+
await expectAsync(
302+
schematicRunner.runSchematic('ssr', { ...defaultOptions, serverRouting: true }, appTree),
303+
).toBeRejectedWithError(/Server routing APIs.*`application` builder/);
304+
});
305+
306+
it('automatically disables `serverRouting` and does not prompt for it', async () => {
307+
const prompter = jasmine.createSpy<Prompt>('prompt').and.resolveTo(false);
308+
setPrompterForTestOnly(prompter);
309+
310+
process.env['NG_FORCE_TTY'] = 'TRUE';
311+
const tree = await schematicRunner.runSchematic(
312+
'ssr',
313+
{ ...defaultOptions, serverRouting: undefined },
314+
appTree,
315+
);
316+
317+
expect(prompter).not.toHaveBeenCalled();
318+
319+
expect(tree.exists('/projects/test-app/src/app/app.routes.server.ts')).toBeFalse();
320+
});
219321
});
220322
});

packages/schematics/angular/ssr/schema.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@
1818
},
1919
"serverRouting": {
2020
"description": "Creates a server application using the Server Routing and App Engine APIs (Developer Preview).",
21-
"x-prompt": "Would you like to use the Server Routing and App Engine APIs (Developer Preview) for this server application?",
22-
"type": "boolean",
23-
"default": false
21+
"type": "boolean"
2422
}
2523
},
2624
"required": ["project"],
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
function _isTruthy(value: undefined | string): boolean {
10+
// Returns true if value is a string that is anything but 0 or false.
11+
return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE';
12+
}
13+
14+
export function isTTY(): boolean {
15+
// If we force TTY, we always return true.
16+
const force = process.env['NG_FORCE_TTY'];
17+
if (force !== undefined) {
18+
return _isTruthy(force);
19+
}
20+
21+
return !!process.stdout.isTTY && !_isTruthy(process.env['CI']);
22+
}

tests/legacy-cli/e2e/tests/build/prerender/discover-routes-ngmodule.ts

+22-9
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,28 @@ export default async function () {
3232

3333
// Forcibly remove in case another test doesn't clean itself up.
3434
await rimraf('node_modules/@angular/ssr');
35-
await ng(
36-
'add',
37-
'@angular/ssr',
38-
'--project',
39-
projectName,
40-
'--skip-confirmation',
41-
'--skip-install',
42-
'--server-routing',
43-
);
35+
if (useWebpackBuilder) {
36+
await ng(
37+
'add',
38+
'@angular/ssr',
39+
'--project',
40+
projectName,
41+
'--skip-confirmation',
42+
'--skip-install',
43+
// Server routing is not supported on `browser` builder.
44+
// '--server-routing',
45+
);
46+
} else {
47+
await ng(
48+
'add',
49+
'@angular/ssr',
50+
'--project',
51+
projectName,
52+
'--skip-confirmation',
53+
'--skip-install',
54+
'--server-routing',
55+
);
56+
}
4457

4558
await useSha();
4659
await installWorkspacePackages();

tests/legacy-cli/e2e/tests/build/server-rendering/express-engine-csp-nonce.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ export default async function () {
99
const useWebpackBuilder = !getGlobalVariable('argv')['esbuild'];
1010
// forcibly remove in case another test doesn't clean itself up
1111
await rimraf('node_modules/@angular/ssr');
12-
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
12+
13+
if (useWebpackBuilder) {
14+
// `--server-routing` not supported in `browser` builder.
15+
await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install');
16+
} else {
17+
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
18+
}
1319

1420
await useSha();
1521
await installWorkspacePackages();

tests/legacy-cli/e2e/tests/build/server-rendering/express-engine-standalone.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@ import { updateJsonFile, updateServerFileForWebpack, useSha } from '../../../uti
88
export default async function () {
99
// forcibly remove in case another test doesn't clean itself up
1010
await rimraf('node_modules/@angular/ssr');
11-
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
1211

1312
const useWebpackBuilder = !getGlobalVariable('argv')['esbuild'];
13+
14+
if (useWebpackBuilder) {
15+
// `--server-routing` not supported in `browser` builder.
16+
await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install');
17+
} else {
18+
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
19+
}
20+
1421
if (!useWebpackBuilder) {
1522
// Disable prerendering
1623
await updateJsonFile('angular.json', (json) => {

tests/legacy-cli/e2e/tests/commands/serve/ssr-http-requests-assets.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@ import { killAllProcesses, ng } from '../../../utils/process';
44
import { rimraf, writeMultipleFiles } from '../../../utils/fs';
55
import { installWorkspacePackages } from '../../../utils/packages';
66
import { ngServe, useSha } from '../../../utils/project';
7+
import { getGlobalVariable } from '../../../utils/env';
78

89
export default async function () {
10+
const useWebpackBuilder = !getGlobalVariable('argv')['esbuild'];
11+
912
// Forcibly remove in case another test doesn't clean itself up.
1013
await rimraf('node_modules/@angular/ssr');
11-
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation');
14+
if (useWebpackBuilder) {
15+
// `--server-routing` not supported in `browser` builder.
16+
await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install');
17+
} else {
18+
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
19+
}
20+
1221
await useSha();
1322
await installWorkspacePackages();
1423

0 commit comments

Comments
 (0)