From 7774fbe868d25955b8d5f7bc39b064d4be89464b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:27:11 -0500 Subject: [PATCH 1/2] test: migrate `app-shell-with-service-worker` E2E test to Puppeteer Replaces the Protractor-based ng e2e execution with the new Puppeteer executeBrowserTest utility in `build/app-shell/app-shell-with-service-worker.ts` E2E test. --- .../app-shell-with-service-worker.ts | 40 ++++++++++--------- tests/e2e/utils/puppeteer.ts | 2 +- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts b/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts index b69e28e9ea38..ce247410e282 100644 --- a/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts +++ b/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts @@ -1,8 +1,10 @@ +import { setTimeout } from 'node:timers/promises'; import { getGlobalVariable } from '../../../utils/env'; -import { appendToFile, expectFileToMatch, writeFile } from '../../../utils/fs'; +import { appendToFile, expectFileToMatch } from '../../../utils/fs'; import { installPackage } from '../../../utils/packages'; import { ng } from '../../../utils/process'; import { updateJsonFile } from '../../../utils/project'; +import { executeBrowserTest } from '../../../utils/puppeteer'; const snapshots = require('../../../ng-snapshot/package.json'); @@ -31,26 +33,26 @@ export default async function () { } } - await writeFile( - 'e2e/app.e2e-spec.ts', - ` - import { browser, by, element } from 'protractor'; + await ng('build'); + await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); - it('should have ngsw in normal state', () => { - browser.get('/'); + await executeBrowserTest({ + configuration: 'production', + checkFn: async (page) => { // Wait for service worker to load. - browser.sleep(2000); - browser.waitForAngularEnabled(false); - browser.get('/ngsw/state'); - // Should have updated, and be in normal state. - expect(element(by.css('pre')).getText()).not.toContain('Last update check: never'); - expect(element(by.css('pre')).getText()).toContain('Driver state: NORMAL'); - }); - `, - ); + await setTimeout(2000); - await ng('build'); - await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); + const baseUrl = page.url(); + await page.goto(new URL('/ngsw/state', baseUrl).href); - await ng('e2e', '--configuration=production'); + // Should have updated, and be in normal state. + const preText = await page.$eval('pre', (el) => el.textContent); + if (preText?.includes('Last update check: never')) { + throw new Error(`Expected service worker to have checked for updates, but got: ${preText}`); + } + if (!preText?.includes('Driver state: NORMAL')) { + throw new Error(`Expected service worker driver state to be NORMAL, but got: ${preText}`); + } + }, + }); } diff --git a/tests/e2e/utils/puppeteer.ts b/tests/e2e/utils/puppeteer.ts index 8cab9f2ddef6..647773964ec5 100644 --- a/tests/e2e/utils/puppeteer.ts +++ b/tests/e2e/utils/puppeteer.ts @@ -18,7 +18,7 @@ export async function executeBrowserTest(options: BrowserTestOptions = {}) { if (!url) { // Start serving and find address (1 - Webpack; 2 - Vite) const match = /(?:open your browser on|Local:)\s+(http:\/\/localhost:\d+\/)/; - const serveArgs = ['serve', '--port=0']; + const serveArgs = ['serve', '--port=0', '--no-watch', '--no-live-reload']; if (options.project) { serveArgs.push(options.project); } From f1ebc0d008b10e92d7b9b47e53689ddfae898dbf Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:10:25 +0000 Subject: [PATCH 2/2] fixup! test: migrate `app-shell-with-service-worker` E2E test to Puppeteer --- .../application/execute-post-bundle.ts | 13 +++--- .../src/builders/dev-server/vite/index.ts | 10 +++-- .../src/builders/dev-server/vite/utils.ts | 43 ++++++++----------- .../vite/middlewares/assets-middleware.ts | 5 ++- tests/e2e/utils/puppeteer.ts | 3 ++ 5 files changed, 39 insertions(+), 35 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 5171ca254d5d..ba4b500a87f8 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -138,13 +138,14 @@ export async function executePostBundleSteps( // Pre-render (SSG) and App-shell // If localization is enabled, prerendering is handled in the inlining process. + let indexHtmlOutputPath = indexHtmlOptions?.output; if ( !partialSSRBuild && (prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) && !allErrors.length ) { assert( - indexHtmlOptions, + indexHtmlOptions && indexHtmlOutputPath, 'The "index" option is required when using the "ssg" or "appShell" options.', ); @@ -163,17 +164,19 @@ export async function executePostBundleSteps( allErrors.push(...errors); allWarnings.push(...warnings); - const indexHasBeenPrerendered = output[indexHtmlOptions.output]; + const indexHasBeenPrerendered = output[indexHtmlOutputPath]; for (const [path, { content, appShellRoute }] of Object.entries(output)) { // Update the index contents with the app shell under these conditions: // - Replace 'index.html' with the app shell only if it hasn't been prerendered yet. // - Always replace 'index.csr.html' with the app shell. let filePath = path; if (appShellRoute && !indexHasBeenPrerendered) { - if (outputMode !== OutputMode.Server && indexHtmlOptions.output === INDEX_HTML_CSR) { + if (outputMode !== OutputMode.Server && indexHtmlOutputPath === INDEX_HTML_CSR) { filePath = 'index.html'; + // Needed to update the ngsw.json "index" value. + indexHtmlOutputPath = filePath; } else { - filePath = indexHtmlOptions.output; + filePath = indexHtmlOutputPath; } } @@ -232,7 +235,7 @@ export async function executePostBundleSteps( workspaceRoot, serviceWorker, baseHref, - options.indexHtmlOptions?.output, + indexHtmlOutputPath, // Ensure additional files recently added are used [...outputFiles, ...additionalOutputFiles], assetFiles, diff --git a/packages/angular/build/src/builders/dev-server/vite/index.ts b/packages/angular/build/src/builders/dev-server/vite/index.ts index 8129daac1ba1..bb1e99b8c23e 100644 --- a/packages/angular/build/src/builders/dev-server/vite/index.ts +++ b/packages/angular/build/src/builders/dev-server/vite/index.ts @@ -9,19 +9,21 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; import assert from 'node:assert'; -import { builtinModules, isBuiltin } from 'node:module'; import { join } from 'node:path'; import type { Connect, ViteDevServer } from 'vite'; import type { ComponentStyleRecord } from '../../../tools/vite/middlewares'; import { ServerSsrMode } from '../../../tools/vite/plugins'; import { EsbuildLoaderOption, updateExternalMetadata } from '../../../tools/vite/utils'; import { normalizeSourceMaps } from '../../../utils'; -import { useComponentStyleHmr, useComponentTemplateHmr } from '../../../utils/environment-options'; +import { + useComponentStyleHmr, + useComponentTemplateHmr, + usePartialSsrBuild, +} from '../../../utils/environment-options'; import { Result, ResultKind } from '../../application/results'; import { OutputHashing } from '../../application/schema'; import { type ApplicationBuilderInternalOptions, - type ExternalResultMetadata, JavaScriptTransformer, getSupportedBrowsers, isZonelessApp, @@ -119,7 +121,7 @@ export async function* serveWithVite( // Disable generating a full manifest with routes. // This is done during runtime when using the dev-server. - browserOptions.partialSSRBuild = true; + browserOptions.partialSSRBuild = usePartialSsrBuild; // The development server currently only supports a single locale when localizing. // This matches the behavior of the Webpack-based development server but could be expanded in the future. diff --git a/packages/angular/build/src/builders/dev-server/vite/utils.ts b/packages/angular/build/src/builders/dev-server/vite/utils.ts index e1e6b4f96847..99991570338b 100644 --- a/packages/angular/build/src/builders/dev-server/vite/utils.ts +++ b/packages/angular/build/src/builders/dev-server/vite/utils.ts @@ -48,26 +48,21 @@ export function updateResultRecord( return; } - let filePath; - if (outputPath === htmlIndexPath) { - // Convert custom index output path to standard index path for dev-server usage. - // This mimics the Webpack dev-server behavior. - filePath = '/index.html'; - } else { - filePath = '/' + normalizePath(outputPath); - } - - const servable = - file.type === BuildOutputFileType.Browser || file.type === BuildOutputFileType.Media; + const filePath = '/' + normalizePath(outputPath); + const generatedFile: OutputFileRecord = { + contents: file.contents, + size: file.contents.byteLength, + hash: file.hash, + // Consider the files updated except on the initial build result + updated: !initial, + type: file.type, + servable: file.type === BuildOutputFileType.Browser || file.type === BuildOutputFileType.Media, + }; // Skip analysis of sourcemaps if (filePath.endsWith('.map')) { generatedFiles.set(filePath, { - contents: file.contents, - servable, - size: file.contents.byteLength, - hash: file.hash, - type: file.type, + ...generatedFile, updated: false, }); @@ -75,15 +70,13 @@ export function updateResultRecord( } // New or updated file - generatedFiles.set(filePath, { - contents: file.contents, - size: file.contents.byteLength, - hash: file.hash, - // Consider the files updated except on the initial build result - updated: !initial, - type: file.type, - servable, - }); + generatedFiles.set(filePath, generatedFile); + + if (outputPath === htmlIndexPath) { + // Convert custom index output path to standard index path for dev-server usage. + // This mimics the Webpack dev-server behavior. + generatedFiles.set('/index.html', generatedFile); + } // Record any external component styles if (filePath.endsWith('.css') && /^\/[a-f0-9]{64}\.css$/.test(filePath)) { diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts index f0a137f578f8..3c7abc4e7f7b 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -12,6 +12,7 @@ import { readFileSync } from 'node:fs'; import type { ServerResponse } from 'node:http'; import { extname } from 'node:path'; import type { Connect, ViteDevServer } from 'vite'; +import { INDEX_HTML_CSR } from '../../../builders/application/options'; import { ResultFile } from '../../../builders/application/results'; import { AngularMemoryOutputFiles, AngularOutputAssets, pathnameWithoutBasePath } from '../utils'; @@ -96,7 +97,9 @@ export function createAngularAssetsMiddleware( // Resource files are handled directly. // Global stylesheets (CSS files) are currently considered resources to workaround // dev server sourcemap issues with stylesheets. - if (extension !== '.js' && extension !== '.html') { + // Vite will added the client code even when no-live-reload and no-hmr are passed thus we need to + // exclude .js and .html files when hmr is disabled. + if ((extension !== '.js' && extension !== '.html') || !server.config.server?.hmr) { const outputFile = outputFiles.get(pathname); if (outputFile?.servable) { let data: Uint8Array | string = outputFile.contents; diff --git a/tests/e2e/utils/puppeteer.ts b/tests/e2e/utils/puppeteer.ts index 647773964ec5..23fefa3b48b7 100644 --- a/tests/e2e/utils/puppeteer.ts +++ b/tests/e2e/utils/puppeteer.ts @@ -29,11 +29,14 @@ export async function executeBrowserTest(options: BrowserTestOptions = {}) { const { stdout } = await execAndWaitForOutputToMatch('ng', serveArgs, match, { ...process.env, 'NO_COLOR': '1', + 'NG_BUILD_PARTIAL_SSR': '0', }); + url = stripVTControlCharacters(stdout).match(match)?.[1]; if (!url) { throw new Error('Could not find serving URL'); } + hasStartedServer = true; }