diff --git a/package.json b/package.json index 4d44db8a0c6f..05798e7a2460 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@types/express": "^4.16.0", "@types/find-cache-dir": "^3.0.0", "@types/glob": "^7.1.1", + "@types/http-proxy": "^1.17.4", "@types/inquirer": "^7.3.0", "@types/jasmine": "~3.6.0", "@types/karma": "^5.0.0", @@ -153,6 +154,7 @@ "gh-got": "^9.0.0", "git-raw-commits": "^2.0.0", "glob": "7.1.6", + "http-proxy": "^1.18.1", "husky": "^4.0.10", "inquirer": "7.3.3", "jasmine": "^3.3.1", diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 5333457ab981..dc794e7c2b48 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -282,6 +282,8 @@ LARGE_SPECS = { "@npm//@types/node-fetch", "@npm//express", "@npm//node-fetch", + "@npm//@types/http-proxy", + "@npm//http-proxy", "@npm//puppeteer", ], }, diff --git a/packages/angular_devkit/build_angular/src/dev-server/live-reload_spec.ts b/packages/angular_devkit/build_angular/src/dev-server/live-reload_spec.ts new file mode 100644 index 000000000000..608ca2baf9b2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/dev-server/live-reload_spec.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +// tslint:disable: no-implicit-dependencies +import { Architect, BuilderRun } from '@angular-devkit/architect'; +import { tags } from '@angular-devkit/core'; +import { createProxyServer } from 'http-proxy'; +import { HTTPResponse } from 'puppeteer/lib/cjs/puppeteer/api-docs-entry'; +import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser'; +import { Page } from 'puppeteer/lib/cjs/puppeteer/common/Page'; +import puppeteer from 'puppeteer/lib/cjs/puppeteer/node'; +import { debounceTime, switchMap, take } from 'rxjs/operators'; +import { createArchitect, host } from '../test-utils'; + +// tslint:disable-next-line: no-any +declare const document: any; + +interface ProxyInstance { + server: typeof createProxyServer extends () => infer R ? R : never; + url: string; +} + +let proxyPort = 9100; +function createProxy(target: string, secure: boolean): ProxyInstance { + proxyPort++; + + const server = createProxyServer({ + ws: true, + target, + secure, + ssl: secure && { + key: tags.stripIndents` + -----BEGIN RSA PRIVATE KEY----- + MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEBRUsUz4rdcMt + CQGLvG3SzUinsmgdgOyTNQNA0eOMyRSrmS8L+F/kSLUnqqu4mzdeqDzo2Xj553jK + dRqMCRFGJuGnQ/VIbW2A+ywgrqILuDyF5i4PL1aQW4yJ7TnXfONKfpswQArlN6DF + gBYJtoJlf8XD1sOeJpsv/O46/ix/wngQ+GwQQ2cfqxQT0fE9SBCY23VNt3SPUJ3k + 9etJMvJ9U9GHSb1CFdNQe7Gyx7xdKf1TazB27ElNZEg2aF99if47uRskYjvvFivy + 7nxGx/ccIwjwNMpk29AsKG++0sn1yTK7tD5Px6aCSVK0BKbdXZS2euJor8hASGBJ + 3GpVGJvdAgMBAAECggEAapYo8TVCdPdP7ckb4hPP0/R0MVu9aW2VNmZ5ImH+zar5 + ZmWhQ20HF2bBupP/VB5yeTIaDLNUKO9Iqy4KBWNY1UCHKyC023FFPgFV+V98FctU + faqwGOmwtEZToRwxe48ZOISndhEc247oCPyg/x8SwIY9z0OUkwaDFBEAqWtUXxM3 + /SPpCT5ilLgxnRgVB8Fj5Z0q7ThnxNVOmVC1OSIakEj46PzmMXn1pCKLOCUmAAOQ + BnrOZuty2b8b2M/GHsktLZwojQQJmArnIBymTXQTVhaGgKSyOv1qvHLp9L1OJf0/ + Xm+/TqT6ztzhzlftcObdfQZZ5JuoEwlvyrsGFlA3MQKBgQDiQC3KYMG8ViJkWrv6 + XNAFEoAjVEKrtirGWJ66YfQ9KSJ7Zttrd1Y1V1OLtq3z4YMH39wdQ8rOD+yR8mWV + 6Tnsxma6yJXAH8uan8iVbxjIZKF1hnvNCxUoxYmWOmTLcEQMzmxvTzAiR+s6R6Uj + 9LgGqppt30nM4wnOhOJU6UxqbwKBgQDdy03KidbPZuycJSy1C9AIt0jlrxDsYm+U + fZrB6mHEZcgoZS5GbLKinQCdGcgERa05BXvJmNbfZtT5a37YEnbjsTImIhDiBP5P + nW36/9a3Vg1svd1KP2206/Bh3gfZbgTsQg4YogXgjf0Uzuvw18btgTtLVpVyeuqz + TU3eeF30cwKBgQCN6lvOmapsDEs+T3uhqx4AUH53qp63PmjOSUAnANJGmsq6ROZV + HmHAy6nn9Qpf85BRHCXhZWiMoIhvc3As/EINNtWxS6hC/q6jqp4SvcD50cVFBroY + /16iWGXZCX+37A+DSOfTWgSDPEFcKRx41UOpStHbITgVgEPieo/NWxlHmQKBgQDX + JOLs2RB6V0ilnpnjdPXzvncD9fHgmwvJap24BPeZX3HtXViqD76oZsu1mNCg9EW3 + zk3pnEyyoDlvSIreZerVq4kN3HWsCVP3Pqr0kz9g0CRtmy8RWr28hjHDfXD3xPUZ + iGnMEz7IOHOKv722/liFAprV1cNaLUmFbDNg3jmlaQKBgQDG5WwngPhOHmjTnSml + amfEz9a4yEhQqpqgVNW5wwoXOf6DbjL2m/maJh01giThj7inMcbpkZlIclxD0Eu6 + Lof+ctCeqSAJvaVPmd+nv8Yp26zsF1yM8ax9xXjrIvv9fSbycNveGTDCsNNTiYoW + QyvMqmN1kGy20SZbQDD/fLfqBQ== + -----END RSA PRIVATE KEY----- + `, + cert: tags.stripIndents` + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJALz8gD/gAt0OMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV + BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX + aWRnaXRzIFB0eSBMdGQwHhcNMTgxMDIzMTgyMTQ5WhcNMTkxMDIzMTgyMTQ5WjBF + MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 + ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB + CgKCAQEAxAUVLFM+K3XDLQkBi7xt0s1Ip7JoHYDskzUDQNHjjMkUq5kvC/hf5Ei1 + J6qruJs3Xqg86Nl4+ed4ynUajAkRRibhp0P1SG1tgPssIK6iC7g8heYuDy9WkFuM + ie0513zjSn6bMEAK5TegxYAWCbaCZX/Fw9bDniabL/zuOv4sf8J4EPhsEENnH6sU + E9HxPUgQmNt1Tbd0j1Cd5PXrSTLyfVPRh0m9QhXTUHuxsse8XSn9U2swduxJTWRI + NmhffYn+O7kbJGI77xYr8u58Rsf3HCMI8DTKZNvQLChvvtLJ9ckyu7Q+T8emgklS + tASm3V2UtnriaK/IQEhgSdxqVRib3QIDAQABo1AwTjAdBgNVHQ4EFgQUDZBhVKdb + 3BRhLIhuuE522Vsul0IwHwYDVR0jBBgwFoAUDZBhVKdb3BRhLIhuuE522Vsul0Iw + DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABh9WWZwWLgb9/DcTxL72 + 6pI96t4jiF79Q+pPefkaIIi0mE6yodWrTAsBQu9I6bNRaEcCSoiXkP2bqskD/UGg + LwUFgSrDOAA3UjdHw3QU5g2NocduG7mcFwA40TB98sOsxsUyYlzSyWzoiQWwPYwb + hek1djuWkqPXsTjlj54PTPN/SjTFmo4p5Ip6nbRf2nOREl7v0rJpGbJvXiCMYyd+ + Zv+j4mRjCGo8ysMR2HjCUGkYReLAgKyyz3M7i8vevJhKslyOmy6Txn4F0nPVumaU + DDIy4xXPW1STWfsmSYJfYW3wa0wk+pJQ3j2cTzkPQQ8gwpvM3U9DJl43uwb37v6I + 7Q== + -----END CERTIFICATE----- + `, + }, + }) + .listen(proxyPort); + + return { + server, + url: `${secure ? 'https' : 'http'}://localhost:${proxyPort}`, + }; +} + +async function goToPageAndWaitForSockJs(page: Page, url: string): Promise { + const socksRequest = `${url.endsWith('/') ? url : url + '/'}sockjs-node/info?t=`; + + await Promise.all([ + page.waitForResponse((r: HTTPResponse) => r.url().startsWith(socksRequest) && r.status() === 200), + page.goto(url), + ]); +} + +describe('Dev Server Builder live-reload', () => { + const target = { project: 'app', target: 'serve' }; + const overrides = { hmr: false, watch: true, port: 0, liveReload: true }; + let architect: Architect; + let browser: Browser; + let page: Page; + let runs: BuilderRun[]; + let proxy: ProxyInstance | undefined; + + beforeAll(async () => { + browser = await puppeteer.launch({ + // MacOSX users need to set the local binary manually because Chrome has lib files with + // spaces in them which Bazel does not support in runfiles + // See: https://github.com/angular/angular-cli/pull/17624 + // tslint:disable-next-line: max-line-length + // executablePath: '/Users//git/angular-cli/node_modules/puppeteer/.local-chromium/mac-809590/chrome-mac/Chromium.app/Contents/MacOS/Chromium', + args: [ + '--no-sandbox', + '--disable-gpu', + '--ignore-certificate-errors', + '--ignore-urlfetcher-cert-requests', + ], + }); + }); + + afterAll(async () => { + await browser.close(); + }); + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + host.writeMultipleFiles({ + 'src/app/app.component.html': ` +

{{title}}

+ `, + }); + + runs = []; + page = await browser.newPage(); + }); + + afterEach(async () => { + proxy?.server.close(); + proxy = undefined; + await host.restore().toPromise(); + await page.close(); + await Promise.all(runs.map(r => r.stop())); + }); + + it('works without proxy', async () => { + const run = await architect.scheduleTarget(target, overrides); + runs.push(run); + + let buildCount = 0; + await run.output + .pipe( + debounceTime(1000), + switchMap(async buildEvent => { + expect(buildEvent.success).toBe(true); + const url = buildEvent.baseUrl as string; + switch (buildCount) { + case 0: + await goToPageAndWaitForSockJs(page, url); + host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`); + break; + case 1: + const innerText = await page.evaluate(() => document.querySelector('p').innerText); + expect(innerText).toBe('app-live-reload'); + break; + } + + buildCount++; + }), + take(2), + ) + .toPromise(); + }, 30000); + + it('works without http -> http proxy', async () => { + const run = await architect.scheduleTarget(target, overrides); + runs.push(run); + + let proxy: ProxyInstance | undefined; + let buildCount = 0; + await run.output + .pipe( + debounceTime(1000), + switchMap(async buildEvent => { + expect(buildEvent.success).toBe(true); + const url = buildEvent.baseUrl as string; + switch (buildCount) { + case 0: + proxy = createProxy(url, false); + await goToPageAndWaitForSockJs(page, proxy.url); + host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`); + break; + case 1: + const innerText = await page.evaluate(() => document.querySelector('p').innerText); + expect(innerText).toBe('app-live-reload'); + break; + } + + buildCount++; + }), + take(2), + ) + .toPromise(); + }, 30000); + + it('works without https -> http proxy', async () => { + const run = await architect.scheduleTarget(target, overrides); + runs.push(run); + + let proxy: ProxyInstance | undefined; + let buildCount = 0; + await run.output + .pipe( + debounceTime(1000), + switchMap(async buildEvent => { + expect(buildEvent.success).toBe(true); + const url = buildEvent.baseUrl as string; + switch (buildCount) { + case 0: + proxy = createProxy(url, true); + await goToPageAndWaitForSockJs(page, proxy.url); + host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`); + break; + case 1: + const innerText = await page.evaluate(() => document.querySelector('p').innerText); + expect(innerText).toBe('app-live-reload'); + break; + } + + buildCount++; + }), + take(2), + ) + .toPromise(); + }, 30000); +}); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts b/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts index ae98e6efcf4f..53aedcbcb793 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts @@ -56,6 +56,8 @@ export function getDevServerConfig( const parsedHost = url.parse(publicHost); publicHost = parsedHost.host; + } else { + publicHost = '0.0.0.0:0'; } if (!watch) { diff --git a/tests/legacy-cli/e2e/tests/misc/live-reload.ts b/tests/legacy-cli/e2e/tests/misc/live-reload.ts deleted file mode 100644 index 8787089dcd7d..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/live-reload.ts +++ /dev/null @@ -1,172 +0,0 @@ -export default function temporarilyDisabledTest() { return Promise.resolve(); } -// import * as os from 'os'; -// import * as _ from 'lodash'; -// import * as express from 'express'; -// import * as http from 'http'; - -// import {appendToFile, writeMultipleFiles, writeFile} from '../../utils/fs'; -// import { -// killAllProcesses, -// execAndWaitForOutputToMatch, -// waitForAnyProcessOutputToMatch -// } from '../../utils/process'; -// import { wait } from '../../utils/utils'; - - -// export default function () { -// const protractorGoodRegEx = /Jasmine started/; -// const webpackGoodRegEx = / Compiled successfully./; - -// // Create an express api for the Angular app to call. -// const app = express(); -// const server = http.createServer(app); -// let liveReloadCount = 0; -// function resetApiVars() { -// liveReloadCount = 0; -// } - -// server.listen(0); -// app.set('port', server.address().port); - -// const firstLocalIp = _(os.networkInterfaces()) -// .values() -// .flatten() -// .filter({ family: 'IPv4', internal: false }) -// .map('address') -// .first(); -// const publicHost = `${firstLocalIp}:4200`; - -// const apiUrl = `http://localhost:${server.address().port}`; - -// // This endpoint will be pinged by the main app on each reload. -// app.get('/live-reload-count', _ => liveReloadCount++); - -// const proxyConfigFile = 'proxy.config.json'; -// const proxyConfig = { -// '/live-reload-count': { -// target: apiUrl -// } -// }; - -// return Promise.resolve() -// .then(_ => writeMultipleFiles({ -// 'src/app/app.module.ts': ` -// import { BrowserModule } from '@angular/platform-browser'; -// import { NgModule } from '@angular/core'; -// import { FormsModule } from '@angular/forms'; -// import { HttpModule } from '@angular/http'; -// import { AppComponent } from './app.component'; -// @NgModule({ -// declarations: [ -// AppComponent -// ], -// imports: [ -// BrowserModule, -// FormsModule, -// HttpModule -// ], -// providers: [], -// bootstrap: [AppComponent] -// }) -// export class AppModule { } -// `, -// // e2e test that just opens the page and waits, so that the app runs. -// './e2e/app.e2e-spec.ts': ` -// import { browser } from 'protractor'; - -// describe('master-project App', function() { -// it('should wait', _ => { -// browser.get('/'); -// browser.sleep(30000); -// }); -// }); -// `, -// // App that calls the express server once. -// './src/app/app.component.ts': ` -// import { Component } from '@angular/core'; -// import { Http } from '@angular/http'; - -// @Component({ -// selector: 'app-root', -// template: '

Live reload test

' -// }) -// export class AppComponent { -// constructor(private http: Http) { -// http.get('${apiUrl + '/live-reload-count'}').subscribe(res => null); -// } -// } -// ` -// })) -// .then(_ => execAndWaitForOutputToMatch( -// 'ng', -// ['e2e', '--watch', '--live-reload'], -// protractorGoodRegEx -// )) -// // Let app run. -// .then(_ => wait(2000)) -// .then(_ => appendToFile('src/main.ts', 'console.log(1);')) -// .then(_ => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) -// .then(_ => wait(2000)) -// .then(_ => { -// if (liveReloadCount != 2) { -// throw new Error( -// `Expected API to have been called 2 times but it was called ${liveReloadCount} times.` -// ); -// } -// }) -// .then(_ => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) -// .then(_ => resetApiVars()) -// // Serve with live reload off should call api only once. -// .then(_ => execAndWaitForOutputToMatch( -// 'ng', -// ['e2e', '--watch', '--no-live-reload'], -// protractorGoodRegEx -// )) -// .then(_ => wait(2000)) -// .then(_ => appendToFile('src/main.ts', 'console.log(1);')) -// .then(_ => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) -// .then(_ => wait(2000)) -// .then(_ => { -// if (liveReloadCount != 1) { -// throw new Error( -// `Expected API to have been called 1 time but it was called ${liveReloadCount} times.` -// ); -// } -// }) -// .then(_ => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) -// .then(_ => resetApiVars()) -// // Serve with live reload client set to api should call api. -// .then(() => writeFile(proxyConfigFile, JSON.stringify(proxyConfig, null, 2))) -// // Update the component to call the webserver -// .then(() => writeFile('./src/app/app.component.ts', -// ` -// import { Component } from '@angular/core'; -// import { Http } from '@angular/http'; -// @Component({ -// selector: 'app-root', -// template: '

Live reload test

' -// }) -// export class AppComponent { -// constructor(private http: Http) { -// http.get('http://${publicHost + '/live-reload-count'}').subscribe(res => null); -// } -// }`)) -// .then(_ => execAndWaitForOutputToMatch( -// 'ng', -// ['e2e', '--watch', '--host=0.0.0.0', '--port=4200', `--public-host=${publicHost}`, '--proxy', proxyConfigFile], -// protractorGoodRegEx -// )) -// .then(_ => wait(2000)) -// .then(_ => appendToFile('src/main.ts', 'console.log(1);')) -// .then(_ => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) -// .then(_ => wait(2000)) -// .then(_ => { -// if (liveReloadCount != 2) { -// throw new Error( -// `Expected API to have been called 2 times but it was called ${liveReloadCount} times.` -// ); -// } -// }) -// .then(_ => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) -// .then(_ => server.close(), (err) => { server.close(); throw err; }); -// } diff --git a/yarn.lock b/yarn.lock index 8024faf881bc..7f38180190e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1558,7 +1558,7 @@ "@types/http-proxy" "*" "@types/node" "*" -"@types/http-proxy@*": +"@types/http-proxy@*", "@types/http-proxy@^1.17.4": version "1.17.4" resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b" integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==