diff --git a/package.json b/package.json index 9d2435d3..65a0aa80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/build", - "version": "20.0.0-next.8+sha-bbb08d6", + "version": "20.0.0-rc.3+sha-b776d32", "description": "Official build system for Angular", "keywords": [ "Angular CLI", @@ -24,33 +24,33 @@ "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "github:angular/angular-devkit-architect-builds#{BUILD_SCM_ABBREV_HASH}", - "@babel/core": "7.26.10", - "@babel/helper-annotate-as-pure": "7.25.9", + "@babel/core": "7.27.1", + "@babel/helper-annotate-as-pure": "7.27.1", "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "5.1.9", + "@inquirer/confirm": "5.1.10", "@vitejs/plugin-basic-ssl": "2.0.0", "beasties": "0.3.3", "browserslist": "^4.23.0", - "esbuild": "0.25.3", + "esbuild": "0.25.4", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", - "listr2": "8.3.2", + "listr2": "8.3.3", "magic-string": "0.30.17", "mrmime": "2.0.1", "parse5-html-rewriting-stream": "7.1.0", "picomatch": "4.0.2", - "piscina": "4.9.2", - "rollup": "4.40.1", - "sass": "1.87.0", - "semver": "7.7.1", + "piscina": "5.0.0", + "rollup": "4.40.2", + "sass": "1.88.0", + "semver": "7.7.2", "source-map-support": "0.5.21", "tinyglobby": "0.2.13", - "vite": "6.3.4", + "vite": "6.3.5", "watchpack": "2.4.2" }, "optionalDependencies": { - "lmdb": "3.2.6" + "lmdb": "3.3.0" }, "peerDependencies": { "@angular/core": "^20.0.0 || ^20.0.0-next.0", @@ -110,9 +110,9 @@ "type": "git", "url": "https://github.com/angular/angular-cli.git" }, - "packageManager": "pnpm@9.15.6", + "packageManager": "pnpm@9.15.9", "engines": { - "node": "^20.11.1 || >=22.11.0", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, diff --git a/src/builders/application/execute-build.js b/src/builders/application/execute-build.js index 7c6901ff..10978133 100644 --- a/src/builders/application/execute-build.js +++ b/src/builders/application/execute-build.js @@ -229,8 +229,9 @@ async function executeBuild(options, context, rebuildState) { const result = await (0, execute_post_bundle_1.executePostBundleSteps)(metafile, options, executionResult.outputFiles, executionResult.assetFiles, initialFiles, // Set lang attribute to the defined source locale if present i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined); - executionResult.addErrors(result.errors); - executionResult.addWarnings(result.warnings); + // Deduplicate and add errors and warnings + executionResult.addErrors([...new Set(result.errors)]); + executionResult.addWarnings([...new Set(result.warnings)]); executionResult.addPrerenderedRoutes(result.prerenderedRoutes); executionResult.outputFiles.push(...result.additionalOutputFiles); executionResult.assetFiles.push(...result.additionalAssets); diff --git a/src/builders/application/i18n.js b/src/builders/application/i18n.js index b299c6c2..97eeca66 100644 --- a/src/builders/application/i18n.js +++ b/src/builders/application/i18n.js @@ -92,6 +92,21 @@ async function inlineI18n(metafile, options, executionResult, initialFiles) { if (!i18nOptions.flatOutput) { executionResult.assetFiles = updatedAssetFiles; } + // Inline any template updates if present + if (executionResult.templateUpdates?.size) { + // The development server only allows a single locale but issue a warning if used programmatically (experimental) + // with multiple locales and template HMR. + if (i18nOptions.inlineLocales.size > 1) { + inlineResult.warnings.push(`Component HMR updates can only be inlined with a single locale. The first locale will be used.`); + } + const firstLocale = [...i18nOptions.inlineLocales][0]; + for (const [id, content] of executionResult.templateUpdates) { + const templateUpdateResult = await inliner.inlineTemplateUpdate(firstLocale, i18nOptions.locales[firstLocale].translation, content, id); + executionResult.templateUpdates.set(id, templateUpdateResult.code); + inlineResult.errors.push(...templateUpdateResult.errors); + inlineResult.warnings.push(...templateUpdateResult.warnings); + } + } return inlineResult; } /** diff --git a/src/builders/application/options.js b/src/builders/application/options.js index e1989236..9e774d28 100644 --- a/src/builders/application/options.js +++ b/src/builders/application/options.js @@ -69,7 +69,7 @@ async function normalizeOptions(context, projectName, options, extensions) { if (options.forceI18nFlatOutput) { i18nOptions.flatOutput = true; } - const entryPoints = normalizeEntryPoints(workspaceRoot, options.browser, options.entryPoints); + const entryPoints = normalizeEntryPoints(workspaceRoot, projectSourceRoot, options.browser, options.entryPoints); const tsconfig = node_path_1.default.join(workspaceRoot, options.tsConfig); const optimizationOptions = (0, utils_1.normalizeOptimization)(options.optimization); const sourcemapOptions = (0, utils_1.normalizeSourceMaps)(options.sourceMap ?? false); @@ -355,23 +355,22 @@ async function getTailwindConfig(searchDirectories, workspaceRoot, context) { * @param entryPoints Set of entry points to use if provided. * @returns An object mapping entry point names to their file paths. */ -function normalizeEntryPoints(workspaceRoot, browser, entryPoints = new Set()) { +function normalizeEntryPoints(workspaceRoot, projectSourceRoot, browser, entryPoints) { if (browser === '') { throw new Error('`browser` option cannot be an empty string.'); } // `browser` and `entryPoints` are mutually exclusive. - if (browser && entryPoints.size > 0) { + if (browser && entryPoints) { throw new Error('Only one of `browser` or `entryPoints` may be provided.'); } - if (!browser && entryPoints.size === 0) { - // Schema should normally reject this case, but programmatic usages of the builder might make this mistake. - throw new Error('Either `browser` or at least one `entryPoints` value must be provided.'); - } - // Schema types force `browser` to always be provided, but it may be omitted when the builder is invoked programmatically. if (browser) { // Use `browser` alone. return { 'main': node_path_1.default.join(workspaceRoot, browser) }; } + else if (!entryPoints) { + // Default browser entry if no explicit entry points + return { 'main': node_path_1.default.join(projectSourceRoot, 'main.ts') }; + } else if (entryPoints instanceof Map) { return Object.fromEntries(Array.from(entryPoints.entries(), ([name, entryPoint]) => { // Get the full file path to a relative entry point input. Leave bare specifiers alone so they are resolved as modules. diff --git a/src/builders/application/schema.d.ts b/src/builders/application/schema.d.ts index c21de968..12550e55 100644 --- a/src/builders/application/schema.d.ts +++ b/src/builders/application/schema.d.ts @@ -27,7 +27,7 @@ export type Schema = { * The full path for the browser entry point to the application, relative to the current * workspace. */ - browser: string; + browser?: string; /** * Budget thresholds to ensure parts of your application stay within boundaries which you * set. diff --git a/src/builders/application/schema.json b/src/builders/application/schema.json index 934bfe93..ef4cbb75 100644 --- a/src/builders/application/schema.json +++ b/src/builders/application/schema.json @@ -616,7 +616,7 @@ } }, "additionalProperties": false, - "required": ["browser", "tsConfig"], + "required": ["tsConfig"], "definitions": { "assetPattern": { "oneOf": [ diff --git a/src/builders/dev-server/vite-server.js b/src/builders/dev-server/vite-server.js index 472a92b5..2c9090c2 100644 --- a/src/builders/dev-server/vite-server.js +++ b/src/builders/dev-server/vite-server.js @@ -114,13 +114,12 @@ async function* serveWithVite(serverOptions, builderName, builderAction, context // https://nodejs.org/api/process.html#processsetsourcemapsenabledval process.setSourceMapsEnabled(true); } + const componentsHmrCanBeUsed = browserOptions.aot && serverOptions.liveReload && serverOptions.hmr; // Enable to support link-based component style hot reloading (`NG_HMR_CSTYLES=1` can be used to enable) - browserOptions.externalRuntimeStyles = - serverOptions.liveReload && serverOptions.hmr && environment_options_1.useComponentStyleHmr; + browserOptions.externalRuntimeStyles = componentsHmrCanBeUsed && environment_options_1.useComponentStyleHmr; // Enable to support component template hot replacement (`NG_HMR_TEMPLATE=0` can be used to disable selectively) // This will also replace file-based/inline styles as code if external runtime styles are not enabled. - browserOptions.templateUpdates = - serverOptions.liveReload && serverOptions.hmr && environment_options_1.useComponentTemplateHmr; + browserOptions.templateUpdates = componentsHmrCanBeUsed && environment_options_1.useComponentTemplateHmr; browserOptions.incrementalResults = true; // Setup the prebundling transformer that will be shared across Vite prebundling requests const prebundleTransformer = new internal_1.JavaScriptTransformer( diff --git a/src/builders/extract-i18n/builder.js b/src/builders/extract-i18n/builder.js index 19b0f374..bcf5ec01 100644 --- a/src/builders/extract-i18n/builder.js +++ b/src/builders/extract-i18n/builder.js @@ -100,13 +100,20 @@ async function execute(options, context, extensions) { return node_path_1.default.relative(from, to); }, }; + const duplicateTranslationBehavior = normalizedOptions.i18nOptions.duplicateTranslationBehavior; const diagnostics = checkDuplicateMessages( // eslint-disable-next-line @typescript-eslint/no-explicit-any - checkFileSystem, extractionResult.messages, normalizedOptions.i18nOptions.i18nDuplicateTranslation || 'warning', + checkFileSystem, extractionResult.messages, duplicateTranslationBehavior, // eslint-disable-next-line @typescript-eslint/no-explicit-any extractionResult.basePath); - if (diagnostics.messages.length > 0) { - context.logger.warn(diagnostics.formatDiagnostics('')); + if (diagnostics.messages.length > 0 && duplicateTranslationBehavior !== 'ignore') { + if (duplicateTranslationBehavior === 'error') { + context.logger.error(`Extraction Failed: ${diagnostics.formatDiagnostics('')}`); + return { success: false }; + } + else { + context.logger.warn(diagnostics.formatDiagnostics('')); + } } // Serialize all extracted messages const serializer = await createSerializer(localizeToolsModule, normalizedOptions.format, normalizedOptions.i18nOptions.sourceLocale, extractionResult.basePath, extractionResult.useLegacyIds, diagnostics); diff --git a/src/builders/extract-i18n/options.d.ts b/src/builders/extract-i18n/options.d.ts index b23a5b83..c2c4ace5 100644 --- a/src/builders/extract-i18n/options.d.ts +++ b/src/builders/extract-i18n/options.d.ts @@ -5,7 +5,9 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ +import { type DiagnosticHandlingStrategy } from '@angular/localize/tools'; import { BuilderContext } from '@angular-devkit/architect'; +import { type I18nOptions } from '../../utils/i18n-options'; import { Schema as ExtractI18nOptions, Format } from './schema'; export type NormalizedExtractI18nOptions = Awaited>; /** @@ -22,7 +24,9 @@ export declare function normalizeOptions(context: BuilderContext, projectName: s workspaceRoot: string; projectRoot: string; buildTarget: import("@angular-devkit/architect").Target; - i18nOptions: import("../../utils/i18n-options").I18nOptions; + i18nOptions: I18nOptions & { + duplicateTranslationBehavior: DiagnosticHandlingStrategy; + }; format: Format.Arb | Format.Json | Format.LegacyMigrate | Format.Xliff | Format.Xliff2 | Format.Xmb; outFile: string; progress: boolean; diff --git a/src/builders/extract-i18n/options.js b/src/builders/extract-i18n/options.js index b3b50d51..d7b5fe54 100644 --- a/src/builders/extract-i18n/options.js +++ b/src/builders/extract-i18n/options.js @@ -33,7 +33,10 @@ async function normalizeOptions(context, projectName, options) { // Target specifier defaults to the current project's build target with no specified configuration const buildTargetSpecifier = options.buildTarget ?? ':'; const buildTarget = (0, architect_1.targetFromTargetString)(buildTargetSpecifier, projectName, 'build'); - const i18nOptions = (0, i18n_options_1.createI18nOptions)(projectMetadata, /** inline */ false, context.logger); + const i18nOptions = { + ...(0, i18n_options_1.createI18nOptions)(projectMetadata, /** inline */ false, context.logger), + duplicateTranslationBehavior: options.i18nDuplicateTranslation || 'warning', + }; // Normalize xliff format extensions let format = options.format; switch (format) { diff --git a/src/builders/extract-i18n/schema.d.ts b/src/builders/extract-i18n/schema.d.ts index fa72d336..b5911201 100644 --- a/src/builders/extract-i18n/schema.d.ts +++ b/src/builders/extract-i18n/schema.d.ts @@ -12,6 +12,10 @@ export type Schema = { * Output format for the generated file. */ format?: Format; + /** + * How to handle duplicate translations. + */ + i18nDuplicateTranslation?: I18NDuplicateTranslation; /** * Name of the file to output. */ @@ -39,3 +43,11 @@ export declare enum Format { Xliff2 = "xliff2", Xmb = "xmb" } +/** + * How to handle duplicate translations. + */ +export declare enum I18NDuplicateTranslation { + Error = "error", + Ignore = "ignore", + Warning = "warning" +} diff --git a/src/builders/extract-i18n/schema.js b/src/builders/extract-i18n/schema.js index 6f767a2e..3904eb68 100644 --- a/src/builders/extract-i18n/schema.js +++ b/src/builders/extract-i18n/schema.js @@ -2,7 +2,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED. TO UPDATE THIS FILE YOU NEED TO CHANGE THE // CORRESPONDING JSON SCHEMA FILE, THEN RUN devkit-admin build (or bazel build ...). Object.defineProperty(exports, "__esModule", { value: true }); -exports.Format = void 0; +exports.I18NDuplicateTranslation = exports.Format = void 0; /** * Output format for the generated file. */ @@ -18,3 +18,12 @@ var Format; Format["Xliff2"] = "xliff2"; Format["Xmb"] = "xmb"; })(Format || (exports.Format = Format = {})); +/** + * How to handle duplicate translations. + */ +var I18NDuplicateTranslation; +(function (I18NDuplicateTranslation) { + I18NDuplicateTranslation["Error"] = "error"; + I18NDuplicateTranslation["Ignore"] = "ignore"; + I18NDuplicateTranslation["Warning"] = "warning"; +})(I18NDuplicateTranslation || (exports.I18NDuplicateTranslation = I18NDuplicateTranslation = {})); diff --git a/src/builders/extract-i18n/schema.json b/src/builders/extract-i18n/schema.json index 9ab939b0..08a118ad 100644 --- a/src/builders/extract-i18n/schema.json +++ b/src/builders/extract-i18n/schema.json @@ -27,6 +27,11 @@ "outFile": { "type": "string", "description": "Name of the file to output." + }, + "i18nDuplicateTranslation": { + "type": "string", + "description": "How to handle duplicate translations.", + "enum": ["error", "warning", "ignore"] } }, "additionalProperties": false diff --git a/src/builders/karma/application_builder.js b/src/builders/karma/application_builder.js index 699584cf..7ca73bc0 100644 --- a/src/builders/karma/application_builder.js +++ b/src/builders/karma/application_builder.js @@ -88,7 +88,7 @@ class AngularAssetsMiddleware { } switch (file.origin) { case 'disk': - this.serveFile(file.inputPath, undefined, res); + this.serveFile(file.inputPath, undefined, res, undefined, undefined, /* doNotCache */ true); break; case 'memory': // Include pathname to help with Content-Type headers. diff --git a/src/builders/unit-test/builder.js b/src/builders/unit-test/builder.js index 5562b401..1ceb2e88 100644 --- a/src/builders/unit-test/builder.js +++ b/src/builders/unit-test/builder.js @@ -16,6 +16,7 @@ const node_crypto_1 = require("node:crypto"); const node_module_1 = require("node:module"); const node_path_1 = __importDefault(require("node:path")); const virtual_module_plugin_1 = require("../../tools/esbuild/virtual-module-plugin"); +const error_1 = require("../../utils/error"); const load_esm_1 = require("../../utils/load-esm"); const application_1 = require("../application"); const results_1 = require("../application/results"); @@ -27,6 +28,7 @@ const options_1 = require("./options"); /** * @experimental Direct usage of this function is considered experimental. */ +// eslint-disable-next-line max-lines-per-function async function* execute(options, context, extensions = {}) { // Determine project name from builder context target const projectName = context.target?.project; @@ -55,7 +57,19 @@ async function* execute(options, context, extensions = {}) { } const entryPoints = (0, find_tests_1.getTestEntrypoints)(testFiles, { projectSourceRoot, workspaceRoot }); entryPoints.set('init-testbed', 'angular:test-bed-init'); - const { startVitest } = await (0, load_esm_1.loadEsmModule)('vitest/node'); + let vitestNodeModule; + try { + vitestNodeModule = await (0, load_esm_1.loadEsmModule)('vitest/node'); + } + catch (error) { + (0, error_1.assertIsError)(error); + if (error.code !== 'ERR_MODULE_NOT_FOUND') { + throw error; + } + context.logger.error('The `vitest` package was not found. Please install the package and rerun the test command.'); + return; + } + const { startVitest } = vitestNodeModule; // Setup test file build options based on application build target options const buildTargetOptions = (await context.validateOptions(await context.getTargetOptions(normalizedOptions.buildTarget), await context.getBuilderNameForTarget(normalizedOptions.buildTarget))); if (buildTargetOptions.polyfills?.includes('zone.js')) { @@ -65,6 +79,7 @@ async function* execute(options, context, extensions = {}) { const buildOptions = { ...buildTargetOptions, watch: normalizedOptions.watch, + incrementalResults: normalizedOptions.watch, outputPath, index: false, browser: undefined, @@ -89,9 +104,27 @@ async function* execute(options, context, extensions = {}) { loadContent: async () => { const contents = [ // Initialize the Angular testing environment - `import { getTestBed } from '@angular/core/testing';`, + `import { NgModule } from '@angular/core';`, + `import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing';`, `import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';`, - `getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {`, + '', + normalizedOptions.providersFile + ? `import providers from './${node_path_1.default + .relative(projectSourceRoot, normalizedOptions.providersFile) + .replace(/.[mc]?ts$/, '') + .replace(/\\/g, '/')}'` + : 'const providers = [];', + '', + // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/src/test_hooks.ts#L21-L29 + `beforeEach(getCleanupHook(false));`, + `afterEach(getCleanupHook(true));`, + '', + `@NgModule({`, + ` providers,`, + `})`, + `export class TestModule {}`, + '', + `getTestBed().initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting(), {`, ` errorOnUnknownElements: true,`, ` errorOnUnknownProperties: true,`, '});', @@ -106,59 +139,83 @@ async function* execute(options, context, extensions = {}) { extensions.codePlugins.unshift(virtualTestBedInit); let instance; // Setup vitest browser options if configured - let browser; - if (normalizedOptions.browsers) { - const provider = findBrowserProvider(projectSourceRoot); - if (!provider) { - context.logger.error('The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' + - ' Please install one of these packages and rerun the test command.'); - return { success: false }; - } - browser = { - enabled: true, - provider, - instances: normalizedOptions.browsers.map((browserName) => ({ - browser: browserName, - })), - }; - } - for await (const result of (0, application_1.buildApplicationInternal)(buildOptions, context, extensions)) { - if (result.kind === results_1.ResultKind.Failure) { - continue; + const { browser, errors } = setupBrowserConfiguration(normalizedOptions.browsers, normalizedOptions.debug, projectSourceRoot); + if (errors?.length) { + errors.forEach((error) => context.logger.error(error)); + return { success: false }; + } + // Add setup file entries for TestBed initialization and project polyfills + const setupFiles = ['init-testbed.js']; + if (buildTargetOptions?.polyfills?.length) { + setupFiles.push('polyfills.js'); + } + const debugOptions = normalizedOptions.debug + ? { + inspectBrk: true, + isolate: false, + fileParallelism: false, } - else if (result.kind !== results_1.ResultKind.Full) { - node_assert_1.default.fail('A full build result is required from the application builder.'); + : {}; + try { + for await (const result of (0, application_1.buildApplicationInternal)(buildOptions, context, extensions)) { + if (result.kind === results_1.ResultKind.Failure) { + continue; + } + else if (result.kind !== results_1.ResultKind.Full && result.kind !== results_1.ResultKind.Incremental) { + node_assert_1.default.fail('A full and/or incremental build result is required from the application builder.'); + } + (0, node_assert_1.default)(result.files, 'Builder did not provide result files.'); + await (0, application_builder_1.writeTestFiles)(result.files, outputPath); + instance ??= await startVitest('test', undefined /* cliFilters */, { + // Disable configuration file resolution/loading + config: false, + }, { + test: { + root: outputPath, + globals: true, + setupFiles, + // Use `jsdom` if no browsers are explicitly configured. + // `node` is effectively no "environment" and the default. + environment: browser ? 'node' : 'jsdom', + watch: normalizedOptions.watch, + browser, + reporters: normalizedOptions.reporters ?? ['default'], + coverage: { + enabled: normalizedOptions.codeCoverage, + excludeAfterRemap: true, + }, + ...debugOptions, + }, + plugins: [ + { + name: 'angular-coverage-exclude', + configureVitest(context) { + // Adjust coverage excludes to not include the otherwise automatically inserted included unit tests. + // Vite does this as a convenience but is problematic for the bundling strategy employed by the + // builder's test setup. To workaround this, the excludes are adjusted here to only automatically + // exclude the TypeScript source test files. + context.project.config.coverage.exclude = [ + ...(normalizedOptions.codeCoverageExclude ?? []), + '**/*.{test,spec}.?(c|m)ts', + ]; + }, + }, + ], + }); + // Check if all the tests pass to calculate the result + const testModules = instance.state.getTestModules(); + yield { success: testModules.every((testModule) => testModule.ok()) }; } - (0, node_assert_1.default)(result.files, 'Builder did not provide result files.'); - await (0, application_builder_1.writeTestFiles)(result.files, outputPath); - const setupFiles = ['init-testbed.js']; - if (buildTargetOptions?.polyfills?.length) { - setupFiles.push('polyfills.js'); + } + finally { + if (normalizedOptions.watch) { + // Vitest will automatically close if not using watch mode + await instance?.close(); } - instance ??= await startVitest('test', undefined /* cliFilters */, undefined /* options */, { - test: { - root: outputPath, - setupFiles, - // Use `jsdom` if no browsers are explicitly configured. - // `node` is effectively no "environment" and the default. - environment: browser ? 'node' : 'jsdom', - watch: normalizedOptions.watch, - browser, - coverage: { - enabled: normalizedOptions.codeCoverage, - exclude: normalizedOptions.codeCoverageExclude, - excludeAfterRemap: true, - }, - }, - }); - // Check if all the tests pass to calculate the result - const testModules = instance.state.getTestModules(); - yield { success: testModules.every((testModule) => testModule.ok()) }; } } -function findBrowserProvider(projectSourceRoot) { - const projectResolver = (0, node_module_1.createRequire)(projectSourceRoot + '/').resolve; - // These must be installed in the project to be used +function findBrowserProvider(projectResolver) { + // One of these must be installed in the project to use browser testing const vitestBuiltinProviders = ['playwright', 'webdriverio']; for (const providerName of vitestBuiltinProviders) { try { @@ -168,3 +225,41 @@ function findBrowserProvider(projectSourceRoot) { catch { } } } +function setupBrowserConfiguration(browsers, debug, projectSourceRoot) { + if (browsers === undefined) { + return {}; + } + const projectResolver = (0, node_module_1.createRequire)(projectSourceRoot + '/').resolve; + let errors; + try { + projectResolver('@vitest/browser'); + } + catch { + errors ??= []; + errors.push('The "browsers" option requires the "@vitest/browser" package to be installed within the project.' + + ' Please install this package and rerun the test command.'); + } + const provider = findBrowserProvider(projectResolver); + if (!provider) { + errors ??= []; + errors.push('The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' + + ' Please install one of these packages and rerun the test command.'); + } + // Vitest current requires the playwright browser provider to use the inspect-brk option used by "debug" + if (debug && provider !== 'playwright') { + errors ??= []; + errors.push('Debugging browser mode tests currently requires the use of "playwright".' + + ' Please install this package and rerun the test command.'); + } + if (errors) { + return { errors }; + } + const browser = { + enabled: true, + provider, + instances: browsers.map((browserName) => ({ + browser: browserName, + })), + }; + return { browser }; +} diff --git a/src/builders/unit-test/karma-bridge.js b/src/builders/unit-test/karma-bridge.js index f048d510..36a4e996 100644 --- a/src/builders/unit-test/karma-bridge.js +++ b/src/builders/unit-test/karma-bridge.js @@ -42,6 +42,9 @@ var __importStar = (this && this.__importStar) || (function () { Object.defineProperty(exports, "__esModule", { value: true }); exports.useKarmaBuilder = useKarmaBuilder; async function useKarmaBuilder(context, unitTestOptions) { + if (unitTestOptions.debug) { + context.logger.warn('The "karma" test runner does not support the "debug" option. The option will be ignored.'); + } const buildTargetOptions = (await context.validateOptions(await context.getTargetOptions(unitTestOptions.buildTarget), await context.getBuilderNameForTarget(unitTestOptions.buildTarget))); const options = { tsConfig: unitTestOptions.tsConfig, diff --git a/src/builders/unit-test/options.d.ts b/src/builders/unit-test/options.d.ts index 14b0b853..69715766 100644 --- a/src/builders/unit-test/options.d.ts +++ b/src/builders/unit-test/options.d.ts @@ -23,4 +23,6 @@ export declare function normalizeOptions(context: BuilderContext, projectName: s reporters: string[] | undefined; browsers: string[] | undefined; watch: boolean; + debug: boolean; + providersFile: string | undefined; }>; diff --git a/src/builders/unit-test/options.js b/src/builders/unit-test/options.js index b81176b3..75437dbf 100644 --- a/src/builders/unit-test/options.js +++ b/src/builders/unit-test/options.js @@ -15,6 +15,7 @@ const architect_1 = require("@angular-devkit/architect"); const node_path_1 = __importDefault(require("node:path")); const normalize_cache_1 = require("../../utils/normalize-cache"); const project_metadata_1 = require("../../utils/project-metadata"); +const tty_1 = require("../../utils/tty"); async function normalizeOptions(context, projectName, options) { // Setup base paths based on workspace root and project information const workspaceRoot = context.workspaceRoot; @@ -43,20 +44,8 @@ async function normalizeOptions(context, projectName, options) { tsConfig, reporters, browsers, - // TODO: Implement watch support - watch: false, + watch: options.watch ?? (0, tty_1.isTTY)(), + debug: options.debug ?? false, + providersFile: options.providersFile && node_path_1.default.join(workspaceRoot, options.providersFile), }; } -/** - * Normalize a directory path string. - * Currently only removes a trailing slash if present. - * @param path A path string. - * @returns A normalized path string. - */ -function normalizeDirectoryPath(path) { - const last = path[path.length - 1]; - if (last === '/' || last === '\\') { - return path.slice(0, -1); - } - return path; -} diff --git a/src/builders/unit-test/schema.d.ts b/src/builders/unit-test/schema.d.ts index 44100590..cacb6c80 100644 --- a/src/builders/unit-test/schema.d.ts +++ b/src/builders/unit-test/schema.d.ts @@ -21,6 +21,10 @@ export type Schema = { * Globs to exclude from code coverage. */ codeCoverageExclude?: string[]; + /** + * Initialize the test runner to support using the Node Inspector for test debugging. + */ + debug?: boolean; /** * Globs of files to exclude, relative to the project root. */ @@ -34,6 +38,11 @@ export type Schema = { * instead. */ include?: string[]; + /** + * TypeScript file that exports an array of Angular providers to use during test execution. + * The array must be a default export. + */ + providersFile?: string; /** * Test runner reporters to use. Directly passed to the test runner. */ diff --git a/src/builders/unit-test/schema.json b/src/builders/unit-test/schema.json index 223aa149..764a751a 100644 --- a/src/builders/unit-test/schema.json +++ b/src/builders/unit-test/schema.json @@ -46,6 +46,11 @@ "type": "boolean", "description": "Run build when files change." }, + "debug": { + "type": "boolean", + "description": "Initialize the test runner to support using the Node Inspector for test debugging.", + "default": false + }, "codeCoverage": { "type": "boolean", "description": "Output a code coverage report.", @@ -65,6 +70,11 @@ "items": { "type": "string" } + }, + "providersFile": { + "type": "string", + "description": "TypeScript file that exports an array of Angular providers to use during test execution. The array must be a default export.", + "minLength": 1 } }, "additionalProperties": false, diff --git a/src/tools/angular/compilation/aot-compilation.js b/src/tools/angular/compilation/aot-compilation.js index 6cd382f4..5d491a91 100644 --- a/src/tools/angular/compilation/aot-compilation.js +++ b/src/tools/angular/compilation/aot-compilation.js @@ -121,10 +121,7 @@ class AotCompilation extends angular_compilation_1.AngularCompilation { const updateId = encodeURIComponent(`${host.getCanonicalFileName(relativePath)}@${node.name?.text}`); const updateText = angularCompiler.emitHmrUpdateModule(node); // If compiler cannot generate an update for the component, prevent template updates. - // Also prevent template updates if $localize is directly present which also currently - // prevents a template update at runtime. - // TODO: Support localized template update modules and remove this check. - if (updateText === null || updateText.includes('$localize')) { + if (updateText === null) { // Build is needed if a template cannot be updated templateUpdates = undefined; break; diff --git a/src/tools/esbuild/bundler-context.js b/src/tools/esbuild/bundler-context.js index 08654c33..b68e60de 100644 --- a/src/tools/esbuild/bundler-context.js +++ b/src/tools/esbuild/bundler-context.js @@ -13,6 +13,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.BundlerContext = exports.BuildOutputFileType = void 0; const esbuild_1 = require("esbuild"); const node_assert_1 = __importDefault(require("node:assert")); +const node_module_1 = require("node:module"); const node_path_1 = require("node:path"); const load_result_cache_1 = require("./load-result-cache"); const utils_1 = require("./utils"); @@ -198,10 +199,11 @@ class BundlerContext { if (this.incremental) { // Add input files except virtual angular files which do not exist on disk for (const input of Object.keys(result.metafile.inputs)) { - if (!isInternalAngularFile(input)) { - // input file paths are always relative to the workspace root - this.watchFiles.add((0, node_path_1.join)(this.workspaceRoot, input)); + if (isInternalAngularFile(input) || isInternalBundlerFile(input)) { + continue; } + // Input file paths are always relative to the workspace root + this.watchFiles.add((0, node_path_1.join)(this.workspaceRoot, input)); } } // Return if the build encountered any errors @@ -380,3 +382,16 @@ exports.BundlerContext = BundlerContext; function isInternalAngularFile(file) { return file.startsWith('angular:'); } +function isInternalBundlerFile(file) { + // Bundler virtual files such as "" or "" + if (file[0] === '<' && file.at(-1) === '>') { + return true; + } + const DISABLED_BUILTIN = '(disabled):'; + // Disabled node builtins such as "/some/path/(disabled):fs" + const disabledIndex = file.indexOf(DISABLED_BUILTIN); + if (disabledIndex >= 0) { + return node_module_1.builtinModules.includes(file.slice(disabledIndex + DISABLED_BUILTIN.length)); + } + return false; +} diff --git a/src/tools/esbuild/i18n-inliner-worker.d.ts b/src/tools/esbuild/i18n-inliner-worker.d.ts index 1d69bb53..f660a061 100644 --- a/src/tools/esbuild/i18n-inliner-worker.d.ts +++ b/src/tools/esbuild/i18n-inliner-worker.d.ts @@ -8,7 +8,7 @@ /** * The options passed to the inliner for each file request */ -interface InlineRequest { +interface InlineFileRequest { /** * The filename that should be processed. The data for the file is provided to the Worker * during Worker initialization. @@ -23,14 +23,35 @@ interface InlineRequest { */ translation?: Record; } +/** + * The options passed to the inliner for each code request + */ +interface InlineCodeRequest { + /** + * The code that should be processed. + */ + code: string; + /** + * The filename to use in error and warning messages for the provided code. + */ + filename: string; + /** + * The locale specifier that should be used during the inlining process of the file. + */ + locale: string; + /** + * The translation messages for the locale that should be used during the inlining process of the file. + */ + translation?: Record; +} /** * Inlines the provided locale and translation into a JavaScript file that contains `$localize` usage. * This function is the main entry for the Worker's action that is called by the worker pool. * * @param request An InlineRequest object representing the options for inlining - * @returns An array containing the inlined file and optional map content. + * @returns An object containing the inlined file and optional map content. */ -export default function inlineLocale(request: InlineRequest): Promise<{ +export default function inlineFile(request: InlineFileRequest): Promise<{ file: string; code: string; map: string | undefined; @@ -39,4 +60,18 @@ export default function inlineLocale(request: InlineRequest): Promise<{ message: string; }[]; }>; +/** + * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. + * This function is a secondary entry primarily for use with component HMR update modules. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An object containing the inlined code. + */ +export declare function inlineCode(request: InlineCodeRequest): Promise<{ + output: string; + messages: { + type: "warning" | "error"; + message: string; + }[]; +}>; export {}; diff --git a/src/tools/esbuild/i18n-inliner-worker.js b/src/tools/esbuild/i18n-inliner-worker.js index d6079517..311b2c0a 100644 --- a/src/tools/esbuild/i18n-inliner-worker.js +++ b/src/tools/esbuild/i18n-inliner-worker.js @@ -10,7 +10,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.default = inlineLocale; +exports.default = inlineFile; +exports.inlineCode = inlineCode; const remapping_1 = __importDefault(require("@ampproject/remapping")); const core_1 = require("@babel/core"); const node_assert_1 = __importDefault(require("node:assert")); @@ -25,9 +26,9 @@ const { files, missingTranslation, shouldOptimize } = (node_worker_threads_1.wor * This function is the main entry for the Worker's action that is called by the worker pool. * * @param request An InlineRequest object representing the options for inlining - * @returns An array containing the inlined file and optional map content. + * @returns An object containing the inlined file and optional map content. */ -async function inlineLocale(request) { +async function inlineFile(request) { const data = files.get(request.filename); (0, node_assert_1.default)(data !== undefined, `Invalid inline request for file '${request.filename}'.`); const code = await data.text(); @@ -40,6 +41,20 @@ async function inlineLocale(request) { messages: result.diagnostics.messages, }; } +/** + * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. + * This function is a secondary entry primarily for use with component HMR update modules. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An object containing the inlined code. + */ +async function inlineCode(request) { + const result = await transformWithBabel(request.code, undefined, request); + return { + output: result.code, + messages: result.diagnostics.messages, + }; +} /** * Cached instance of the `@angular/localize/tools` module. * This is used to remove the need to repeatedly import the module per file translation. diff --git a/src/tools/esbuild/i18n-inliner.d.ts b/src/tools/esbuild/i18n-inliner.d.ts index 47c2d11d..9b44032d 100644 --- a/src/tools/esbuild/i18n-inliner.d.ts +++ b/src/tools/esbuild/i18n-inliner.d.ts @@ -38,6 +38,11 @@ export declare class I18nInliner { errors: string[]; warnings: string[]; }>; + inlineTemplateUpdate(locale: string, translation: Record | undefined, templateCode: string, templateId: string): Promise<{ + code: string; + errors: string[]; + warnings: string[]; + }>; /** * Stops all active transformation tasks and shuts down all workers. * @returns A void promise that resolves when closing is complete. diff --git a/src/tools/esbuild/i18n-inliner.js b/src/tools/esbuild/i18n-inliner.js index b4e7b167..7c25cc78 100644 --- a/src/tools/esbuild/i18n-inliner.js +++ b/src/tools/esbuild/i18n-inliner.js @@ -201,6 +201,32 @@ class I18nInliner { warnings, }; } + async inlineTemplateUpdate(locale, translation, templateCode, templateId) { + const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD); + if (!hasLocalize) { + return { + code: templateCode, + errors: [], + warnings: [], + }; + } + const { output, messages } = await this.#workerPool.run({ code: templateCode, filename: templateId, locale, translation }, { name: 'inlineCode' }); + const errors = []; + const warnings = []; + for (const message of messages) { + if (message.type === 'error') { + errors.push(message.message); + } + else { + warnings.push(message.message); + } + } + return { + code: output, + errors, + warnings, + }; + } /** * Stops all active transformation tasks and shuts down all workers. * @returns A void promise that resolves when closing is complete. diff --git a/src/tools/esbuild/utils.js b/src/tools/esbuild/utils.js index d54ddf26..d2d4761b 100644 --- a/src/tools/esbuild/utils.js +++ b/src/tools/esbuild/utils.js @@ -315,7 +315,7 @@ function transformSupportedBrowsersToTargets(supportedBrowsers) { } return transformed; } -const SUPPORTED_NODE_VERSIONS = '^20.11.1 || >=22.11.0'; +const SUPPORTED_NODE_VERSIONS = '^20.19.0 || ^22.12.0 || >=24.0.0'; /** * Transform supported Node.js versions to esbuild target. * @see https://esbuild.github.io/api/#target diff --git a/src/utils/i18n-options.d.ts b/src/utils/i18n-options.d.ts index 364132b0..481a3221 100644 --- a/src/utils/i18n-options.d.ts +++ b/src/utils/i18n-options.d.ts @@ -5,7 +5,6 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import { DiagnosticHandlingStrategy } from '@angular/localize/tools'; import type { TranslationLoader } from './load-translations'; export interface LocaleDescription { files: { @@ -25,7 +24,6 @@ export interface I18nOptions { flatOutput?: boolean; readonly shouldInline: boolean; hasDefinedSourceLocale?: boolean; - i18nDuplicateTranslation?: DiagnosticHandlingStrategy; } export declare function createI18nOptions(projectMetadata: { i18n?: unknown; diff --git a/src/utils/load-proxy-config.js b/src/utils/load-proxy-config.js index f8a7088a..2f89d612 100644 --- a/src/utils/load-proxy-config.js +++ b/src/utils/load-proxy-config.js @@ -78,8 +78,7 @@ async function loadProxyConfiguration(root, proxyConfig) { // Load the ESM configuration file using the TypeScript dynamic import workaround. // Once TypeScript provides support for keeping the dynamic import this workaround can be // changed to a direct dynamic import. - proxyConfiguration = (await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath))) - .default; + proxyConfiguration = await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath)); break; case '.cjs': proxyConfiguration = require(proxyPath); @@ -93,17 +92,19 @@ async function loadProxyConfiguration(root, proxyConfig) { } catch (e) { (0, error_1.assertIsError)(e); - if (e.code === 'ERR_REQUIRE_ESM') { + if (e.code === 'ERR_REQUIRE_ESM' || e.code === 'ERR_REQUIRE_ASYNC_MODULE') { // Load the ESM configuration file using the TypeScript dynamic import workaround. // Once TypeScript provides support for keeping the dynamic import this workaround can be // changed to a direct dynamic import. - proxyConfiguration = (await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath))) - .default; + proxyConfiguration = await (0, load_esm_1.loadEsmModule)((0, node_url_1.pathToFileURL)(proxyPath)); break; } throw e; } } + if ('default' in proxyConfiguration) { + proxyConfiguration = proxyConfiguration.default; + } return normalizeProxyConfiguration(proxyConfiguration); } /** diff --git a/src/utils/normalize-cache.js b/src/utils/normalize-cache.js index 3e9d7776..53c08ce3 100644 --- a/src/utils/normalize-cache.js +++ b/src/utils/normalize-cache.js @@ -10,7 +10,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeCacheOptions = normalizeCacheOptions; const node_path_1 = require("node:path"); /** Version placeholder is replaced during the build process with actual package version */ -const VERSION = '20.0.0-next.8+sha-bbb08d6'; +const VERSION = '20.0.0-rc.3+sha-b776d32'; function hasCacheMetadata(value) { return (!!value && typeof value === 'object' && diff --git a/src/utils/worker-pool.js b/src/utils/worker-pool.js index 06fdcc74..378e0430 100644 --- a/src/utils/worker-pool.js +++ b/src/utils/worker-pool.js @@ -17,7 +17,7 @@ class WorkerPool extends piscina_1.Piscina { idleTimeout: 1000, // Web containers do not support transferable objects with receiveOnMessagePort which // is used when the Atomics based wait loop is enable. - useAtomics: !process.versions.webcontainer, + atomics: process.versions.webcontainer ? 'disabled' : 'sync', recordTiming: false, ...options, }; diff --git a/uniqueId b/uniqueId index a2ced7e0..082e4924 100644 --- a/uniqueId +++ b/uniqueId @@ -1 +1 @@ -Wed Apr 30 2025 18:45:54 GMT+0000 (Coordinated Universal Time) \ No newline at end of file +Wed May 21 2025 14:06:54 GMT+0000 (Coordinated Universal Time) \ No newline at end of file