diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 5b1e589fbb17..000000000000 --- a/.appveyor.yml +++ /dev/null @@ -1,21 +0,0 @@ -environment: - matrix: - - nodejs_version: "6" - -matrix: - fast_finish: true - -install: - - ps: Install-Product node $env:nodejs_version - - npm install -g npm@~5.3.0 - - npm install - -test_script: - - node --version - - npm --version - - node tests\run_e2e.js --appveyor - -build: off - -cache: - - node_modules diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 000000000000..a02316a9aa50 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,181 @@ +# Disable NG CLI TTY mode +build --action_env=NG_FORCE_TTY=false + +# Required by `rules_ts`. +common --@aspect_rules_ts//ts:skipLibCheck=always +common --@aspect_rules_ts//ts:default_to_tsc_transpiler + +# Make TypeScript compilation fast, by keeping a few copies of the compiler +# running as daemons, and cache SourceFile AST's to reduce parse time. +build --strategy=TypeScriptCompile=worker + +# Enable debugging tests with --config=debug +test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results + +# Enable debugging tests with --config=no-sharding +# The below is useful to while using `fit` and `fdescribe` to avoid sharing and re-runs of failed flaky tests. +test:no-sharding --flaky_test_attempts=1 --test_sharding_strategy=disabled + +# Frozen lockfile +common --lockfile_mode=error + +############################### +# Filesystem interactions # +############################### + +# Create symlinks in the project: +# - dist/bin for outputs +# - dist/testlogs, dist/genfiles +# - bazel-out +# NB: bazel-out should be excluded from the editor configuration. +# The checked-in /.vscode/settings.json does this for VSCode. +# Other editors may require manual config to ignore this directory. +# In the past, we saw a problem where VSCode traversed a massive tree, opening file handles and +# eventually a surprising failure with auto-discovery of the C++ toolchain in +# MacOS High Sierra. +# See https://github.com/bazelbuild/bazel/issues/4603 +build --symlink_prefix=dist/ + +# Turn off legacy external runfiles +build --nolegacy_external_runfiles + +# Turn on --incompatible_strict_action_env which was on by default +# in Bazel 0.21.0 but turned off again in 0.22.0. Follow +# https://github.com/bazelbuild/bazel/issues/7026 for more details. +# This flag is needed to so that the bazel cache is not invalidated +# when running bazel via `pnpm bazel`. +# See https://github.com/angular/angular/issues/27514. +build --incompatible_strict_action_env +run --incompatible_strict_action_env +test --incompatible_strict_action_env + +# Enable remote caching of build/action tree +build --experimental_remote_merkle_tree_cache + +# Ensure that tags applied in BUILDs propagate to actions +common --incompatible_allow_tags_propagation + +# Ensure sandboxing is enabled even for exclusive tests +test --incompatible_exclusive_test_sandboxed + +############################### +# Saucelabs support # +# Turn on these settings with # +# --config=saucelabs # +############################### + +# Expose SauceLabs environment to actions +# These environment variables are needed by +# web_test_karma to run on Saucelabs +test:saucelabs --action_env=SAUCE_USERNAME +test:saucelabs --action_env=SAUCE_ACCESS_KEY +test:saucelabs --action_env=SAUCE_READY_FILE +test:saucelabs --action_env=SAUCE_PID_FILE +test:saucelabs --action_env=SAUCE_TUNNEL_IDENTIFIER +test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED + +############################### +# Release support # +# Turn on these settings with # +# --config=release # +############################### + +# Releases should always be stamped with version control info +# This command assumes node on the path and is a workaround for +# https://github.com/bazelbuild/bazel/issues/4802 +build:release --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" +build:release --stamp + +build:snapshot --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --stamp +build:snapshot --//:enable_snapshot_repo_deps + +build:e2e --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" +build:e2e --stamp +test:e2e --test_timeout=3600 --experimental_ui_max_stdouterr_bytes=2097152 + +# Retry in the event of flakes +test:e2e --flaky_test_attempts=2 + +build:local --//:enable_package_json_tar_deps + +############################### +# Output # +############################### + +# A more useful default output mode for bazel query +# Prints eg. "ng_module rule //foo:bar" rather than just "//foo:bar" +query --output=label_kind + +# By default, failing tests don't print any output, it goes to the log file +test --test_output=errors +################################ +# Remote Execution Setup # +################################ + +# Use the Angular team internal GCP instance for remote execution. +build:remote --remote_instance_name=projects/internal-200822/instances/primary_instance +build:remote --bes_instance_name=internal-200822 + +# Starting with Bazel 0.27.0 strategies do not need to be explicitly +# defined. See https://github.com/bazelbuild/bazel/issues/7480 +build:remote --define=EXECUTOR=remote + +# Setup the remote build execution servers. +build:remote --remote_cache=remotebuildexecution.googleapis.com +build:remote --remote_executor=remotebuildexecution.googleapis.com +build:remote --remote_timeout=600 +build:remote --jobs=150 + +# Setup the toolchain and platform for the remote build execution. The platform +# is provided by the shared dev-infra package and targets k8 remote containers. +build:remote --extra_execution_platforms=@devinfra//bazel/remote-execution:platform_with_network +build:remote --host_platform=@devinfra//bazel/remote-execution:platform_with_network +build:remote --platforms=@devinfra//bazel/remote-execution:platform_with_network + +# Set remote caching settings +build:remote --remote_accept_cached=true +build:remote --remote_upload_local_results=false + +# Force remote executions to consider the entire run as linux. +# This is required for OSX cross-platform RBE. +build:remote --cpu=k8 +build:remote --host_cpu=k8 + +# Set up authentication mechanism for RBE +build:remote --google_default_credentials + +# Use HTTP remote cache +build:remote-cache --remote_cache=https://storage.googleapis.com/angular-team-cache +build:remote-cache --remote_accept_cached=true +build:remote-cache --remote_upload_local_results=false +build:remote-cache --google_default_credentials + +# Additional flags added when running a "trusted build" with additional access +build:trusted-build --remote_upload_local_results=true + +# Fixes issues with browser archives and files with spaces. Could be +# removed in Bazel 8 when Bazel runfiles supports spaces. +build --experimental_inprocess_symlink_creation + +#################################################### +# rules_js specific flags +#################################################### + +# TODO(josephperrott): investigate if this can be removed eventually. +# Prevents the npm package extract from occuring or caching on RBE which overwhelms our quota +build --modify_execution_info=NpmPackageExtract=+no-remote + +# Allow the Bazel server to check directory sources for changes. `rules_js` previously +# heavily relied on this, but still uses directory "inputs" in some cases. +# See: https://github.com/aspect-build/rules_js/issues/1408. +startup --host_jvm_args=-DBAZEL_TRACK_SOURCE_DIRECTORIES=1 + +#################################################### +# User bazel configuration +# NOTE: This needs to be the *last* entry in the config. +#################################################### + +# Load any settings which are specific to the current user. Needs to be *last* statement +# in this config, as the user configuration should be able to overwrite flags from this file. +try-import .bazelrc.user diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 000000000000..11f1d47dac93 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.8.2 diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 420f032c4828..000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: 2 -jobs: - build: - working_directory: ~/angular-cli - docker: - - image: angular/ngcontainer - steps: - - checkout - - restore_cache: - key: angular-cli-{{ .Branch }}-{{ checksum "yarn.lock" }} - - run: | - npm install -g npm@~5.3.0 - npm install - - save_cache: - key: angular-cli-{{ .Branch }}-{{ checksum "yarn.lock" }} - paths: - - "node_modules" - - run: xvfb-run -a node tests/run_e2e.js --glob=tests/build/** diff --git a/.editorconfig b/.editorconfig index b53765a86c8d..c0a70d2acb14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,13 +1,15 @@ -# http://editorconfig.org +# https://editorconfig.org root = true -[*] +[*.ts] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true +spaces_around_brackets = inside trim_trailing_whitespace = true +quote_type = single [*.md] insert_final_newline = false diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index bd119135a505..000000000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -dist/ -.git/ -tmp/ -typings/ - -# Ignore all blueprint files. We e2e tests those later on. -packages/@angular/cli/blueprints/*/files/ - -# Ignore ember cli. -packages/@angular/cli/ember-cli/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index ad06da4e2265..000000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,230 +0,0 @@ -{ - "ecmaFeatures": { - "modules": true, - "module": true - }, - "env": { - "mocha": true, - "node": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - }, - "rules": { - "no-alert": "off", - "no-array-constructor": "off", - "no-bitwise": "off", - "no-caller": "off", - "no-case-declarations": "error", - "no-catch-shadow": "off", - "no-class-assign": "error", - "no-cond-assign": "error", - "no-confusing-arrow": "off", - "no-console": "error", - "no-const-assign": "error", - "no-constant-condition": "error", - "no-continue": "off", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-div-regex": "off", - "no-dupe-class-members": "error", - "no-dupe-keys": "error", - "no-dupe-args": "error", - "no-duplicate-case": "error", - "no-duplicate-imports": "off", - "no-else-return": "off", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-function": "off", - "no-empty-pattern": "error", - "no-eq-null": "off", - "no-eval": "off", - "no-ex-assign": "error", - "no-extend-native": "off", - "no-extra-bind": "off", - "no-extra-boolean-cast": "error", - "no-extra-label": "off", - "no-extra-parens": "off", - "no-extra-semi": "error", - "no-fallthrough": "error", - "no-floating-decimal": "off", - "no-func-assign": "error", - "no-implicit-coercion": "off", - "no-implicit-globals": "off", - "no-implied-eval": "off", - "no-inline-comments": "off", - "no-inner-declarations": "error", - "no-invalid-regexp": "error", - "no-invalid-this": "off", - "no-irregular-whitespace": "error", - "no-iterator": "off", - "no-label-var": "off", - "no-labels": "off", - "no-lone-blocks": "off", - "no-lonely-if": "off", - "no-loop-func": "off", - "no-mixed-requires": "off", - "no-mixed-spaces-and-tabs": "error", - "linebreak-style": "off", - "no-multi-spaces": "off", - "no-multi-str": "off", - "no-multiple-empty-lines": "off", - "no-native-reassign": "off", - "no-negated-condition": "off", - "no-negated-in-lhs": "error", - "no-nested-ternary": "off", - "no-new": "off", - "no-new-func": "off", - "no-new-object": "off", - "no-new-require": "off", - "no-new-symbol": "error", - "no-new-wrappers": "off", - "no-obj-calls": "error", - "no-octal": "error", - "no-octal-escape": "off", - "no-param-reassign": "off", - "no-path-concat": "off", - "no-plusplus": "off", - "no-process-env": "off", - "no-process-exit": "off", - "no-proto": "off", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-restricted-globals": "off", - "no-restricted-imports": "off", - "no-restricted-modules": "off", - "no-restricted-syntax": "off", - "no-return-assign": "off", - "no-script-url": "off", - "no-self-assign": "error", - "no-self-compare": "off", - "no-sequences": "off", - "no-shadow": "off", - "no-shadow-restricted-names": "off", - "no-whitespace-before-property": "off", - "no-spaced-func": "off", - "no-sparse-arrays": "error", - "no-sync": "off", - "no-ternary": "off", - "no-trailing-spaces": "off", - "no-this-before-super": "error", - "no-throw-literal": "off", - "no-undef": "error", - "no-undef-init": "off", - "no-undefined": "off", - "no-unexpected-multiline": "error", - "no-underscore-dangle": "off", - "no-unmodified-loop-condition": "off", - "no-unneeded-ternary": "off", - "no-unreachable": "error", - "no-unused-expressions": "off", - "no-unused-labels": "error", - "no-unused-vars": "error", - "no-use-before-define": "off", - "no-useless-call": "off", - "no-useless-concat": "off", - "no-useless-constructor": "off", - "no-void": "off", - "no-var": "off", - "no-warning-comments": "off", - "no-with": "off", - "no-magic-numbers": "off", - "array-bracket-spacing": "off", - "array-callback-return": "off", - "arrow-body-style": "off", - "arrow-parens": "off", - "arrow-spacing": "off", - "accessor-pairs": "off", - "block-scoped-var": "off", - "block-spacing": "off", - "brace-style": [2, "1tbs", { "allowSingleLine": false }], - "callback-return": "off", - "camelcase": "off", - "comma-dangle": "error", - "comma-spacing": "off", - "comma-style": "off", - "complexity": ["off", 11], - "computed-property-spacing": "off", - "consistent-return": "off", - "consistent-this": "off", - "constructor-super": "error", - "curly": "off", - "default-case": "off", - "dot-location": "off", - "dot-notation": "off", - "eol-last": "off", - "eqeqeq": "off", - "func-names": "off", - "func-style": "off", - "generator-star-spacing": "off", - "global-require": "off", - "guard-for-in": "off", - "handle-callback-err": "off", - "id-length": "off", - "indent": [2,2, { "SwitchCase": 1 }], - "init-declarations": "off", - "jsx-quotes": "off", - "key-spacing": [2, { - "multiLine": { - "beforeColon": false, - "afterColon": true, - "mode": "minimum" - } - }], - "keyword-spacing": "off", - "lines-around-comment": "off", - "max-depth": "off", - "max-len": "off", - "max-nested-callbacks": "off", - "max-params": "off", - "max-statements": "off", - "max-statements-per-line": "off", - "new-cap": "off", - "new-parens": "off", - "newline-after-var": "off", - "newline-before-return": "off", - "newline-per-chained-call": "off", - "object-curly-spacing": [2, "always"], - "object-shorthand": "off", - "one-var": "off", - "one-var-declaration-per-line": "off", - "operator-assignment": "off", - "operator-linebreak": "off", - "padded-blocks": "off", - "prefer-arrow-callback": "off", - "prefer-const": "off", - "prefer-reflect": "off", - "prefer-rest-params": "off", - "prefer-spread": "off", - "prefer-template": "off", - "quote-props": "off", - "quotes": [2, "single"], - "radix": "off", - "id-match": "off", - "id-blacklist": "off", - "require-jsdoc": "off", - "require-yield": "off", - "semi-spacing": "off", - "sort-vars": "off", - "sort-imports": "off", - "space-before-blocks": "off", - "space-before-function-paren": "off", - "space-in-parens": "off", - "space-infix-ops": "off", - "space-unary-ops": "off", - "spaced-comment": "off", - "strict": "off", - "template-curly-spacing": "off", - "use-isnan": "error", - "valid-jsdoc": "off", - "valid-typeof": "error", - "vars-on-top": "off", - "wrap-iife": "off", - "wrap-regex": "off", - "yield-star-spacing": "off", - "yoda": "off" - } -} diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 000000000000..795367b13a53 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,13 @@ +have_fun: false +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: false + code_review: true + include_drafts: false +ignore_patterns: + - pnpm-lock.yaml + - CHANGELOG.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..de32b85d6693 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# JS and TS files must always use LF for tools to work +*.js eol=lf +*.ts eol=lf +*.json eol=lf +*.css eol=lf +*.scss eol=lf +*.less eol=lf +*.html eol=lf +*.svg eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 1c37110b0f28..000000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,3 +0,0 @@ -packages/@ngtools/webpack/* @hansl -packages/@angular/blueprints/* @Brocco -packages/@angular/models/webpack-configs/* @filipesilva diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 392f93c10d91..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,39 +0,0 @@ - -### Bug Report or Feature Request (mark with an `x`) -``` -- [ ] bug report -> please search issues before submitting -- [ ] feature request -``` - -### Versions. - - - -### Repro steps. - - - -### The log given by the failure. - - - -### Desired functionality. - - - -### Mention any other details that might be useful. - - diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 000000000000..5c4ea7d2cbdb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,102 @@ +name: Bug report +description: Report a bug in Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are effected by this bug? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - other + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: checkboxes + id: is-regression + attributes: + label: Is this a regression? + description: Did this behavior use to work in the previous version? + options: + - label: Yes, this behavior used to work in the previous version + - type: input + id: version-bug-was-not-present + attributes: + label: The previous version in which this bug was not present was + validations: + required: false + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem. + validations: + required: true + - type: textarea + id: minimal-reproduction + attributes: + label: Minimal Reproduction + description: | + Simple steps to reproduce this bug. + + **Please include:** + * commands run (including args) + * packages added + * related code changes + + + If reproduction steps are not enough for reproduction of your issue, please create a minimal GitHub repository with the reproduction of the issue. + A good way to make a minimal reproduction is to create a new app via `ng new repro-app` and add the minimum possible code to show the problem. + Share the link to the repo below along with step-by-step instructions to reproduce the problem, as well as expected and actual behavior. + + Issues that don't have enough info and can't be reproduced will be closed. + + You can read more about issue submission guidelines [here](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). + validations: + required: true + - type: textarea + id: exception-or-error + attributes: + label: Exception or Error + description: If the issue is accompanied by an exception or an error, please share it below. + render: text + validations: + required: false + - type: textarea + id: environment + attributes: + label: Your Environment + description: Run `ng version` and paste output below. + render: text + validations: + required: true + - type: textarea + id: other + attributes: + label: Anything else relevant? + description: | + Is this a browser specific issue? If so, please specify the browser and version. + Do any of these matter: operating system, IDE, package manager, HTTP server, ...? If so, please mention it below. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 000000000000..4a01698e0f37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,55 @@ +name: Feature request +description: Suggest a feature for Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are relevant for this feature request? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem or missing capability. + validations: + required: true + - type: textarea + id: desired-solution + attributes: + label: Describe the solution you'd like + description: If you have a solution in mind, please describe it. + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..898698af3906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Docs or angular.dev issue report + url: https://github.com/angular/angular/issues/new + about: Report an issue in Angular's documentation or angular.dev application + - name: Security issue disclosure + url: https://angular.dev/best-practices/security + about: Report a security issue in Angular Framework, Material, or CLI + - name: Support request + url: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#question + about: Questions and requests for support. + - name: Angular Framework + url: https://github.com/angular/angular/issues/new/choose + about: Issues and feature requests for Angular Framework + - name: Angular Material + url: https://github.com/angular/components/issues/new/choose + about: Issues and feature requests for Angular Material diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..3214dde0a4f4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## PR Checklist + +Please check to confirm your PR fulfills the following requirements: + + + +- [ ] The commit message follows our guidelines: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-commit-message-guidelines +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been added / updated (for bug fixes / features) + +## PR Type + +What kind of change does this PR introduce? + + + +- [ ] Bugfix +- [ ] Feature +- [ ] Code style update (formatting, local variables) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] CI related changes +- [ ] Documentation content changes +- [ ] Other... Please describe: + +## What is the current behavior? + + + +Issue Number: N/A + +## What is the new behavior? + + + +## Does this PR introduce a breaking change? + +- [ ] Yes +- [ ] No + + + +## Other information diff --git a/.github/SAVED_REPLIES.md b/.github/SAVED_REPLIES.md index 697336412011..dfce61802c0e 100644 --- a/.github/SAVED_REPLIES.md +++ b/.github/SAVED_REPLIES.md @@ -4,77 +4,108 @@ The following are canned responses that the Angular CLI team should use to close Since GitHub currently doesn't allow us to have a repository-wide or organization-wide list of [saved replies](https://help.github.com/articles/working-with-saved-replies/), these replies need to be maintained by individual team members. Since the responses can be modified in the future, all responses are versioned to simplify the process of keeping the responses up to date. - ## Angular CLI: Already Fixed (v1) + ``` Thanks for reporting this issue. Luckily, it has already been fixed in one of the recent releases. Please update to the most recent version to resolve the problem. If the problem persists in your application after upgrading, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. You can use `ng new repro-app` to create a new project where you reproduce the problem. ``` - ## Angular CLI: Don't Understand (v1) + ``` I'm sorry, but we don't understand the problem you are reporting. If the problem persists, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. You can use `ng new repro-app` to create a new project where you reproduce the problem. ``` - ## Angular CLI: Duplicate (v1.1) + ``` Thanks for reporting this issue. However, this issue is a duplicate of #. Please subscribe to that issue for future updates. ``` - ## Angular CLI: Insufficient Information Provided (v1) + ``` -Thanks for reporting this issue. However, you didn't provide sufficient information for us to understand and reproduce the problem. Please check out [our submission guidelines](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-submitting-an-issue) to understand why we can't act on issues that are lacking important information. +Thanks for reporting this issue. However, you didn't provide sufficient information for us to understand and reproduce the problem. Please check out [our submission guidelines](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue) to understand why we can't act on issues that are lacking important information. If the problem persists, please file a new issue and ensure you provide all of the required information when filling out the issue template. ``` +## Angular CLI: NPM install issue (v1) + +``` +This seems like a problem with your node/npm and not with Angular CLI. + +Please have a look at the [fixing npm permissions page](https://docs.npmjs.com/getting-started/fixing-npm-permissions), [common errors page](https://docs.npmjs.com/troubleshooting/common-errors), [npm issue tracker](https://github.com/npm/npm/issues), or open a new issue if the problem you are experiencing isn't known. +``` + ## Angular CLI: Issue Outside of Angular CLI (v1.1) + ``` I'm sorry, but this issue is not caused by Angular CLI. Please contact the author(s) of the project or file an issue on their issue tracker. ``` - ## Angular CLI: Non-reproducible (v1) + ``` I'm sorry, but we can't reproduce the problem following the instructions you provided. +Remember that we have a large number of issues to resolve, and have only a limited amount of time to reproduce your issue. +Short, explicit instructions make it much more likely we'll be able to reproduce the problem so we can fix it. -If the problem persists, please open a new issue following [our submission guidelines](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-submitting-an-issue). -``` +If the problem persists, please open a new issue following [our submission guidelines](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). +A good way to make a minimal repro is to create a new app via `ng new repro-app` and adding the minimum possible code to show the problem. Then you can push this repository to github and link it here. +``` ## Angular CLI: Obsolete (v1) + ``` Thanks for reporting this issue. This issue is now obsolete due to changes in the recent releases. Please update to the most recent Angular CLI version. If the problem persists after upgrading, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. ``` - ## Angular CLI: Support Request (v1) -``` -Hello, we reviewed this issue and determined that it doesn't fall into the bug report or feature request category. This issue tracker is not suitable for support requests, please repost your issue on [StackOverflow](http://stackoverflow.com/) using tag `angular-cli`. -If you are wondering why we don't resolve support issues via the issue tracker, please [check out this explanation](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-got-a-question-or-problem). ``` +Hello, we reviewed this issue and determined that it doesn't fall into the bug report or feature request category. This issue tracker is not suitable for support requests, please repost your issue on [StackOverflow](https://stackoverflow.com/) using tag `angular-cli`. +If you are wondering why we don't resolve support issues via the issue tracker, please [check out this explanation](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-got-a-question-or-problem). +``` ## Angular CLI: Static Analysis errors (v1) + ``` Hello, errors like `Error encountered resolving symbol values statically` mean that there has been some problem in statically analyzing your app. -Angular CLI always runs *some* statical analysis, even on JIT mode, in order to discover lazy-loaded routes. +Angular CLI always runs *some* static analysis, even in JIT mode, in order to discover lazy-loaded routes. This may cause a lot of static analysis errors to surface when importing your project into the CLI, or upgrading for older versions where we didn't run this kind of analysis. -Below are good resources on how to to debug these errors: +Below are good resources on how to debug these errors: - https://gist.github.com/chuckjaz/65dcc2fd5f4f5463e492ed0cb93bca60 - https://github.com/rangle/angular-2-aot-sandbox#aot-dos-and-donts If your problem still persists, it might be a bug with the Angular Compiler itself. In that case, please open an issue in https://github.com/angular/angular. ``` + +## Angular CLI: Lockfiles (v1) + +``` +I'd like to remind everyone that **you only have reproducible installs if you use a lockfile**. Both [NPM v5+](https://docs.npmjs.com/files/package-locks) and [Yarn](https://yarnpkg.com/lang/en/docs/yarn-lock/) support lockfiles. If your CI works one day but not the next and you did not change your code or `package.json`, it is likely because one of your dependencies had a bad release and you did not have a lockfile. + +**It is your responsibility as a library consumer to use lockfiles**. No one wants to do a release with bugs but it sometimes happens, and the best we can do is to fix it as fast as possible with a new release. When you have a couple of thousand total dependencies it is only a matter of time until one of them has a bad release. +``` + +## Angular CLI: Spam (v1) + +``` +Woah, looks like you've opened a lot of issues/PRs recently. While we appreciate contributions from the community, triaging and reviewing a large influx of content in a short time period takes time away from other ongoing projects. As a result, we're closing these issues/PRs to maintain the team's focus. + +Note that this is not necessarily a rejection of the goals or direction of any of these contributions in particular, so much as a reflection of the team's current capacity and priorities. + +You are welcome to open a smaller subset of issues/PRs in accordance with [our policy](https://github.com/angular/angular/blob/main/contributing-docs/spam.md) focused on the most important and impactful contributions and we will do our best to prioritize a response as soon as possible. +``` diff --git a/.github/angular-robot.yml b/.github/angular-robot.yml new file mode 100644 index 000000000000..3e3a56a25603 --- /dev/null +++ b/.github/angular-robot.yml @@ -0,0 +1,92 @@ +# Configuration for angular-robot + +# options for the merge plugin +merge: + # the status will be added to your pull requests + status: + # set to true to disable + disabled: true + # the name of the status + context: 'ci/angular: merge status' + # text to show when all checks pass + successText: 'All checks passed!' + # text to show when some checks are failing + failureText: 'The following checks are failing:' + + # comment that will be added to a PR when there is a conflict, leave empty or set to false to disable + mergeConflictComment: >- + Hi @{{PRAuthor}}! This PR has merge conflicts due to recent upstream merges. + + Please help to unblock it by resolving these conflicts. Thanks! + + # label to monitor + mergeLabel: 'action: merge' + + # list of checks that will determine if the merge label can be added + checks: + # whether the PR shouldn't have a conflict with the base branch + noConflict: true + # whether the PR should have all reviews completed. + requireReviews: true + # list of labels that a PR needs to have, checked with a regexp (e.g. "target:" will work for the label "target: major") + requiredLabels: + - 'target: *' + + # list of labels that a PR shouldn't have, checked after the required labels with a regexp + forbiddenLabels: + - 'action: cleanup' + - 'action: review' + - 'PR state: blocked' + + # list of PR statuses that need to be successful + # NOTE: Required PR statuses are now exclusively handled via Github configuration + requiredStatuses: [] + + # the comment that will be added when the merge label is added despite failing checks, leave empty or set to false to disable + # {{MERGE_LABEL}} will be replaced by the value of the mergeLabel option + # {{PLACEHOLDER}} will be replaced by the list of failing checks + mergeRemovedComment: >- + I see that you just added the `{{MERGE_LABEL}}` label, but the following + checks are still failing: + + {{PLACEHOLDER}} + + **If you want your PR to be merged, it has to pass all the CI checks.** + + If you can't get the PR to a green state due to flakes or broken `main`, + please try rebasing to `main` and/or restarting the CI job. If that fails + and you believe that the issue is not due to your change, please contact the + caretaker and ask for help. +# options for the triage plugin +triage: + # set to true to disable + disabled: true + # number of the milestone to apply when the issue has not been triaged yet + needsTriageMilestone: 11, + # number of the milestone to apply when the issue is triaged + defaultMilestone: 12, + # arrays of labels that determine if an issue has been triaged by the caretaker + l1TriageLabels: + - - 'area: *' + # arrays of labels that determine if an issue has been fully triaged + l2TriageLabels: + - - 'type: bug/fix' + - 'severity*' + - 'freq*' + - 'area: *' + - - 'type: feature' + - 'area: *' + - - 'type: refactor' + - 'area: *' + - - 'type: RFC / Discussion / question' + - 'area: *' + - - 'type: docs' + - 'area: *' + +# Size checking +size: + # Size checking for production build is performed via the E2E test `build/prod-build` + disabled: true + circleCiStatusName: 'ci/circleci: e2e-cli' + maxSizeIncrease: 10000 + comment: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..a01353b6bcf0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'daily' + commit-message: + prefix: 'build' + labels: + - 'area: build & ci' + - 'target: patch' + - 'action: merge' + # Disable version updates + # This does not affect security updates + open-pull-requests-limit: 0 diff --git a/.github/shared-actions/windows-bazel-test/action.yml b/.github/shared-actions/windows-bazel-test/action.yml new file mode 100644 index 000000000000..7a5cf7c0297b --- /dev/null +++ b/.github/shared-actions/windows-bazel-test/action.yml @@ -0,0 +1,42 @@ +name: Native Windows Bazel E2E test +description: Runs an Angular CLI e2e Bazel test on native Windows +author: Angular + +inputs: + test_target_name: + description: E2E test target name. + required: true + e2e_temp_dir: + description: 'The temporary directory path for E2E tests.' + required: false + # Use D:\\ by default as it's much faster + # See: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows + default: 'D:\\tmp_dir' + +runs: + using: 'composite' + steps: + - name: Set up temp directory + shell: bash + run: | + mkdir ${{ inputs.e2e_temp_dir }} + + - name: Convert symlinks for Windows host + shell: pwsh + run: | + $runfiles_dir = "./dist/bin/tests/${{inputs.test_target_name}}_/${{inputs.test_target_name}}.bat.runfiles" + + # Needed for resolution because Aspect/Bazel looks for repositories at `/external`. + # TODO(devversion): consult with Aspect on why this is needed. + Set-Location -Path "${runfiles_dir}\_main" + New-Item -ItemType SymbolicLink -Path "external" -Target ".." + + - name: Run CLI E2E tests + shell: bash + env: + BAZEL_BINDIR: '.' + E2E_TEMP: ${{ inputs.e2e_temp_dir }} + run: | + node ./scripts/windows-testing/parallel-executor.mjs \ + "./dist/bin/tests/${{ inputs.test_target_name }}_/${{ inputs.test_target_name }}.bat.runfiles" \ + ${{ inputs.test_target_name }} diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml new file mode 100644 index 000000000000..cbf328812441 --- /dev/null +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -0,0 +1,23 @@ +name: DevInfra + +on: + push: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, labeled] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + assistant_to_the_branch_manager: + runs-on: ubuntu-latest + if: github.event.repository.fork == false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: angular/dev-infra/github-actions/branch-manager@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..a5ecf468b34f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,264 @@ +name: CI + +on: + push: + branches: + - main + - '[0-9]+.[0-9]+.x' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm run build-schema + - name: Run ESLint + run: pnpm lint --cache-strategy content + - name: Validate NgBot Configuration + run: pnpm ng-dev ngbot verify + - name: Validate Circular Dependencies + run: pnpm ts-circular-deps check + - name: Run Validation + run: pnpm admin validate + - name: Check tooling setup + run: pnpm check-tooling-setup + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test -- //... -//tests/... + + e2e: + needs: test + strategy: + fail-fast: false + matrix: + node: [22, 24, 26] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + # Pin to ubuntu-24.04 because on ubuntu-26.04 /tmp is mounted as a tmpfs with a 7.8GB quota, + # causing disk quota exceeded (EDQUOT) errors during E2E package extraction. + # See: https://github.com/actions/runner-images/issues/14777 + runs-on: ubuntu-24.04 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + build-e2e-windows: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build E2E tests for Windows on Linux + run: | + pnpm bazel build \ + --config=e2e \ + //tests:e2e.webpack_node24 \ + //tests:e2e.esbuild_node24 \ + --platforms=tools:windows_x64 + - name: Store built Windows E2E tests + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: win-e2e-build-artifacts + path: | + dist/bin/tests/** + !**/node_modules/** + retention-days: 1 + if-no-files-found: 'error' + + e2e-windows: + needs: build-e2e-windows + strategy: + fail-fast: false + matrix: + node: [24] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Download built Windows E2E tests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: win-e2e-build-artifacts + path: dist/bin/tests/ + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.${{ matrix.subset }}_node${{ matrix.node }} + env: + E2E_SHARD_TOTAL: 6 + E2E_SHARD_INDEX: ${{ matrix.shard }} + + e2e-package-managers: + needs: test + strategy: + fail-fast: false + matrix: + node: [22] + subset: [yarn, pnpm, bun] + shard: [0, 1, 2] + # Pin to ubuntu-24.04 because on ubuntu-26.04 /tmp is mounted as a tmpfs with a 7.8GB quota, + # causing disk quota exceeded (EDQUOT) errors during E2E package extraction. + # See: https://github.com/actions/runner-images/issues/14777 + runs-on: ubuntu-24.04 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: test + if: github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + # Pin to ubuntu-24.04 because on ubuntu-26.04 /tmp is mounted as a tmpfs with a 7.8GB quota, + # causing disk quota exceeded (EDQUOT) errors during E2E package extraction. + # See: https://github.com/actions/runner-images/issues/14777 + runs-on: ubuntu-24.04 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} + + browsers: + needs: build + runs-on: ubuntu-latest + name: Browser Compatibility Tests + env: + SAUCE_TUNNEL_IDENTIFIER: angular-cli-${{ github.workflow }}-${{ github.run_number }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Start Sauce Connect + uses: saucelabs/sauce-connect-action@cb88b508c6f9ff4d84490093733315dbd55de022 # v3 + timeout-minutes: 10 + with: + username: ${{ vars.SAUCE_USERNAME }} + accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} + region: us-west-1 + tunnelName: ${{ env.SAUCE_TUNNEL_IDENTIFIER }} + proxyLocalhost: allow + # By default we disable SSL bumping for all requests. This is because SSL bumping is not needed + # for our test setup and in order to perform the SSL bumping, Saucelabs intercepts all HTTP + # requests in the tunnel VM and modifies them. This can cause flakiness as it makes all requests + # dependent on the SSL bumping middleware. + # See: https://wiki.saucelabs.com/display/DOCS/Troubleshooting+Sauce+Connect#TroubleshootingSauceConnect-DisablingSSLBumping + tlsPassthroughDomains: all + - name: Run E2E Browser tests + env: + SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + run: pnpm bazel test --config=saucelabs //tests:e2e.saucelabs + + publish-snapshots: + needs: build + runs-on: ubuntu-latest + env: + CIRCLE_BRANCH: ${{ github.ref_name }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - run: pnpm admin snapshots --verbose + env: + SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml new file mode 100644 index 000000000000..05c78832060f --- /dev/null +++ b/.github/workflows/dev-infra.yml @@ -0,0 +1,36 @@ +name: DevInfra + +on: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} + pull_request_target: + types: [opened, synchronize, reopened] + issues: + types: [opened, reopened] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + labels: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/labeling/pull-request@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + post_approval_changes: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/post-approval-changes@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + issue_labels: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/labeling/issue@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000000..63056bcff482 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,60 @@ +name: Performance Tracking + +on: + push: + branches: + - main + # Run workflows for all releasable branches + - '[0-9]+.[0-9]+.x' + +permissions: + contents: 'read' + +defaults: + run: + shell: bash + +jobs: + list: + timeout-minutes: 3 + runs-on: ubuntu-latest + outputs: + workflows: ${{ steps.workflows.outputs.workflows }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - id: workflows + run: echo "workflows=$(pnpm --silent ng-dev perf workflows --list)" >> "$GITHUB_OUTPUT" + + workflow: + timeout-minutes: 30 + runs-on: ubuntu-latest + needs: list + permissions: + contents: 'read' + id-token: 'write' + strategy: + matrix: + workflow: ${{ fromJSON(needs.list.outputs.workflows) }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + # We utilize the google-github-actions/auth action to allow us to get an active credential using workflow + # identity federation. This allows us to request short lived credentials on demand, rather than storing + # credentials in secrets long term. More information can be found at: + # https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + project_id: 'internal-200822' + workload_identity_provider: 'projects/823469418460/locations/global/workloadIdentityPools/measurables-tracking/providers/angular' + service_account: 'measures-uploader@internal-200822.iam.gserviceaccount.com' + - run: pnpm ng-dev perf workflows --name "$WORKFLOW" --commit-sha "$COMMIT_SHA" + env: + WORKFLOW: ${{ matrix.workflow }} + COMMIT_SHA: ${{ github.sha }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000000..4f821bbb2481 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,225 @@ +name: Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + analyze: + runs-on: ubuntu-latest + outputs: + snapshots: ${{ steps.filter.outputs.snapshots }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + snapshots: + - 'tests/e2e/ng-snapshot/package.json' + + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup ESLint Caching + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .eslintcache + key: ${{ runner.os }}-${{ hashFiles('.eslintrc.json') }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm run build-schema + - name: Run ESLint + run: pnpm lint --cache-strategy content + - name: Validate NgBot Configuration + run: pnpm ng-dev ngbot verify + - name: Validate Circular Dependencies + run: pnpm ts-circular-deps check + - name: Run Validation + run: pnpm admin validate + - name: Check tooling setup + run: pnpm check-tooling-setup + - name: Check commit message + # Commit message validation is only done on pull requests as its too late to validate once + # it has been merged. + run: pnpm ng-dev commit-message validate-range ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} + - name: Check code format + # Code formatting checks are only done on pull requests as its too late to validate once + # it has been merged. + run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }} + - name: Check Package Licenses + uses: angular/dev-infra/github-actions/linting/licenses@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + - name: Store PR release packages + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: packages + path: dist/releases/*.tgz + retention-days: 14 + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test -- //... -//tests/... + + e2e: + needs: build + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + # Pin to ubuntu-24.04 because on ubuntu-26.04 /tmp is mounted as a tmpfs with a 7.8GB quota, + # causing disk quota exceeded (EDQUOT) errors during E2E package extraction. + # See: https://github.com/actions/runner-images/issues/14777 + runs-on: ubuntu-24.04 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + build-e2e-windows-subset: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build E2E tests for Windows on Linux + run: | + pnpm bazel build \ + --config=e2e \ + //tests:e2e.esbuild_node24 \ + --platforms=tools:windows_x64 + - name: Store built Windows E2E tests + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: win-e2e-build-artifacts + path: | + dist/bin/tests/** + !**/node_modules/** + retention-days: 1 + if-no-files-found: 'error' + + e2e-windows-subset: + needs: build-e2e-windows-subset + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Download built Windows E2E tests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: win-e2e-build-artifacts + path: dist/bin/tests/ + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.esbuild_node24 + env: + E2E_SHARD_TOTAL: 1 + TESTBRIDGE_TEST_ONLY: tests/basic/{build,rebuild,serve}.ts + + e2e-package-managers: + needs: build + strategy: + fail-fast: false + matrix: + node: [22] + subset: [yarn, pnpm, bun] + shard: [0, 1, 2] + # Pin to ubuntu-24.04 because on ubuntu-26.04 /tmp is mounted as a tmpfs with a 7.8GB quota, + # causing disk quota exceeded (EDQUOT) errors during E2E package extraction. + # See: https://github.com/actions/runner-images/issues/14777 + runs-on: ubuntu-24.04 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: [analyze, build] + if: needs.analyze.outputs.snapshots == 'true' + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + # Pin to ubuntu-24.04 because on ubuntu-26.04 /tmp is mounted as a tmpfs with a 7.8GB quota, + # causing disk quota exceeded (EDQUOT) errors during E2E package extraction. + # See: https://github.com/actions/runner-images/issues/14777 + runs-on: ubuntu-24.04 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47 # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} diff --git a/.gitignore b/.gitignore index cc0280e30214..83582f8ece43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,59 @@ +# Outputs +bazel-* +test-project-host-* dist/ -node_modules/ -npm-debug.log* -yarn-error.log* +dist-schema/ + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions # IDEs -.idea/ jsconfig.json -.vscode/ + +# Intellij IDEA/WebStorm +# https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/inspectionProfiles/ +.idea/**/compiler.xml +.idea/**/encodings.xml +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Also ignore code styles because .editorconfig is used instead. +.idea/codeStyles/ + +# VSCode +# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +**/*.code-workspace # Typings file. typings/ # Misc +coverage/ +node_modules/ tmp/ +npm-debug.log* +yarn-error.log* +.ng_pkg_build/ +.ng-dev.log +.ng-dev.err*.log +.ng-dev.user* +.husky/_ +.bazelrc.user +.eslintcache # Mac OSX Finder files. **/.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000000..de361903a541 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000000..7f9d46b47835 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm --silent ng-dev format staged; diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 000000000000..f4598b6d1b6e --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000000..f651b93dd40b --- /dev/null +++ b/.mailmap @@ -0,0 +1,54 @@ +# Please keep this file in alphabetical order, per section. + + +################################################################################ +# Angular Team +################################################################################ +Hans Larsen +Igor Minar + Igor Minar + + +################################################################################ +# Angular CLI Team +################################################################################ +Charles Lyding + Charles Lyding <19598772+clydin@users.noreply.github.com> +Filipe Silva +Mike Brocchi +Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + + +################################################################################ +# OpenSource Contributors +################################################################################ +Ashok Tamang + Ashok Tamang +Bram Borggreve +Carlo Dapor + +Cédric Exbrayat + Cédric Exbrayat +Chris Pearce + Chris Pearce +Dominic Watson + Dominic Watson +Jeremy Wells +Jan Kuri +Jim Cummins + Jim Cummins +John Papa + John Papa +Meligy +Michał Gołębiowski-Owczarek +Preston Van Loon + Preston Van Loon +Rodric Haddad +Shai Reznik + Shai Reznik + Shai Reznik +Stephen Cavaliere + Stephen Cavaliere diff --git a/.monorepo.json b/.monorepo.json new file mode 100644 index 000000000000..ad86e09acdeb --- /dev/null +++ b/.monorepo.json @@ -0,0 +1,118 @@ +{ + "packages": { + "@_/builders": {}, + "devkit": {}, + "@angular/build": { + "name": "Angular Build System", + "section": "Tooling", + "links": [ + { + "label": "README", + "url": "/packages/angular/build/README.md" + } + ], + "snapshotRepo": "angular/angular-build-builds" + }, + "@angular/cli": { + "name": "Angular CLI", + "section": "Tooling", + "links": [ + { + "label": "README", + "url": "/packages/angular/cli/README.md" + } + ], + "snapshotRepo": "angular/cli-builds" + }, + "@angular/create": { + "name": "Angular Create", + "section": "Misc", + "links": [ + { + "label": "README", + "url": "/packages/angular/create/README.md" + } + ] + }, + "@angular/pwa": { + "name": "Angular PWA Schematics", + "section": "Schematics", + "snapshotRepo": "angular/angular-pwa-builds" + }, + "@angular/ssr": { + "name": "Angular SSR", + "links": [ + { + "label": "README", + "url": "/packages/angular/ssr/README.md" + } + ], + "snapshotRepo": "angular/angular-ssr-builds" + }, + "@angular-devkit/architect": { + "name": "Architect", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/architect/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-architect-builds" + }, + "@angular-devkit/build-angular": { + "name": "Build Angular", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/build_angular/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-build-angular-builds" + }, + "@angular-devkit/build-webpack": { + "name": "Build Webpack", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/build_webpack/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-build-webpack-builds" + }, + "@angular-devkit/core": { + "name": "Core", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/core/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-core-builds" + }, + "@angular-devkit/schematics": { + "name": "Schematics", + "links": [ + { + "label": "README", + "url": "/packages/angular_devkit/schematics/README.md" + } + ], + "snapshotRepo": "angular/angular-devkit-schematics-builds" + }, + "@angular-devkit/schematics-cli": { + "name": "Schematics CLI", + "section": "Tooling", + "snapshotRepo": "angular/angular-devkit-schematics-cli-builds" + }, + "@ngtools/webpack": { + "name": "Webpack Angular Plugin", + "section": "Misc", + "snapshotRepo": "angular/ngtools-webpack-builds" + }, + "@schematics/angular": { + "name": "Angular Schematics", + "section": "Schematics", + "snapshotRepo": "angular/schematics-angular-builds" + } + } +} diff --git a/.ng-dev/caretaker.mjs b/.ng-dev/caretaker.mjs new file mode 100644 index 000000000000..d21e783f4af5 --- /dev/null +++ b/.ng-dev/caretaker.mjs @@ -0,0 +1,17 @@ +/** + * The configuration for `ng-dev caretaker` commands. + * + * @type { import("@angular/ng-dev").CaretakerConfig } + */ +export const caretaker = { + githubQueries: [ + { + name: 'Merge Queue', + query: `is:pr is:open status:success label:"action: merge"`, + }, + { + name: 'Merge Assistance Queue', + query: `is:pr is:open label:"action: merge-assistance"`, + }, + ], +}; diff --git a/.ng-dev/commit-message.mjs b/.ng-dev/commit-message.mjs new file mode 100644 index 000000000000..d30060674355 --- /dev/null +++ b/.ng-dev/commit-message.mjs @@ -0,0 +1,14 @@ +import { packages } from '../scripts/packages.mts'; + +/** + * The configuration for `ng-dev commit-message` commands. + * + * @type { import("@angular/ng-dev").CommitMessageConfig } + */ +export const commitMessage = { + maxLineLength: Infinity, + minBodyLength: 0, + minBodyLengthTypeExcludes: ['docs'], + // Note: When changing this logic, also change the `contributing.ejs` file. + scopes: packages.map(({ name }) => name), +}; diff --git a/.ng-dev/config.mjs b/.ng-dev/config.mjs new file mode 100644 index 000000000000..6add9773c06c --- /dev/null +++ b/.ng-dev/config.mjs @@ -0,0 +1,6 @@ +export { commitMessage } from './commit-message.mjs'; +export { format } from './format.mjs'; +export { github } from './github.mjs'; +export { pullRequest } from './pull-request.mjs'; +export { release } from './release.mjs'; +export { caretaker } from './caretaker.mjs'; diff --git a/.ng-dev/dx-perf-workflows.yml b/.ng-dev/dx-perf-workflows.yml new file mode 100644 index 000000000000..70960ff760db --- /dev/null +++ b/.ng-dev/dx-perf-workflows.yml @@ -0,0 +1,49 @@ +workflows: + build-cli: + name: '@angular/cli build' + prepare: + - bazel clean + workflow: + - bazel build //packages/angular/cli:npm_package + + angular-build-integration: + name: '@angular/build integration' + disabled: true + prepare: + - bazel clean + workflow: + - bazel test //packages/angular/build:integration_tests + + modules-builder-tests: + name: '@ngtools/webpack test' + prepare: + - bazel clean + workflow: + - bazel test //packages/ngtools/webpack:test + + devkit-core-tests: + name: '@angular/devkit/core tests' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular_devkit/core:test + + devkit-core-tests-rerun: + name: '@angular/devkit/core return test' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular_devkit/core:test + # Add a single line to the beginning of a file to trigger a rebuild/retest + - sed -i '1i // comment' packages/angular_devkit/core/src/workspace/core_spec.ts + - bazel test //packages/angular_devkit/core:test + cleanup: + # Remove the single line added + - sed -i '1d' packages/angular_devkit/core/src/workspace/core_spec.ts + + build-unit-tests: + name: '@angular/build tests' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular/build:test diff --git a/.ng-dev/format.mjs b/.ng-dev/format.mjs new file mode 100644 index 000000000000..c750eeea395c --- /dev/null +++ b/.ng-dev/format.mjs @@ -0,0 +1,9 @@ +/** + * Configuration for the `ng-dev format` command. + * + * @type { import("@angular/ng-dev").FormatConfig } + */ +export const format = { + 'prettier': true, + 'buildifier': true, +}; diff --git a/.ng-dev/github.mjs b/.ng-dev/github.mjs new file mode 100644 index 000000000000..467741f70f83 --- /dev/null +++ b/.ng-dev/github.mjs @@ -0,0 +1,13 @@ +/** + * Github configuration for the ng-dev command. This repository is + * used as remote for the merge script. + * + * @type { import("@angular/ng-dev").GithubConfig } + */ +export const github = { + owner: 'angular', + name: 'angular-cli', + mainBranchName: 'main', + useNgDevAuthService: true, + mergeMode: 'team-only', +}; diff --git a/.ng-dev/pull-request.mjs b/.ng-dev/pull-request.mjs new file mode 100644 index 000000000000..1f2d84d7ccba --- /dev/null +++ b/.ng-dev/pull-request.mjs @@ -0,0 +1,12 @@ +/** + * Configuration for the merge tool in `ng-dev`. This sets up the labels which + * are respected by the merge script (e.g. the target labels). + * + * @type { import("@angular/ng-dev").PullRequestConfig } + */ +export const pullRequest = { + githubApiMerge: { + default: 'auto', + labels: [{ pattern: 'merge: squash commits', method: 'squash' }], + }, +}; diff --git a/.ng-dev/release.mjs b/.ng-dev/release.mjs new file mode 100644 index 000000000000..19e111f22f80 --- /dev/null +++ b/.ng-dev/release.mjs @@ -0,0 +1,48 @@ +import semver from 'semver'; +import { releasePackages } from '../scripts/packages.mts'; + +/** + * Configuration for the `ng-dev release` command. + * + * @type { import("@angular/ng-dev").ReleaseConfig } + */ +export const release = { + representativeNpmPackage: '@angular/cli', + npmPackages: releasePackages.map(({ name, experimental }) => { + if ( + name === '@angular-devkit/build-angular' || + name === '@angular-devkit/build-webpack' || + name === '@ngtools/webpack' + ) { + return { + name, + experimental, + deprecated: { + version: '>=22.0.0-rc.0', + message: + 'Angular\'s Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead.', + }, + }; + } + + return { name, experimental }; + }), + buildPackages: async () => { + // The `performNpmReleaseBuild` function is loaded at runtime to avoid loading additional + // files and dependencies unless a build is required. + const { performNpmReleaseBuild } = await import('../scripts/build-packages-dist.mts'); + return performNpmReleaseBuild(); + }, + prereleaseCheck: async (newVersionStr) => { + const newVersion = new semver.SemVer(newVersionStr); + const { assertValidDependencyRanges } = + await import('../scripts/release-checks/dependency-ranges/index.mts'); + + await assertValidDependencyRanges(newVersion, releasePackages); + }, + releaseNotes: { + groupOrder: ['@angular/cli', '@schematics/angular', '@angular-devkit/schematics-cli'], + }, + publishRegistry: 'https://wombat-dressing-room.appspot.com', + releasePrLabels: ['action: merge'], +}; diff --git a/.ng-dev/tsconfig.json b/.ng-dev/tsconfig.json new file mode 100644 index 000000000000..a250849eb673 --- /dev/null +++ b/.ng-dev/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true, + "allowJs": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "module": "Node16", + "moduleResolution": "Node16", + "checkJs": true, + "noEmit": true, + "types": [] + }, + "include": ["**/*.mjs"], + "exclude": [] +} diff --git a/.nvmrc b/.nvmrc index 1e8b31496214..ce62013c39ae 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -6 +24.22.1 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000000..1adbf7759080 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +/bazel-out/ +/docs/design/analytics.md +/dist-schema/ +/goldens/public-api +/modules/testing/builder/projects/ +/packages/angular_devkit/build_angular/test/ +/packages/angular_devkit/core/src/workspace/json/test/ +/packages/angular_devkit/schematics_cli/blank/project-files/ +/packages/angular_devkit/schematics_cli/blank/schematic-files/ +/packages/angular_devkit/schematics_cli/schematic/files/ +/packages/schematics/angular/third_party/ +/README.md +/CONTRIBUTING.md +dist/ +/tests/e2e/assets/ +/tools/test/*.json +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000000..22e9e4076522 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "quoteProps": "preserve", + "singleQuote": true +} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 038d74b90c0a..000000000000 --- a/.travis.yml +++ /dev/null @@ -1,109 +0,0 @@ -dist: trusty -sudo: false - -addons: - chrome: stable - -language: node_js - -cache: - directories: - - ./node_modules - -matrix: - fast_finish: true - allow_failures: - - env: nightly - - env: ng2 - - node_js: "7" - - node_js: "8" - include: - - stage: build - script: npm run lint - env: lint - - script: npm run build - env: build - - - stage: test - script: npm run test - env: test - - node_js: "6" - os: linux - script: node tests/run_e2e.js --nb-shards=4 --shard=0 --nosilent - env: e2e-0 - - node_js: "6" - os: linux - script: node tests/run_e2e.js --nb-shards=4 --shard=1 --nosilent - env: e2e-1 - - node_js: "6" - os: linux - script: node tests/run_e2e.js --nb-shards=4 --shard=2 --nosilent - env: e2e-2 - - node_js: "6" - os: linux - script: node tests/run_e2e.js --nb-shards=4 --shard=3 --nosilent - env: e2e-3 - - node_js: "6" - os: linux - script: node tests/run_e2e.js --eject "--glob=tests/build/**" - env: eject - - # Optional builds. - - node_js: "6" - os: linux - before_script: if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then exit 0; fi - script: node tests/run_e2e.js --ng2 "--glob=tests/{build,test,misc}/**" - env: ng2 - - node_js: "6" - os: linux - before_script: if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then exit 0; fi - script: node tests/run_e2e.js "--nightly --glob=tests/{build,test,misc}/**" - env: nightly - - node_js: "7" - os: linux - before_script: if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then exit 0; fi - script: node tests/run_e2e.js "--glob=tests/{build,test,misc}/**" - env: node7 - - node_js: "8" - os: linux - before_script: if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then exit 0; fi - script: node tests/run_e2e.js "--glob=tests/build/**" - env: node8 - - - stage: deploy - script: skip - env: builds - deploy: - - provider: script - script: node scripts/git-builds.js - skip_cleanup: true - on: - all_branches: true - - stage: deploy - script: skip - env: publish - deploy: - - provider: script - script: node scripts/publish/publish.js - skip_cleanup: true - on: - tags: true - - -before_install: - # Install npm 5. - - npm install -g npm@~5.3.0 - -install: - - npm install - -env: - global: - - DBUS_SESSION_BUS_ADDRESS=/dev/null - # GITHUB_ACCESS_TOKEN= - # This is needed to publish packages to github for continuous packages delivery. - - secure: "XUM1RCN/ZH34ju2PI9eHPRMnSlbj45XcBx/ZnZ13wfau60iJCveU7Ded8SC8uwGDp58rGGePXyJfh5l5dAnIWbSAGNKlrEgGTr41RSCbyzrV6m4lAcwNzUo/PkKum1vAApk21PqscrSSWOBAOC3nMCln25GLzgUMm6G8FBOc599NuszcoQvru+H+4JthSHn4l/nmeTZDs2oCBvwnmng/78rEW5CXvJEpAwuaaZLvzzIFdoZu+SuceYvGnL5xHo0ppPF/0NYzZlb7KBKREp44Lq1lOKlDtH5wqjEi7b2aA7/GnesOgLUBmbbSVUzmCxs6PtzGhD56aiL2Qtu8c4LZyvNfzPXSLbx8ljYmdytHkio9w3377wWFRXJDfIsgv9JKHxxyA0T34yzQkqWbgWKPMgJ3om+lb875iXAtXfNyS0trnN+rwLZZtFxygjHyx8IWY+NZD5i5fyjMdFyetNxbHe3ktI73nY+Tl6hDqJXvN8WwQlMovLNUMcY4wGUoPooLka9L+94uetbf3U4KVgshrLHI59jMZsO0qrDGsCZMubHEy80acrNkCTi/uGIbVR0KF94z81VF+S7edYv80AkQTRijpgNV34045L/IqSHgbueozRgVFj2arkAGGFjLlZJifmsRC/Eq1ZWkKsKY/1NusKssCRtycJQjeLOW0qV1T0w=" - # NPM_TOKEN used for deployment. - - secure: "QDefZm9p3/SQ6ts0WVkzwr1qv4tvxpu5hB5ptTGQwRP02oPQumebE+uazSjhrbMf9zCnlrQSaCV7VxMdqQ0GUdAQ+gZz4mr/6SyT1eThTfgqq4GFX24M5dlcWW4rB6ot5NICPnsvASBVDn4IkapTY135/s5fqp2TeDJmAH8xMxKRbsG/b4+KCvhjpO7WJMZumkrwsWFrtAh1zFjrXoHwEySYPkwZceJmSi7GPmjZLodMmSkMxi4vuOAgrx6kWPTuCszKGXm7+Y1UbjeSSoBYLt2c19oy2dJKrER9sSx8/1yeV1C2JJ/cabYBktcn1svUh0lo1j1YOWtEXaIv7SRU694EtWxiuaL8iIcABHdoaLt/BZ34MLG0RdvFu0i2Bv5HxGWQg5TB/Jy502dqkep5ILofF80Ya9NTeapIPSbI3IEWcsCCw+t0pv/91XXU7mduDwtvBtzwxC4nKS4PITjrFTSrsr0wiLagW6ZCbKMrUitEFgedvsa3Tf8wVtt1s/Z7W2iLt81TTVdCei+46PtLIG3k9R1J78WmPLsGJ3uMXo/Cgc7q5PN66Dx6V1RZMBbS9xG0OPxd8a/6Lj94u6QWS1x50osVPEqUqGmT2tWP5fabrDKLUnVLm2Y0O1eBnnu9ew+ccCKou0FCqlx5ppRekBIaMibHbmgahRLMgNbaJR0=" - - diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000000..6f3e0e3d3375 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +// A launch configuration that compiles the extension and then opens it inside a new window +{ + "version": "0.2.0", + "configurations": [ + { + "name": "DEBUG: Attach to bazel test", + "type": "node", + "request": "attach", + "port": 9229, + "restart": true, + "timeout": 600000, + "sourceMaps": true, + "skipFiles": ["/**"], + "sourceMapPathOverrides": { + "?:*@0.0.0/node_modules/@angular-devkit/build-angular/*": "${workspaceFolder}/packages/angular_devkit/build_angular/*", + "?:*@0.0.0/node_modules/@angular-devkit/build-webpack/*": "${workspaceFolder}/packages/angular_devkit/build_webpack/*", + "?:*@0.0.0/node_modules/@angular-devkit/*": "${workspaceFolder}/packages/angular_devkit/*", + "?:*@0.0.0/node_modules/@angular/*": "${workspaceFolder}/packages/angular/*", + "?:*/bin/*": "${workspaceFolder}/*" + }, + "resolveSourceMapLocations": ["*/**", "!**/rxjs**"] + }, + { + "name": "DEBUG: Run bazel test (Custom Target)", + "type": "node", + "request": "launch", + "restart": true, + "timeout": 600000, + "runtimeExecutable": "pnpm", + "runtimeArgs": ["bazel", "test", "${input:bazelTarget}", "--config=debug"], + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + } + ], + "compounds": [ + { + "name": "DEBUG: Run bazel test (Custom Target) + Attach", + "configurations": ["DEBUG: Attach to bazel test", "DEBUG: Run bazel test (Custom Target)"] + } + ], + "inputs": [ + { + "id": "bazelTarget", + "type": "promptString", + "description": "Enter the Bazel test target (e.g., //path/to/my:unit_test)", + "default": "//packages/..." + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000000..b98a874af297 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "[javascript]": { + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.formatOnSave": true + }, + // Exclude third party modules and build artifacts from the editor watchers/searches. + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/**": true, + "**/bazel-out/**": true, + "**/dist/**": true, + "**/dist-schema/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/bazel-out": true, + "**/dist": true, + "**/dist-schema": true, + ".history": true + }, + "git.ignoreLimitWarning": true, + "gitlens.advanced.blame.customArguments": ["--ignore-revs-file .git-blame-ignore-revs"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..7ed3ec6a1316 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +--- +trigger: always_on +--- + +This is the source code for the Angular CLI and related build tooling. This guide outlines standard practices for AI agents working in this repository. + +## Environment + +- Use `pnpm` for package management. +- Use `pnpm bazel test //target` to run tests. + +## Key Documentation + +- [Developer Guide](docs/DEVELOPER.md): definitive guide for building, debugging, and running test targets. +- [Contributing Guide](CONTRIBUTING.md): general contribution workflows and guidelines. +- [Commit Guidelines](CONTRIBUTING.md#commit): format for commit messages and PR titles. + +## Building + +- Make a local build of all packages: + ```shell + pnpm build --local + ``` + +## Coding Practices + +- **Imports:** + - Always use the `node:` protocol for Node.js built-in imports (e.g., `node:fs`, `node:path`, `node:assert`). + - Prefer named imports (e.g., `import { mkdtemp } from 'node:fs'`) or default imports (`import fs from 'node:fs'`) instead of namespace imports (`import * as fs`). + - Use type-only imports (`import type { ... }`) when importing types to avoid runtime side-effects. +- **Classes:** + - Prefer ECMAScript private fields (`#field`) over TypeScript `private` keywords for encapsulated state. + +## Testing + +- **Temporary Directories (`TEST_TMPDIR`):** + - Tests in this repository only run in Bazel. **ALWAYS** use `process.env['TEST_TMPDIR']` and assert that it is set: + ```ts + import assert from 'node:assert'; + import { mkdtemp } from 'node:fs/promises'; + import { join } from 'node:path'; + + describe('...', () => { + let tempRoot: string; + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-test-')); + }); + }); + ``` + - **NEVER** use or fallback to `os.tmpdir()`. Bazel executes tests in hermetic sandboxes and sets `TEST_TMPDIR` to an isolated, sandboxed directory. Using `os.tmpdir()` can cause sandboxing failures, permission errors, or file leakage outside the Bazel sandbox. +- **Unit & Integration Tests:** + - Run all unit and integration tests: `pnpm bazel test //packages/...` + - Run a specific unit test target (e.g., `pnpm bazel test //packages/angular/build:test`). Note that package `:test` targets may exclude builder integration tests (such as specs under `**/builders/**/tests/**`). + - Run a specific builder integration test target (e.g., `pnpm bazel test //packages/angular/build:library_integration_tests` or `pnpm bazel test //packages/angular/build:application_integration_tests`). + - Query all test targets: `pnpm bazel query "tests(//packages/...)"` + - Find the exact test target for a specific spec file (using its workspace-relative path, which Bazel resolves as a source file target): `pnpm bazel query "kind(jasmine_test, rdeps(//packages/..., ))"` + - Focus specific tests when debugging: use `fdescribe()` and `fit()`. NEVER commit focused tests to the repository. + - Run tests without sharding when isolating or debugging: use `--config=no-sharding` with a specific test target (e.g., `pnpm bazel test //packages/angular/build:library_integration_tests --config=no-sharding`). + This disables test sharding (`--test_sharding_strategy=disabled`) and flaky test retries (`--flaky_test_attempts=1`). + This is especially useful when isolating test runs or debugging with focused tests (`fit`/`fdescribe`) to avoid empty shard failures and unnecessary re-runs. + Do not use this flag when running broad test suites (such as `//packages/...`), as executing tests without sharding takes significantly longer. +- **End-to-End Tests:** + - Run subset of E2E tests: `pnpm bazel test //tests:e2e_node22 --config=e2e --test_filter=""` + +## Pull Requests + +- Use the `gh` CLI (GitHub CLI) for creating and managing pull requests. +- **Fixup Commits:** + - When addressing review feedback, **ALWAYS** use fixup commits (`git commit --fixup `) instead of amending existing commits. This preserves commit history during review and allows reviewers to easily see incremental changes. + - Only use fixup commits for changes that directly belong to the target commit. Unrelated changes must be made in a separate commit with their own commit message, not as a fixup commit. + - Fixup commits are automatically squashed when merging with `pnpm ng-dev pr merge` or optionally when rebasing with `pnpm ng-dev pr rebase `. +- Use `pnpm ng-dev pr` commands: + - `pnpm ng-dev pr rebase `: Rebase a PR branch on its target branch and optionally squash fixup commits. + - `pnpm ng-dev pr merge `: Merge an approved PR into its targeted branches. diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 000000000000..959b28996ec7 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,111 @@ +load("@aspect_rules_ts//ts:defs.bzl", rules_js_tsconfig = "ts_config") +load("@bazel_skylib//rules:common_settings.bzl", "bool_flag") +load("@devinfra//bazel/validation:defs.bzl", "validate_ts_version_matching") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "copy_to_bin") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +exports_files([ + "LICENSE", + "tsconfig.json", + "tsconfig-test.json", + "tsconfig-build-ng.json", + "tsconfig-build.json", + "package.json", + "node_modules/@angular/core/resources/best-practices.md", +]) + +npm_link_all_packages() + +rules_js_tsconfig( + name = "tsconfig", + src = "tsconfig.json", + visibility = [ + "//:__pkg__", + ], +) + +rules_js_tsconfig( + name = "build-tsconfig", + src = "tsconfig-build.json", + deps = [ + ":tsconfig", + "//:node_modules/@types/node", + ], +) + +rules_js_tsconfig( + name = "test-tsconfig", + src = "tsconfig-test.json", + deps = [ + ":tsconfig", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +rules_js_tsconfig( + name = "build-tsconfig-esm", + src = "tsconfig-build-esm.json", + deps = [ + ":tsconfig", + ], +) + +rules_js_tsconfig( + name = "test-tsconfig-esm", + src = "tsconfig-test-esm.json", + deps = [ + ":build-tsconfig-esm", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +# Files required by e2e tests +copy_to_bin( + name = "config-files", + srcs = [ + "package.json", + ], +) + +# Detect if the build is running under --stamp +config_setting( + name = "stamp", + values = {"stamp": "true"}, +) + +# If set will replace dependency versions with tarballs for packages in this repo +bool_flag( + name = "enable_package_json_tar_deps", + build_setting_default = False, +) + +config_setting( + name = "package_json_use_tar_deps", + flag_values = { + ":enable_package_json_tar_deps": "true", + }, +) + +# If set will replace dependency versions with snapshot repos for packages in this repo +bool_flag( + name = "enable_snapshot_repo_deps", + build_setting_default = False, +) + +config_setting( + name = "package_json_use_snapshot_repo_deps", + flag_values = { + ":enable_snapshot_repo_deps": "true", + }, +) + +validate_ts_version_matching( + module_bazel = "MODULE.bazel", + package_json = "package.json", +) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000000..90dd8f7be3ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19225 @@ + + +# 22.3.0-next.3 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [d69dcec31](https://github.com/angular/angular-cli/commit/d69dcec3137f0fd49e591d410c82569b3798f2ec) | fix | keep schematic positional arguments in index order | +| [21846daf3](https://github.com/angular/angular-cli/commit/21846daf3637496e5f4f86c95843300df871c4dd) | fix | only load autocompletion in interactive shells | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [1c1fe4e88](https://github.com/angular/angular-cli/commit/1c1fe4e88897f69116696a73c46f225b469aebab) | fix | apply `viewEncapsulation` to generated application components | +| [32dd079da](https://github.com/angular/angular-cli/commit/32dd079da3fd6b53fe6d57eaba0ee2bcfb8f3b08) | fix | correct error and warning messages in environments schematic | +| [9732721b0](https://github.com/angular/angular-cli/commit/9732721b0e35bd134240e1f5d316f813bd6ed9c3) | fix | migrate ng-packagr glob string assets in use-library-builder | +| [c32a690f1](https://github.com/angular/angular-cli/commit/c32a690f1e82230a695d6d349f5e7b68e6a07ffe) | fix | migrate secondary entry point style options in use-library-builder | +| [490a6fe2f](https://github.com/angular/angular-cli/commit/490a6fe2f1938ea3da5299dabf3a41dec4ef8399) | fix | show routing module suffixes in `findModule` error | +| [41b54ecdd](https://github.com/angular/angular-cli/commit/41b54ecdda17b96924d82873042c04dc7d3a4e40) | fix | skip `webWorkerTsConfig` for unit-test builder test targets | +| [b9163f3a5](https://github.com/angular/angular-cli/commit/b9163f3a559cf2c45c77dc2a50198b5cc6c3d66f) | fix | use the generated class name when declaring in an NgModule | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [5f65028b1](https://github.com/angular/angular-cli/commit/5f65028b1928bcfc95b1581f754e93a5456bda63) | feat | add `prerender.format` option to prerender routes as `<route>.html` | +| [089d39467](https://github.com/angular/angular-cli/commit/089d39467863643ecadf79cefc5c92a16b0a85d3) | fix | add integrity metadata for all initial chunks beyond the module preload limit | +| [1b893ed95](https://github.com/angular/angular-cli/commit/1b893ed95ba3bfe0690274682c6473489003dbe8) | fix | align lazy route entry names with workspace root for SSR modulepreload | +| [a38958c28](https://github.com/angular/angular-cli/commit/a38958c289663a90791e8d096963d2bd8dec802c) | fix | align library watch asset matching with initial build and deduplicate outputs | +| [3fcf6c852](https://github.com/angular/angular-cli/commit/3fcf6c85237c13484d190455521e73569ebb4191) | fix | attach location to Sass errors and guard stack parsing | +| [97efdcf91](https://github.com/angular/angular-cli/commit/97efdcf9136230baeb9ec757dcd24a17f12cd223) | fix | consolidate initial shared chunks into main with Rolldown | +| [13a734649](https://github.com/angular/angular-cli/commit/13a73464935365ed48bf30ea520bcc4c84828b9e) | fix | correct typos and spacing in error and warning messages | +| [566abde88](https://github.com/angular/angular-cli/commit/566abde8866d0512f89953dc5b6a0aae6ef63db8) | fix | enforce library compilerOptions and skip unused .tsbuildinfo emit | +| [f696eaaf8](https://github.com/angular/angular-cli/commit/f696eaaf8a233c83981c5de01c8568074c575edd) | fix | improve library watch mode state invalidation and lazy asset matchers | +| [adcdcc668](https://github.com/angular/angular-cli/commit/adcdcc6688fd773491dcb9f5197eefffd4aeee45) | fix | invalidate Vite module cache for removed dev server output files | +| [f9f386497](https://github.com/angular/angular-cli/commit/f9f3864974e90c6ea78c53cc4a0cab3f2e4477dc) | fix | match proxy glob patterns against URL pathname only | +| [f9325ab02](https://github.com/angular/angular-cli/commit/f9325ab02da065ea5b4cf81fb5f83ba56de5c50d) | fix | name the provider packages in the unit-test `browsers` error | +| [193abc0c1](https://github.com/angular/angular-cli/commit/193abc0c15621f28b8d12866e22711138c3339f6) | fix | preserve asset precedence and expand glob-prefixed ignores in watch mode | +| [cd70beb20](https://github.com/angular/angular-cli/commit/cd70beb20db711cb178afccf6c688f9475f9e1f9) | fix | preserve diagnostic locations at offset 0 and guard empty files | +| [faca2b0f0](https://github.com/angular/angular-cli/commit/faca2b0f035e22fff421d353d6b51af1f6aec9d6) | fix | preserve entry-point displayName casing and flatten bundle names | +| [137351b7d](https://github.com/angular/angular-cli/commit/137351b7de55c899cea9108554777e020de52851) | fix | preserve incremental .d.ts signatures and emit state in watch mode | +| [c3881e9eb](https://github.com/angular/angular-cli/commit/c3881e9eba39436faed4879342de9fd9541ea207) | fix | report TypeScript declaration emit diagnostics | +| [b8180a653](https://github.com/angular/angular-cli/commit/b8180a6530cfb7a39515de016a5386ec48ef1fdb) | fix | support incremental asset updates and output cleanup in library watch mode | +| [e3787076e](https://github.com/angular/angular-cli/commit/e3787076ebca0af72079f669b5b047a065b16128) | fix | support quotes in `styles` and `polyfills` paths | +| [8c1498395](https://github.com/angular/angular-cli/commit/8c1498395a3576f397581a4629dfd15e83cae819) | fix | use component file path in inline stylesheet diagnostic locations | +| [e93d9cb8b](https://github.com/angular/angular-cli/commit/e93d9cb8bb8d9902bbe437e9dece8f44cb859bfe) | fix | use distinct chunk naming for web worker bundles | +| [58c30c05f](https://github.com/angular/angular-cli/commit/58c30c05f6f60d3a985921f411354705f12f611d) | fix | use isSubDirectory in normalizeAssetPatterns and ignore nested .gitkeep | +| [9e998c128](https://github.com/angular/angular-cli/commit/9e998c12862c2bd7a70a088ea6758aefb23a698e) | fix | validate library outputPath and avoid duplicate secondary README assets | +| [972302ac3](https://github.com/angular/angular-cli/commit/972302ac3dc7b675e4b36dac465b876014a02b1e) | fix | watch only explicitly added paths in chokidar watcher | +| [5691298d0](https://github.com/angular/angular-cli/commit/5691298d035b8d965d73d3a2282436ada441ec90) | perf | avoid reading external component stylesheets twice | +| [6bae97bd4](https://github.com/angular/angular-cli/commit/6bae97bd498ac602e71e33daa8aa0f57a37f7347) | perf | avoid TS AST eviction and d.ts re-emit on external resource edits | +| [751adc3ab](https://github.com/angular/angular-cli/commit/751adc3abc664616dea333667e60910916c6334b) | perf | cache pre-declaration transformed SourceFiles during AoT emit | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| [8aa16b6f9](https://github.com/angular/angular-cli/commit/8aa16b6f9fe5bb1301438bfb4625a7b6c68a5392) | fix | resolve query parameter placeholders in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [97ed36ec4](https://github.com/angular/angular-cli/commit/97ed36ec4f0423258cb949769c2fb6557054daa3) | fix | resolve route parameters in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [b42cfeecf](https://github.com/angular/angular-cli/commit/b42cfeecfac376d1ed365ffbf9d247dbfc2bde59) | fix | use only the first value of the `X-Forwarded-Prefix` header | + + + + + +# 21.2.28 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [225d00adb4](https://github.com/angular/angular-cli/commit/225d00adb469a23a8b14bb33dff9a14595f8ca8c) | fix | bump @modelcontextprotocol/sdk to 1.32.1 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [651d1d293d](https://github.com/angular/angular-cli/commit/651d1d293dd47454d76f2f7ddbc70f7a5607366b) | fix | update webpack to 5.112.0 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [a081d8b6db](https://github.com/angular/angular-cli/commit/a081d8b6dbef26d33cb22b4b40c9a843a2b87047) | fix | bump vite to 7.3.7 | + + + + + +# 20.3.41 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [36fbb892b4](https://github.com/angular/angular-cli/commit/36fbb892b48f6313de1946cf32bc6fb4140eb959) | fix | bump @modelcontextprotocol/sdk to 1.32.1 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [f905af981b](https://github.com/angular/angular-cli/commit/f905af981bb1f812944dfa31a623b7fcbb08f8e9) | fix | update webpack to 5.112.0 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [a1e3016f1e](https://github.com/angular/angular-cli/commit/a1e3016f1efe65b49b9d6affd13e6fe1d95607e4) | fix | bump vite to 7.3.7 | + + + + + +# 22.2.4 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [63e45bcfe](https://github.com/angular/angular-cli/commit/63e45bcfe711b6e2320db7ee24caba898bb90347) | fix | keep schematic positional arguments in index order | +| [6053771cb](https://github.com/angular/angular-cli/commit/6053771cba3c29a481d21ea6a2f2b9dd279b6db2) | fix | only load autocompletion in interactive shells | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [eec8dc049](https://github.com/angular/angular-cli/commit/eec8dc049a21e8ee685dd07ed365e43d04802cf5) | fix | apply `viewEncapsulation` to generated application components | +| [3a549925e](https://github.com/angular/angular-cli/commit/3a549925e2adf2101ae635da1e4a9cf0217c13d2) | fix | correct error and warning messages in environments schematic | +| [fe119a7c7](https://github.com/angular/angular-cli/commit/fe119a7c717db7a50d92073dd8d3a2f4726bbd6f) | fix | show routing module suffixes in `findModule` error | +| [36d988001](https://github.com/angular/angular-cli/commit/36d988001e1d36d02a759f2580bd251fff175e07) | fix | skip `webWorkerTsConfig` for unit-test builder test targets | +| [460167cd3](https://github.com/angular/angular-cli/commit/460167cd335b6b4addb9c92d60f56a04ddf9ed5e) | fix | use the generated class name when declaring in an NgModule | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [c235a9554](https://github.com/angular/angular-cli/commit/c235a9554fb1f5cad734a3fb2df7c4f7e4283f91) | fix | add integrity metadata for all initial chunks beyond the module preload limit | +| [80c7480b4](https://github.com/angular/angular-cli/commit/80c7480b4d574ef2cd178e315288d9bfe821a5ac) | fix | align lazy route entry names with workspace root for SSR modulepreload | +| [b94627706](https://github.com/angular/angular-cli/commit/b9462770643465bd1bf33bc7cdaf2386d0bf9d41) | fix | attach location to Sass errors and guard stack parsing | +| [0dc103735](https://github.com/angular/angular-cli/commit/0dc103735e9cd42007a0b81cf1c2a563f80b94e6) | fix | correct typos and spacing in error and warning messages | +| [3e464c3f5](https://github.com/angular/angular-cli/commit/3e464c3f55d4aa2b7f1cb3d62ee3104c62029a5e) | fix | invalidate Vite module cache for removed dev server output files | +| [7d9ba6280](https://github.com/angular/angular-cli/commit/7d9ba628034866a2ec51a6357f05c9ce426cbb76) | fix | match proxy glob patterns against URL pathname only | +| [3aa7e93cc](https://github.com/angular/angular-cli/commit/3aa7e93ccf898d84d620ff0b46e5a718fa9c3a90) | fix | name the provider packages in the unit-test `browsers` error | +| [e78cf0ae9](https://github.com/angular/angular-cli/commit/e78cf0ae9676e77245de3cae7ae86b42a48a6322) | fix | preserve diagnostic locations at offset 0 and guard empty files | +| [b3baa20c6](https://github.com/angular/angular-cli/commit/b3baa20c6cb5b00c9df4d3d4d072435b324566e2) | fix | support quotes in `styles` and `polyfills` paths | +| [648aff9eb](https://github.com/angular/angular-cli/commit/648aff9ebb54735fab5cc4265200a06c4d531f35) | fix | use component file path in inline stylesheet diagnostic locations | +| [a9bec13b5](https://github.com/angular/angular-cli/commit/a9bec13b58550fc757612c3617aa33eefb776041) | fix | use distinct chunk naming for web worker bundles | +| [a05723948](https://github.com/angular/angular-cli/commit/a057239486e42ec7189f03a61b09e3b0761150c9) | fix | watch only explicitly added paths in chokidar watcher | +| [35702a598](https://github.com/angular/angular-cli/commit/35702a5986dc7ea4f2f2d7f18ef9f8f3b4fd33c2) | perf | avoid reading external component stylesheets twice | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| [3beb2551c](https://github.com/angular/angular-cli/commit/3beb2551cc784ed0714070bbd23c1a8bb70e2938) | fix | resolve query parameter placeholders in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [ff3f3727a](https://github.com/angular/angular-cli/commit/ff3f3727a9eb18f0b7714646a04b7d3e172c18d7) | fix | resolve route parameters in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [d365b0008](https://github.com/angular/angular-cli/commit/d365b0008f0c5bb8bca92e83066999253f409b64) | fix | use only the first value of the `X-Forwarded-Prefix` header | + + + + + +# 22.3.0-next.2 (2026-10-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [5c656152e](https://github.com/angular/angular-cli/commit/5c656152ef91e2731155f6027a845d8014b53e4f) | fix | accept multi-digit array indices in ng config | +| [2e0f48e3f](https://github.com/angular/angular-cli/commit/2e0f48e3ff40289725d3590fa8e1fc059fbaeb88) | fix | keep ng version working when the package manager is not installed | +| [a1cf33bc4](https://github.com/angular/angular-cli/commit/a1cf33bc45882fb74578cc7d1faeba8cecec7b73) | fix | resolve package name in npm workspace members | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [639d31f11](https://github.com/angular/angular-cli/commit/639d31f11b102a12776ae8dce6369d6edd1b6ff3) | feat | add use-library-builder migration to migrate from ng-packagr | +| [445cc767e](https://github.com/angular/angular-cli/commit/445cc767e62b72631dd53c0c5324b458fee76608) | fix | keep the workspace build version in the use-application-builder migration | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [b39a5fcda](https://github.com/angular/angular-cli/commit/b39a5fcdac288e317f3b00468c09173a1d306f98) | fix | avoid stack overflow when chaining many rules | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| [b975da4b0](https://github.com/angular/angular-cli/commit/b975da4b016eaa107acc259eb8205135d9396f95) | fix | add CSP nonce to stylesheet and modulepreload link elements | +| [13923047f](https://github.com/angular/angular-cli/commit/13923047f64b6e1063f7f07029c70295b5c99d69) | fix | always use AOT when extracting i18n messages | +| [d2233e225](https://github.com/angular/angular-cli/commit/d2233e225aa5fd507e3ce7bfd35ae2268c036aa4) | fix | coalesce external watcher directories to avoid macOS FSEvents limit | +| [23d42fc80](https://github.com/angular/angular-cli/commit/23d42fc806f174c7da961c35e07a42aa1e00d1e7) | fix | conditionally declare tslib in built library package | +| [f0a74e8f0](https://github.com/angular/angular-cli/commit/f0a74e8f0ea04fd3f839be0120346067440b1350) | fix | disable Vitest fsModuleCache in unit-test builder | +| [2c518a6db](https://github.com/angular/angular-cli/commit/2c518a6dbe8734d247dfe4eb2c1c4af5e169fa94) | fix | enhance build logs and output location for library builder | +| [47c328d3e](https://github.com/angular/angular-cli/commit/47c328d3ef7b409af02f270a48f5e99a520fbe14) | fix | ensure Sass importers return global Promise instances | +| [432aea016](https://github.com/angular/angular-cli/commit/432aea0161840bc135704c040108e46092e61c27) | fix | filter Rolldown warnings and disable .d.ts annotation comments in library builder | +| [39d230e18](https://github.com/angular/angular-cli/commit/39d230e1862f2f626d3fba03bbf51873f3578153) | fix | keep Vite server file watcher disabled in Vitest watch mode | +| [93f238c97](https://github.com/angular/angular-cli/commit/93f238c9781897997b1c64d607f3bb3c91ba1fd1) | fix | rebuild library when transitive stylesheet dependencies change in watch mode | +| [7714a8537](https://github.com/angular/angular-cli/commit/7714a8537589afc6180443cc36acd5149a459c57) | fix | resolve unimported NgModule scopes when filtering unit-test files | +| [ad4845583](https://github.com/angular/angular-cli/commit/ad4845583b8d1b8de26ccc0e45b4e6e58d95b362) | fix | respect tsconfig declarationMap and compilationMode in library builder | +| [01afbf418](https://github.com/angular/angular-cli/commit/01afbf418cf6a7688f3f131988eeba3cdac2bde4) | fix | sort discovered unit-test files for deterministic builds | +| [b640b32e1](https://github.com/angular/angular-cli/commit/b640b32e148ab68f820b390b1d9f03ae9a809266) | fix | use module.registerHooks() for in-memory ESM loader | +| [0a077eeed](https://github.com/angular/angular-cli/commit/0a077eeed92b43fa195bf10f479d39a7aca2d3f7) | perf | avoid invalidating Angular diagnostic cache on stylesheet change | +| [2b5a9a620](https://github.com/angular/angular-cli/commit/2b5a9a620495b4c85bf5cfa3db39b632d88e6ca5) | perf | enable TypeScript incremental build cache in library builder | +| [07845f019](https://github.com/angular/angular-cli/commit/07845f019cf7d70c17f037d711b430a776f96031) | perf | optimize incremental watch rebuilds in AOT compilation | +| [fb7efc862](https://github.com/angular/angular-cli/commit/fb7efc86228164330f68ed86801f9d5bbe9ad5b0) | perf | resolve asset patterns concurrently with bounded concurrency | +| [dcf906a62](https://github.com/angular/angular-cli/commit/dcf906a6261a00f95cfbdb18cf9065e1c966e182) | perf | reuse extendedConfigCache in TypeScript compilation | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [0b9a52cc9](https://github.com/angular/angular-cli/commit/0b9a52cc9516d28afa0d9ef0d07ec745967faf56) | fix | preserve preload metadata for nested empty-path routes | +| [f2cd8c8cf](https://github.com/angular/angular-cli/commit/f2cd8c8cf7c5aa7e7076c87e9c0c55b3bf880f4b) | fix | preserve query parameters on relative route redirects | +| [b3bbada1e](https://github.com/angular/angular-cli/commit/b3bbada1efd0d3a414cce8843b7284e9560f66fc) | fix | resolve relative redirects against the parent route | + + + + + +# 22.2.3 (2026-10-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [3158e865f](https://github.com/angular/angular-cli/commit/3158e865ffdd828711d645dff16cc17fd26bb58e) | fix | accept multi-digit array indices in ng config | +| [bcff0e76a](https://github.com/angular/angular-cli/commit/bcff0e76adf73cfa32b02c5256490189675cb132) | fix | keep ng version working when the package manager is not installed | +| [5570c91d2](https://github.com/angular/angular-cli/commit/5570c91d2f38764cd841fcce146f1d13fef49b0c) | fix | resolve package name in npm workspace members | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [881865f36](https://github.com/angular/angular-cli/commit/881865f364b33b36630aa6ab7a129f7ed665cfa7) | fix | keep the workspace build version in the use-application-builder migration | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cf1f3b9d4](https://github.com/angular/angular-cli/commit/cf1f3b9d43177e15cf9a00973e6da3baeaf4b2dd) | fix | avoid stack overflow when chaining many rules | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [1d477d6bc](https://github.com/angular/angular-cli/commit/1d477d6bc27159cb0c78288e047fcadfe6fc31d4) | fix | add CSP nonce to stylesheet and modulepreload link elements | +| [fbe208f83](https://github.com/angular/angular-cli/commit/fbe208f8392ec0ce82d597e8b690f146370bdc81) | fix | always use AOT when extracting i18n messages | +| [c1c4216a6](https://github.com/angular/angular-cli/commit/c1c4216a680891b80670660a621d6736c4aecaf0) | fix | coalesce external watcher directories to avoid macOS FSEvents limit | +| [792990bc2](https://github.com/angular/angular-cli/commit/792990bc2eacdcc8ed4de6ef46e58ceeb5fe8741) | fix | disable Vitest fsModuleCache in unit-test builder | +| [82439399c](https://github.com/angular/angular-cli/commit/82439399cbfb4c1d21d1c80d976021efa9e4a39a) | fix | ensure Sass importers return global Promise instances | +| [a818de415](https://github.com/angular/angular-cli/commit/a818de415e933819e7a2e3e679cd0b2d2be734de) | fix | keep Vite server file watcher disabled in Vitest watch mode | +| [91bad469e](https://github.com/angular/angular-cli/commit/91bad469ec5ce7b2dfd07e394b482d2cef411407) | fix | sort discovered unit-test files for deterministic builds | +| [e84db4af7](https://github.com/angular/angular-cli/commit/e84db4af70ca9be490b462e6d8112524dc8eb6d4) | fix | use module.registerHooks() for in-memory ESM loader | +| [a428021b7](https://github.com/angular/angular-cli/commit/a428021b77dbccf404f39712f1c88bed9b8e4272) | perf | avoid invalidating Angular diagnostic cache on stylesheet change | +| [b0ac394a0](https://github.com/angular/angular-cli/commit/b0ac394a063f084d885df67660224b6a25e2c614) | perf | resolve asset patterns concurrently with bounded concurrency | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [bd09b98fc](https://github.com/angular/angular-cli/commit/bd09b98fcaff4b76c7fd65999b51bfaa942d1858) | fix | preserve preload metadata for nested empty-path routes | +| [a92546892](https://github.com/angular/angular-cli/commit/a925468929c74c30a3e64b361a6dc5d6fdc1006a) | fix | preserve query parameters on relative route redirects | +| [a2b86cc94](https://github.com/angular/angular-cli/commit/a2b86cc94d408ba4c3d422224b58e26b7a68f692) | fix | resolve relative redirects against the parent route | + + + + + +# 21.2.27 (2026-10-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------- | +| [2e1742adf](https://github.com/angular/angular-cli/commit/2e1742adf28645635bb6cff3cc08ad9c94c7d085) | fix | update zod to 4.6.6 | + + + + + +# 20.3.40 (2026-10-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------- | +| [bfe1908f4](https://github.com/angular/angular-cli/commit/bfe1908f46f5799e839dcf0f162955df59a94cb0) | fix | update zod to 4.6.6 | + + + + + +# 22.3.0-next.1 (2026-10-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [3379a13314](https://github.com/angular/angular-cli/commit/3379a133148825a6ecb5eeacafdd6bcfd3c8e50b) | fix | preserve Host header when proxying in ssr-dev-server | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [4244eb7ba1](https://github.com/angular/angular-cli/commit/4244eb7ba1be5371dc8eab0c8ee07a8269ce09aa) | fix | allow library outputPath within project root | +| [f61de66202](https://github.com/angular/angular-cli/commit/f61de6620271d71bb7fc051d0ea03426bfa2af5b) | fix | capture and report Rolldown warnings and errors in library builder | +| [3871a9da91](https://github.com/angular/angular-cli/commit/3871a9da9106aefc4b10095d4716e30db09fd864) | fix | keep the order of glob entries in the proxy configuration | +| [3d00c76eb9](https://github.com/angular/angular-cli/commit/3d00c76eb9c5ea38d1ede08e65ed0ea22f6f0232) | fix | resolve library bundle sourcemap sources relative to output directory | +| [5ffed3f3b6](https://github.com/angular/angular-cli/commit/5ffed3f3b6b39583cda1e35854cc001344ed6eba) | fix | scope cache directory by project and builder name | +| [1e45d4d46a](https://github.com/angular/angular-cli/commit/1e45d4d46ad79ad890fe973b5fe697664826cc2d) | fix | support a string `context` in array-form proxy configurations | +| [2ffbebf24c](https://github.com/angular/angular-cli/commit/2ffbebf24c0b12c14490f98a1df0448031bd105d) | perf | immediately purge freed rolldown arena memory | + + + + + +# 22.2.2 (2026-10-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [cfe970c8bc](https://github.com/angular/angular-cli/commit/cfe970c8bcfbc47e9fa1fa6b17e41cdf1d3b30bf) | fix | preserve Host header when proxying in ssr-dev-server | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [405bfa370a](https://github.com/angular/angular-cli/commit/405bfa370ac3c8f14808d58cfc707b7ff23348c4) | fix | keep the order of glob entries in the proxy configuration | +| [996e47f809](https://github.com/angular/angular-cli/commit/996e47f809a3172486df665bfce6d5c11e652154) | fix | support a string `context` in array-form proxy configurations | + + + + + +# 21.2.26 (2026-10-07) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [9d2987f354](https://github.com/angular/angular-cli/commit/9d2987f354df7718ec76992cf28a0ab6cbf96539) | fix | bump @modelcontextprotocol/sdk to 1.31.0 | + + + + + +# 20.3.39 (2026-10-07) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [373a7cedce](https://github.com/angular/angular-cli/commit/373a7cedce59662f6db6f5361192b6158377e01d) | fix | bump @modelcontextprotocol/sdk to 1.31.0 | + + + + + +# 21.2.25 (2026-10-05) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [7e6d2da084](https://github.com/angular/angular-cli/commit/7e6d2da08406f240b18b1d7e692cad27f29af314) | fix | bump piscina to 5.3.2 | + + + + + +# 20.3.38 (2026-10-05) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [3e39e33fb1](https://github.com/angular/angular-cli/commit/3e39e33fb1422af144ea795d871969640a27627b) | fix | bump piscina to 5.3.2 | + + + + + +# 22.3.0-next.0 (2026-10-01) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f52fb536a1](https://github.com/angular/angular-cli/commit/f52fb536a1fddabee42448a050961c7986314992) | fix | decouple official Node.js support check from execution gate | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [c5b8bf0369](https://github.com/angular/angular-cli/commit/c5b8bf036965f31c283b1d14bd02b31bdfc02353) | feat | add library builder | +| [feb41cc934](https://github.com/angular/angular-cli/commit/feb41cc9341ff001a67ffc22ac9816aba8750e65) | feat | add polyfills option to unit-test builder | +| [d09f98ee70](https://github.com/angular/angular-cli/commit/d09f98ee701754bdb4eca7d56dcf506c8d8b6729) | fix | avoid top-level await for Zone.js injection in Vitest runner | +| [a0a6b422cd](https://github.com/angular/angular-cli/commit/a0a6b422cdc3ce4e79f5b8eaccf1e9ed82579afe) | fix | ensure asset followSymlinks defaults to false | +| [69acf5b822](https://github.com/angular/angular-cli/commit/69acf5b822bebc0b4bebd30f1db6cc7b8d624531) | fix | include dataurl option in global stylesheet config hash | +| [76dd17d522](https://github.com/angular/angular-cli/commit/76dd17d522751b91ff46d42c1eb11bb0f5cb1be9) | fix | re-analyze exports on package.json change in library builder | + + + + + +# 22.2.1 (2026-10-01) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [db053444e0](https://github.com/angular/angular-cli/commit/db053444e0af22223100f931def9f41d1eea4426) | fix | skip components with a change detection strategy in the zoneless migration tool | + +### @schematics/angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [663c580c86](https://github.com/angular/angular-cli/commit/663c580c86645b5b3a31925154427588e48018ba) | fix | add browsers option to angular.json during vitest browser provider ng-add | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ | +| [e6350b6f57](https://github.com/angular/angular-cli/commit/e6350b6f57a546f77d38515604d1c1fa46d85d39) | fix | correctly parse inline boolean values in CLI options ([#34201](https://github.com/angular/angular-cli/pull/34201)) | + +### @angular-devkit/architect + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ | +| [516df4ee6d](https://github.com/angular/angular-cli/commit/516df4ee6d1d07ad8b851ff108595d08ef4d319b) | fix | correctly parse inline boolean values in CLI options ([#34201](https://github.com/angular/angular-cli/pull/34201)) | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [ac70f86208](https://github.com/angular/angular-cli/commit/ac70f862086343d2d9a36fd7b09b3a3680e907b0) | fix | do not mark standalone expression statements as pure | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------- | +| [ec9c0b51c2](https://github.com/angular/angular-cli/commit/ec9c0b51c24fd0fba8a7f01dbb0ff25cf520a0d5) | fix | deduplicate spurious chokidar polling change events ([#34188](https://github.com/angular/angular-cli/pull/34188)) | +| [c1b5154180](https://github.com/angular/angular-cli/commit/c1b515418033685a9624140b5eecb6330ec07504) | fix | encode script type in auto-CSP loader | +| [cb4dd71a1b](https://github.com/angular/angular-cli/commit/cb4dd71a1bbf9d7a8273061c54a8997cb725e9e3) | fix | ensure @angular/compiler is loaded for Vitest TestBed initialization | +| [b198044048](https://github.com/angular/angular-cli/commit/b1980440484d809f89e54d04bdeb484ff2db6aae) | fix | preserve nested workspace path for git worktree cache | +| [5194b41531](https://github.com/angular/angular-cli/commit/5194b415314a4fb2435b29dcf42ccffbc26070a9) | fix | prevent chokidar from scanning unimported node_modules in watch mode | +| [37cf399d95](https://github.com/angular/angular-cli/commit/37cf399d95d9376811cd79144bbd517f3efe3d32) | fix | reset component updates on dev-server SSR page request | +| [ddff42cb03](https://github.com/angular/angular-cli/commit/ddff42cb03cd45f1e668794057843f8a98501c0a) | fix | serve emitted HTML assets in dev-server | +| [e3e893682d](https://github.com/angular/angular-cli/commit/e3e893682d501d3135bf3abcb1c74b9598f78017) | fix | watchers outside the workspace via parcel didn't work | +| [d55e899954](https://github.com/angular/angular-cli/commit/d55e899954e677c9d0751ab8e56b0364bf3a3098) | perf | avoid loading compiler-cli and typescript in js transform worker | +| [3d08e5b053](https://github.com/angular/angular-cli/commit/3d08e5b053d50f9101215974039aafcc39f6a9e9) | perf | avoid sharding intermediate files under worker surplus in i18n inliner | +| [5ea36fe8b8](https://github.com/angular/angular-cli/commit/5ea36fe8b8b9732054a3749912018bae71e3b746) | perf | bound aggregate worker allocation across dominant files in i18n inliner | +| [90ee193bd1](https://github.com/angular/angular-cli/commit/90ee193bd188629da762e2f98cb664d8493ed733) | perf | bound concurrent worker allocation for dominant files in i18n inliner | +| [41b353856b](https://github.com/angular/angular-cli/commit/41b353856ba0babc03907701508d7a2b45c96743) | perf | process batch locales sequentially in i18n inliner worker | + + + + + +# 22.2.0 (2026-09-23) + +## Deprecations + +### @angular/build + +- The `splitting` option in the unit-test builder is deprecated as it is no longer needed with Vitest 5. + + Disabling code splitting is no longer necessary with Vitest 5, as the issue where shared-module exports were uninitialized in class-field initializers under JSDOM has been resolved. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [41555dfb3](https://github.com/angular/angular-cli/commit/41555dfb3b71d08cdfe2853bf2cbeca5b6942f67) | feat | add `--root` command line option to `mcp` command | +| [6e7f809a7](https://github.com/angular/angular-cli/commit/6e7f809a7d66e4b8d260a1c00297812412a926f1) | perf | skip eager yargs help message formatting during command execution | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------- | +| [ad8271320](https://github.com/angular/angular-cli/commit/ad8271320f67c2b1502e446f1e3cb0a05444d04f) | feat | migrate Angular Linker to oxc-parser and magic-string | +| [175273931](https://github.com/angular/angular-cli/commit/175273931ebb0cf08bf62dd1694dbde5ff5229b6) | feat | Support splitting browser and server stats jsonfiles for easier consumption | +| [fe2f180fd](https://github.com/angular/angular-cli/commit/fe2f180fd0c2fa02706353ca6a034d235feb6c59) | fix | add automatic corruption recovery in SQLite cache store | +| [675f1f527](https://github.com/angular/angular-cli/commit/675f1f5274ff853f153832f7adc69273fa63f0cd) | fix | avoid pure comment pattern in oxc transform | +| [d396324b4](https://github.com/angular/angular-cli/commit/d396324b4eb3b16640526a64365d953642bc7ca1) | fix | default preloadInitial to false when serviceWorker is enabled | +| [7e5510e46](https://github.com/angular/angular-cli/commit/7e5510e464eb423d8757ae6f058f73fc17249d81) | fix | disable linker internal source mapping in OXC linker | +| [8504cfffc](https://github.com/angular/angular-cli/commit/8504cfffcfb943fcd8fda62507e4b236e4bf429a) | fix | disable root config extension for inline test project | +| [ee3041325](https://github.com/angular/angular-cli/commit/ee3041325cd2400c26e239e64e14a0e314ab6031) | fix | ensure chokidar watcher is ready before returning | +| [54d712f57](https://github.com/angular/angular-cli/commit/54d712f57904d2f612eec27a0507ea298b40915e) | fix | ensure parent directory exists in SQLite cache store | +| [80acd6236](https://github.com/angular/angular-cli/commit/80acd6236eac6b1b07543583717e94cbd1de1180) | fix | execute setup file hooks for each spec file when coverage is enabled | +| [978351af7](https://github.com/angular/angular-cli/commit/978351af71889d4786a18fec9e193eb5b16af7dd) | fix | include inline component stylesheets in referenced watch files | +| [f47f77f5f](https://github.com/angular/angular-cli/commit/f47f77f5f6db5cda1493652f813c98c26f172ea9) | fix | only compile included test files in unit-test builder | +| [72dc9677c](https://github.com/angular/angular-cli/commit/72dc9677cf743243d6cb22d2b81782022f71356e) | fix | pass load cache to compiler plugin and escape extension regex in polyfills | +| [701bde481](https://github.com/angular/angular-cli/commit/701bde4818439de0e49c99d57dc749b0dd22c4dd) | fix | preserve duplicate bundle filenames across output file types | +| [e4111ae81](https://github.com/angular/angular-cli/commit/e4111ae8180f3cd5800f248c23010844aa1730ad) | fix | prevent memory leaks in parallel compilation and caches | +| [31c045639](https://github.com/angular/angular-cli/commit/31c045639e8bfd114cbd552d83cc70a968fe7f81) | fix | prevent stale bundler caching and correctly resolve load cache | +| [bad31421c](https://github.com/angular/angular-cli/commit/bad31421cbf23339d4fbd389a91d198df405697d) | fix | prevent syntax corruption in linker and oxc transform | +| [0d9851600](https://github.com/angular/angular-cli/commit/0d9851600ca618ccabfc3143a9c552a5448d6eec) | fix | recursively ignore output and cache paths in watch mode | +| [040bc21ec](https://github.com/angular/angular-cli/commit/040bc21ec3d4fa1bdfa8513206f463d89e20d9a1) | fix | scope Sass package resolution caching for stylesheets in node_modules | +| [1fc273279](https://github.com/angular/angular-cli/commit/1fc2732795069ad521645b06a94e5df3a55c3d9c) | fix | support parenthesized expressions in oxc linker | +| [b423bc39c](https://github.com/angular/angular-cli/commit/b423bc39cd59c73ae79402c6cccdc0ecf1d7aaa1) | fix | use chokidar watcher when followSymlinks is enabled | +| [87551ad5c](https://github.com/angular/angular-cli/commit/87551ad5c53f90cbdc68fdb5e47007b9436c7f75) | perf | avoid encoding intermediate source maps before remapping | +| [6ee559ca9](https://github.com/angular/angular-cli/commit/6ee559ca9d209fb4c53de2aaf9918d10e5f062a1) | perf | bypass worker dispatch for files without transform candidates | +| [0589edd9c](https://github.com/angular/angular-cli/commit/0589edd9cdc493ecf34a160cc7b8491618b59f72) | perf | consolidate component stylesheet bundling with shared load result cache | +| [482f6d8aa](https://github.com/angular/angular-cli/commit/482f6d8aab289fbd6dc5f9aecdcf7b8e1b6cac4f) | perf | decouple diagnostic type checking from build start | +| [a877aa7eb](https://github.com/angular/angular-cli/commit/a877aa7ebdbf919d6bc17e6ec0920ca702db0db4) | perf | implement SharedArrayBuffer translation dictionaries for zero-copy worker access | +| [f1cc95943](https://github.com/angular/angular-cli/commit/f1cc959439d8ca11a45d693520c34dd94e0b6548) | perf | implement sliding-window batching and worker translation eviction | +| [d97c8857c](https://github.com/angular/angular-cli/commit/d97c8857c18da02fcc6c69a0ea652cc706c5c9b6) | perf | key Sass package resolutions without containing URL qualification | +| [5cd20e141](https://github.com/angular/angular-cli/commit/5cd20e141ad176c87e4002da2d4ee3755f97cd3e) | perf | only reset Sass worker pool caches when stylesheet files change | +| [1c00edce0](https://github.com/angular/angular-cli/commit/1c00edce02bdd0201546417d71518f78bfb89d34) | perf | optimize sourcemap stripping and loading with buffer fast path | +| [0ba4d8e29](https://github.com/angular/angular-cli/commit/0ba4d8e29c0fb9abe6b07432edfab9e39066ca29) | perf | reduce watcher debounce latency for faster incremental rebuilds | +| [a6ef9cfbe](https://github.com/angular/angular-cli/commit/a6ef9cfbeace725d58c0f7f65640ef6de9b39c33) | perf | replace watchpack with @parcel/watcher and chokidar | +| [4d11a1334](https://github.com/angular/angular-cli/commit/4d11a1334ff5fabfe5338cc3de5ce4f45f6afa5f) | perf | selectively compress large entries in SQLite cache store | +| [0a137f98d](https://github.com/angular/angular-cli/commit/0a137f98d035f3dbd9a5daf1b6999246f73610c8) | perf | share sass directory and resolution caches across stylesheets | +| [f4dad31cc](https://github.com/angular/angular-cli/commit/f4dad31cce41bc3e8ed8767256d9e50a712c3a72) | perf | unify Oxc linking and optimization AST traversal passes | +| [2b85529e8](https://github.com/angular/angular-cli/commit/2b85529e8290c8c916539b5a0336b4efdb68578c) | perf | use iterative post-order AST traversal for OXC transforms | +| [ecbcd87b8](https://github.com/angular/angular-cli/commit/ecbcd87b8857225e4df3df7896b3236d63553f23) | perf | use native sass-embedded compiler daemon | +| [ce83ac0e8](https://github.com/angular/angular-cli/commit/ce83ac0e82981efd9408e07a35315e3cef9e1a36) | perf | use size-weighted task heuristics in i18n inliner | +| [d11a663e7](https://github.com/angular/angular-cli/commit/d11a663e77dad3dc9dbb6336e782f23b8a6f595c) | perf | verify cache metadata before reading file and eliminate sqlite read-locks | +| [66bd6bd1e](https://github.com/angular/angular-cli/commit/66bd6bd1eab41484ed5db38f5545e80de4090b34) | refactor | deprecate splitting option in unit-test builder | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [23e3d44a7](https://github.com/angular/angular-cli/commit/23e3d44a7f051cd3bb67700b8d8407f73b7aa7f3) | perf | pre-compile critical CSS plans at build time | + + + + + +# 22.1.9 (2026-09-23) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [b94dabdb7](https://github.com/angular/angular-cli/commit/b94dabdb744ff1fe72b79bfbda5f5c8749c58f71) | fix | update @types/node to a version vitest 5 accepts | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [5aaad0a13](https://github.com/angular/angular-cli/commit/5aaad0a13a3dc65b07347dde313ac9ae2aa013ac) | fix | name the unknown option in a schema validation error | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [bfec8bac4](https://github.com/angular/angular-cli/commit/bfec8bac4d6185efa768b4387ed9e8926979954f) | fix | support standard JavaScript MIME types and case insensitivity in auto-CSP | + + + + + +# 21.2.24 (2026-09-10) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [dbaadc2f6e](https://github.com/angular/angular-cli/commit/dbaadc2f6e73b611e292b3337066fd5b3b631397) | fix | update undici to 7.29.1 | + + + + + +# 20.3.37 (2026-09-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [c37642011e](https://github.com/angular/angular-cli/commit/c37642011e6d8f7656a2bbc61feb79cae7d353c4) | fix | update undici to 7.29.1 | + + + + + +# 22.1.8 (2026-09-10) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------- | +| [a6e30fa378](https://github.com/angular/angular-cli/commit/a6e30fa378ef5f03b26cd2fcafff6bff2c47da80) | fix | disable strictPort when port 0 is used in dev-server | +| [35a5d4aa49](https://github.com/angular/angular-cli/commit/35a5d4aa49011f83f58cdd30c3ae24a55bf0651c) | perf | avoid full JSON parsing when updating sourcemap ignore list | +| [48cc43cbf2](https://github.com/angular/angular-cli/commit/48cc43cbf204c887b344b773045b5460265eaa14) | perf | read rendered module length once per module in chunk optimizer ([#34045](https://github.com/angular/angular-cli/pull/34045)) | + + + + + +# 22.1.7 (2026-09-02) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e9db85c0f](https://github.com/angular/angular-cli/commit/e9db85c0f5ef0aa0c67d2b8d90b872307af39822) | fix | isolate temporary package installations from parent Yarn workspace | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [1c9b1af61](https://github.com/angular/angular-cli/commit/1c9b1af61115764e0be55f5a1ed9a9967c191a74) | fix | add splitting option to unit-test builder | +| [4b861f458](https://github.com/angular/angular-cli/commit/4b861f458c8c789a5276f7b591bebd28e2080444) | fix | enable code splitting for vitest browser tests | +| [f1fd82381](https://github.com/angular/angular-cli/commit/f1fd823814b5e3620838beb423d5f33600c2e546) | fix | fail build and exclude routes when prerendering fails | +| [a84906fe3](https://github.com/angular/angular-cli/commit/a84906fe393e8c2dee978c02631af20e93627df1) | fix | keep dev-server Vite cache worktree-local | +| [fcf883ef1](https://github.com/angular/angular-cli/commit/fcf883ef166dc2e34d86007c0fcdfad1d0434c86) | fix | preserve binary values in the SQLite cache store | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [baf1ca189](https://github.com/angular/angular-cli/commit/baf1ca189dcf1ab0e55ac853c80f892dbd1864e4) | fix | decode pathname when constructing server asset path | +| [70748ca8e](https://github.com/angular/angular-cli/commit/70748ca8e76fe4b42798719410336119d943e755) | fix | ensure public directory containment in CommonEngine | + + + + + +# 21.2.23 (2026-09-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [d3aaa48ef](https://github.com/angular/angular-cli/commit/d3aaa48ef35b80c3297ee3050cd239f84d051899) | fix | update less to 4.9.0 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [645e41a47](https://github.com/angular/angular-cli/commit/645e41a47d21b7651837a4250de99af0509626e2) | fix | ensure public directory containment in CommonEngine | + + + + + +# 20.3.36 (2026-09-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [b4059cdc7](https://github.com/angular/angular-cli/commit/b4059cdc7b7a65e68abfcf9fb53a6304790b1796) | fix | update less to 4.9.0 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [c3e5982e4](https://github.com/angular/angular-cli/commit/c3e5982e49705f0f6a913cb94622b4c555d2d914) | fix | ensure public directory containment in CommonEngine | + + + + + +# 22.1.6 (2026-08-26) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [3f132eaa97](https://github.com/angular/angular-cli/commit/3f132eaa97b95e16c58b8095c2566e3c78190446) | fix | ignore side-effect annotations in unit test builds | +| [5b1028936f](https://github.com/angular/angular-cli/commit/5b1028936f6e9582917f95ac37942dca0ee2c659) | perf | batch prerender routes and share in-memory server bundles | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [76818cb811](https://github.com/angular/angular-cli/commit/76818cb811a8fe669575f988fec2e4528dca8c73) | fix | abort web request signal when node request is aborted | + + + + + +# 21.2.22 (2026-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [b0047b04f5](https://github.com/angular/angular-cli/commit/b0047b04f56f5cb3fb8e4465ba0fad777497144a) | fix | update webpack-dev-server to 5.2.6 | + + + + + +# 20.3.35 (2026-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [94b620acd7](https://github.com/angular/angular-cli/commit/94b620acd76e39c7d5cc8527dfc6fe9096855cbd) | fix | update webpack-dev-server to 5.2.6 | + + + + + +# 22.1.5 (2026-08-19) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [e672271f8](https://github.com/angular/angular-cli/commit/e672271f81e7b8b422b98c17fca5b67896d40844) | fix | enforce MCP roots in get_best_practices tool | +| [a14916cc4](https://github.com/angular/angular-cli/commit/a14916cc4b13412205d3e4a1c32d7e40be52f0e1) | fix | handle errors from isAllowedWorkspacePath in best-practices tool | +| [d6e1cddff](https://github.com/angular/angular-cli/commit/d6e1cddff92842f6889585ae7899efc028decc78) | fix | throw on out-of-roots workspace in best-practices tool | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [ba2b0e4c2](https://github.com/angular/angular-cli/commit/ba2b0e4c291842c699fdbeaaddec1531dbdd7952) | fix | transform fail() to expect.fail() in refactor-jasmine-vitest | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [cba72902d](https://github.com/angular/angular-cli/commit/cba72902d42022bd5a1bd17b23d65cacc0013e35) | fix | correct misleading error message for top-level await | +| [b4679998c](https://github.com/angular/angular-cli/commit/b4679998c205b6cce01d40fad24c4ee7954aa48d) | fix | disable code splitting for unit test builds | +| [ff1d3565e](https://github.com/angular/angular-cli/commit/ff1d3565e87749a8347c3be98cd4a7ebccb69fc0) | fix | preserve integrity and crossorigin in autoCsp loader | +| [1fc1fb05c](https://github.com/angular/angular-cli/commit/1fc1fb05cfe2c69ee7b9345b582d3c2177b923fd) | perf | traverse AST with iterative post-order walker in i18n inliner | + + + + + +# 22.1.4 (2026-08-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| [67a29899b](https://github.com/angular/angular-cli/commit/67a29899b07f760bac9582cbc6175966cce1c5d2) | fix | disable searching current directory for bare executable names on Windows | +| [1b0ba5c17](https://github.com/angular/angular-cli/commit/1b0ba5c17f0be2679b31bad1bee8b435315dac1a) | fix | serialize configuration as a single argv token in run_target strategies ([#33657](https://github.com/angular/angular-cli/pull/33657)) | +| [9440432d2](https://github.com/angular/angular-cli/commit/9440432d273c044965508d80e808c2fe123af0aa) | perf | avoid eager module loading during global bootstrap | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [d4a48e3ab](https://github.com/angular/angular-cli/commit/d4a48e3abe320e090eefe17cb70abdf8e7eafaed) | fix | generate CLAUDE.md for Claude Code instead of AGENTS.md | +| [d451f15b2](https://github.com/angular/angular-cli/commit/d451f15b2838a8282363dcb30161dd5dc858750f) | fix | import UrlSegment instead of subPath in guard generator | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [46fcb29d6](https://github.com/angular/angular-cli/commit/46fcb29d6c1ded962de95449d63716c13950f59a) | fix | count statically imported chunks in the initial total | +| [796b57684](https://github.com/angular/angular-cli/commit/796b576843191a4d5c74e4f9363ec0e46ae3a881) | fix | normalize setupFiles paths to POSIX for vitest runner | +| [c805e5cfb](https://github.com/angular/angular-cli/commit/c805e5cfb308d02a1944ef446d0a01af8a7ec6d2) | fix | prevent syntax corruption for Crockford-style enum IIFE | +| [cc6c17716](https://github.com/angular/angular-cli/commit/cc6c17716df5c411efca5e6f4c960f0565f45d90) | fix | prevent syntax corruption in oxc transform | +| [a142f6f83](https://github.com/angular/angular-cli/commit/a142f6f83d2f501b64f2404909ef9325bbfa70fb) | fix | return direct file contents for non-Angular TypeScript files | +| [956c6578d](https://github.com/angular/angular-cli/commit/956c6578d31d8482c04dc45723d7ab3b4fe355a5) | fix | return only lowest version per target engine | +| [689b5b116](https://github.com/angular/angular-cli/commit/689b5b116717140da98494e61ac5ea5ad7b68d73) | fix | set target for Rolldown dependency prebundling in Vite dev server | +| [70461f6ed](https://github.com/angular/angular-cli/commit/70461f6ed2e30aeaad2b93400ad00d9274665967) | perf | avoid encoding the inline source map before remapping | +| [22cce7563](https://github.com/angular/angular-cli/commit/22cce7563e82f17373b69e590d3e445767bd9264) | perf | batch last_accessed updates in sqlite cache store | +| [ab2ed18d5](https://github.com/angular/angular-cli/commit/ab2ed18d5739e8565403062ca74aa2d5cb3b95ec) | perf | hash the i18n inline cache key options once per locale | +| [34516ff73](https://github.com/angular/angular-cli/commit/34516ff736185e20ef8239b1fed8eda1f0193f62) | perf | optimize template string size calculation in server manifest | +| [712971c37](https://github.com/angular/angular-cli/commit/712971c370738beaebff18f6995859b1713ed8ae) | perf | share i18n translations with the inliner workers by reference | +| [78f8c5fef](https://github.com/angular/angular-cli/commit/78f8c5fef44dc6ff466b17c98fa1d01222cb1781) | perf | use Map for chunk asset size lookups in budget calculator | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [2331c1047](https://github.com/angular/angular-cli/commit/2331c10476830e7a53e0f2fa4a2f37dad58e2d5d) | fix | destroy platform when response stream is cancelled | + + + + + +# 21.2.21 (2026-08-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [ba5d16cac](https://github.com/angular/angular-cli/commit/ba5d16cacab4139897d1f0cf4161562b1bd00553) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [f7f60e69e](https://github.com/angular/angular-cli/commit/f7f60e69e77cda60378543a9ca9cc2c76c0bf34f) | fix | bump undici to 7.29.0 | + + + + + +# 20.3.34 (2026-08-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [3962517b9](https://github.com/angular/angular-cli/commit/3962517b95c76016ff7bcb53662704dff18c0552) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [f348efe8b](https://github.com/angular/angular-cli/commit/f348efe8b6d9c9d7014afb331eecda0a9478ba0a) | fix | bump undici to 7.29.0 | + + + + + +# 22.1.3 (2026-08-05) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| [f837b788c6](https://github.com/angular/angular-cli/commit/f837b788c6a23e7661db5455192f5a84d20e9b37) | fix | always install package during ng add to inspect manifest on disk for schematics ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [72ae93638d](https://github.com/angular/angular-cli/commit/72ae93638dd83b3877773ac0d9f7e4c4e9056a33) | fix | correctly report available package updates during bare ng update | +| [a949e06568](https://github.com/angular/angular-cli/commit/a949e0656855786e4274159be09242ff9c4eb012) | fix | discover migrations from installed packages when omitted by registry metadata ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [1d9122fd50](https://github.com/angular/angular-cli/commit/1d9122fd50f8a9b82199d44f9b0e075726279538) | fix | normalize Windows drive-letter casing for process.cwd | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [a4860d505a](https://github.com/angular/angular-cli/commit/a4860d505aff0b43fc608cd0745ed187b1d407b5) | fix | prevent build process hang on unhandled error ([#33715](https://github.com/angular/angular-cli/pull/33715)) | +| [7686d66cab](https://github.com/angular/angular-cli/commit/7686d66cabcdfe90748ac405686d42674a19576f) | fix | retain watch files on error in load result cache | +| [25b7cf7504](https://github.com/angular/angular-cli/commit/25b7cf7504838d541abfba043d9ea3d616a23c04) | fix | support case-insensitive and alternative license file names | +| [4fd81402ce](https://github.com/angular/angular-cli/commit/4fd81402ce5f2f3db45a17ee0d116f12edf8d254) | perf | release build resources early in non-watch mode ([#33715](https://github.com/angular/angular-cli/pull/33715)) | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [6af906cb94](https://github.com/angular/angular-cli/commit/6af906cb94bd0686b4c0431989e054711c74892a) | fix | settle writeResponseToNodeResponse when client disconnects | +| [917d3b2254](https://github.com/angular/angular-cli/commit/917d3b22549c19b91e03ceb0775a3a758142b9de) | perf | avoid buffering request body when sanitizing headers | + + + + + +# 21.2.20 (2026-08-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [972f6db7e9](https://github.com/angular/angular-cli/commit/972f6db7e9ccf3c4b0e895341a49b104a8b0ac9b) | fix | upgrade postcss to 8.5.23 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [8de75ada50](https://github.com/angular/angular-cli/commit/8de75ada503d4182af7f0be957e58fe678a63b98) | fix | upgrade postcss to 8.5.23 | + + + + + +# 20.3.33 (2026-08-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [0b4008f17d](https://github.com/angular/angular-cli/commit/0b4008f17d76c821d9f075b86da80b826f085d23) | fix | upgrade postcss to 8.5.23 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [fc861affcd](https://github.com/angular/angular-cli/commit/fc861affcd8866b71b81c8ba90f7a63890b6577c) | fix | upgrade postcss to 8.5.23 | + + + + + +# 22.1.2 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [57db27ea91](https://github.com/angular/angular-cli/commit/57db27ea910b102854267dfcd77e8fe60f23993c) | fix | add packages with built-in schematics to devDependencies only | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [baed978885](https://github.com/angular/angular-cli/commit/baed978885585fceadc64f0151d7474054d57d6a) | fix | prevent IIFE wrapper interleaving for adjacent classes in minified files | + + + + + +# 22.1.1 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ab971a8543](https://github.com/angular/angular-cli/commit/ab971a8543d80dfc93fdddf49598564f25196160) | fix | support npm 12 metadata array and error formats | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [db24220fb8](https://github.com/angular/angular-cli/commit/db24220fb8efb5c9416e375a468672ff69d0659d) | fix | rewrite paths from sandboxed execroots when running under Bazel | + + + + + +# 22.1.0 (2026-07-29) + +## Deprecations + +### @angular-devkit/core + +- `stringToFileBuffer` and `fileBufferToString` are deprecated. Use standard Web APIs (`TextEncoder` and `TextDecoder`) instead. + + Internal usages within the repository have been removed and replaced with standard Web APIs. The public API golden file for `@angular-devkit/core` has been updated to reflect the deprecations. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [89d7f59cd](https://github.com/angular/angular-cli/commit/89d7f59cd7cc5d821ac0e81b1fee50e27877c976) | feat | update ai-config to include Angular MCP server config | +| [b345bb54d](https://github.com/angular/angular-cli/commit/b345bb54d3ec7c73f9db70400f823d90aeab8fa8) | fix | remove unused spec tsconfig outDir | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [f59bcf529](https://github.com/angular/angular-cli/commit/f59bcf52917bc5e25b97c863f96127ec8d0f12f5) | fix | abort and guide manual mitigation when updating catalog packages | +| [48f90f5d5](https://github.com/angular/angular-cli/commit/48f90f5d5e5dd6369b2ccf044a722271c82dffd6) | fix | prevent Yarn registry environment variable override | +| [c963ecbec](https://github.com/angular/angular-cli/commit/c963ecbec241d42dfb419ae081ba22429e37f09e) | fix | remove algoliasearch dependency and support latest docs versions | +| [6859d25c3](https://github.com/angular/angular-cli/commit/6859d25c32b6c93cbbf2d4ad4143aa10b52b7588) | fix | resolve correct registry name when using npm alias syntax during update | +| [3a2589dac](https://github.com/angular/angular-cli/commit/3a2589dac684dfe26b2151b940bd40280d7c5d8e) | fix | respect client-side release age settings during update resolution | +| [5c3101bff](https://github.com/angular/angular-cli/commit/5c3101bff2e1b9d0b0e4668a183e8bcab8a1540e) | fix | revert package.json changes if installation tasks fail during update | +| [7932caaf9](https://github.com/angular/angular-cli/commit/7932caaf987c5692d6624f6af23e65ce3f6d27fd) | fix | robustly parse npm manifest from array | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | +| [fd336d365](https://github.com/angular/angular-cli/commit/fd336d365dbfe8f558db177a8da24790914a541b) | refactor | deprecate stringToFileBuffer and fileBufferToString | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [34d558c3c](https://github.com/angular/angular-cli/commit/34d558c3c98e1b59a2c2808fc04bfbb63dfe8acf) | feat | add built-in SQLite cache store fallback | +| [0c18dc0f6](https://github.com/angular/angular-cli/commit/0c18dc0f6d4f36d7bbb6f35b37bfa688e8fcf7d2) | feat | emit debug ids for stable subresource integrity hashes | +| [51f69276f](https://github.com/angular/angular-cli/commit/51f69276fd2b2aab0e94901e79a51f6860ef8690) | feat | enable chunk optimization for server builds | +| [10dc30f9c](https://github.com/angular/angular-cli/commit/10dc30f9c680f46f65b5beb030f2a75c422a3e71) | feat | migrate advanced optimization Babel plugins to oxc-parser + magic-string | +| [917393a4c](https://github.com/angular/angular-cli/commit/917393a4cd85172574b155f727da5c1eae196fb1) | feat | migrate i18n inliner to oxc-parser + magic-string | +| [52ae7f862](https://github.com/angular/angular-cli/commit/52ae7f862a581cac97d5a9b33c9b71b830d95b19) | feat | share persistent build cache across git worktrees | +| [95510511d](https://github.com/angular/angular-cli/commit/95510511d963384c5a03298300246aa34a62328a) | fix | add bounded timeout to vitest executor disposal | +| [62b11e316](https://github.com/angular/angular-cli/commit/62b11e316e8248161877592acee5954b2a8c9c66) | fix | anchor debug ID comment matching and make injection idempotent | +| [6d9b360e5](https://github.com/angular/angular-cli/commit/6d9b360e5f2b7ab3d4ace4db35f674166630f658) | fix | prevent esbuild service hang on internal component stylesheet builds | +| [291caab61](https://github.com/angular/angular-cli/commit/291caab6158fa804f6221dd024a5b96e6181eaad) | fix | prevent stripping nested sourceMappingURL comments | +| [0e3ca3dfb](https://github.com/angular/angular-cli/commit/0e3ca3dfba8ef60217089d93a321660ebb6fb838) | fix | support hyphens and underscores when stripping chunk hash in chunk optimizer | +| [585d08af8](https://github.com/angular/angular-cli/commit/585d08af881a35653dc5e7b1ee7a75b42e154517) | perf | default chunk optimization to use Rolldown | +| [09d0a11a8](https://github.com/angular/angular-cli/commit/09d0a11a8b832f156aea100ad3497c5221fc0f50) | perf | enable fast-path AST printing with sourcemaps in AotCompilation | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [7ef9ed24d](https://github.com/angular/angular-cli/commit/7ef9ed24d3c111474ae21c06cb2b05a21804f586) | feat | support the standard Forwarded header | + + + + + +# 22.0.9 (2026-07-29) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [9ee8f9b669](https://github.com/angular/angular-cli/commit/9ee8f9b6692b8efa2d0cabd502cd5a598f436c8a) | fix | declare devserver_start and devserver_stop as isReadOnly:false | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [d0c776d461](https://github.com/angular/angular-cli/commit/d0c776d4610e2327c29900b9b3d679e9c0f28061) | fix | safeguard Karma builder stream controller against closed state | + + + + + +# 22.0.8 (2026-07-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [41c356256](https://github.com/angular/angular-cli/commit/41c356256525469a41ea72896b1ddff91a4e6e55) | fix | batch Prettier invocations during migration formatting | +| [bc87c85c3](https://github.com/angular/angular-cli/commit/bc87c85c3c660e969cf77a493dc4ee58fd831214) | fix | update MCP devserver tool names to conform to spec regex | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [5724bf632](https://github.com/angular/angular-cli/commit/5724bf632eba59eccdd941beae8aafad35ce003e) | fix | canonicalize drive letter casing for workspace root on Windows | +| [b8bdbaa1e](https://github.com/angular/angular-cli/commit/b8bdbaa1ea1c58f061580bd58c015078f57c1530) | fix | ensure import map integrity keys are valid URL-like specifiers | +| [212a7bb21](https://github.com/angular/angular-cli/commit/212a7bb21eca131f32fd7b4e7b5a9ab87f2bfded) | fix | favor istanbul coverage provider when browser testing is enabled | +| [bc760b005](https://github.com/angular/angular-cli/commit/bc760b005e0dae10a2dfd31a7f0f772482305398) | fix | preserve custom config options in runnerConfig for vitest | +| [19cda7415](https://github.com/angular/angular-cli/commit/19cda7415693f36e911a736a32576110905b6600) | fix | remap metafile paths when workspace root is a symlink or junction | +| [fdcc98c79](https://github.com/angular/angular-cli/commit/fdcc98c7912f45431268fdf66c8b132bd119bc27) | perf | skip semantic affected-file walk when type checking is disabled | + + + + + +# 22.0.7 (2026-07-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [e85c10ccb](https://github.com/angular/angular-cli/commit/e85c10ccb551eeaf9ac7e819ee6eb095317b5032) | fix | copy packageManager field and yarn config for temp installs | +| [b87cafec0](https://github.com/angular/angular-cli/commit/b87cafec04b24c2f4337ecfa00388197ab5003f0) | fix | support resolving subproject dependencies in pnpm workspaces | + + + + + +# 21.2.19 (2026-07-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [df5429871](https://github.com/angular/angular-cli/commit/df54298712bb84ec079787e4ca6c0164f6358cf1) | fix | update http-proxy-middleware to 3.0.7 | + + + + + +# 20.3.32 (2026-07-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [d4e07cb3e](https://github.com/angular/angular-cli/commit/d4e07cb3e3f871822f9bf37fdea35814ec1cdb80) | fix | update http-proxy-middleware to 3.0.7 | + + + + + +# 22.0.6 (2026-07-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [df49966c6](https://github.com/angular/angular-cli/commit/df49966c6b8ec0c10b58fee18981fc2992ac071a) | fix | fallback to build target preserveSymlinks option in karma runner | +| [9906add5b](https://github.com/angular/angular-cli/commit/9906add5b722cca7f61488b367dd73545c6e13bc) | fix | prevent externalizing builder-injected i18n locale data | +| [070155f6e](https://github.com/angular/angular-cli/commit/070155f6ead185d4b478d9034662d65b8bb86a23) | fix | strip all vite id prefixes from minified code with external dependencies | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [c9aa354b1](https://github.com/angular/angular-cli/commit/c9aa354b1de0c946ddc994191613bb809a799f81) | fix | replace all route parameters when resolving relative redirects | + + + + + +# 22.0.5 (2026-07-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f099e641c](https://github.com/angular/angular-cli/commit/f099e641c96a5932d81535e712af7044269b4788) | fix | gracefully handle package manager errors in command handler | +| [cf033ec48](https://github.com/angular/angular-cli/commit/cf033ec48e2371655fbf6bcdb588c05aff2e6662) | fix | respect release age policy in update bootstrapping logic | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [862a38069](https://github.com/angular/angular-cli/commit/862a380697db60cee8cf83e8fe20641db2a6aae1) | fix | remove unused app tsconfig outDir | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [f75085f4b](https://github.com/angular/angular-cli/commit/f75085f4b58ed61979a2901a4ef0353e56f22136) | fix | prevent OS command injection in ssr-dev-server builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [dfee1e7b1](https://github.com/angular/angular-cli/commit/dfee1e7b142d767c86eecb7791ed72bce65866c5) | fix | auto-inject localize/init in library unit tests | +| [aba718403](https://github.com/angular/angular-cli/commit/aba718403df7a96310570c1449674a49254cd4af) | fix | inherit preserveSymlinks option from build target in unit-test builder | +| [7c0d9f03c](https://github.com/angular/angular-cli/commit/7c0d9f03c2ae82a90a48e51b2aee731a36dc2beb) | fix | load zone.js dynamically for library unit tests | +| [4288e4452](https://github.com/angular/angular-cli/commit/4288e44525e855f29f026276f518968b1b2697db) | fix | scope createRequire module resolution using paths to prevent parent paths | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [5f774f891](https://github.com/angular/angular-cli/commit/5f774f891a086bf0a2ab6f86c24485425e99f16d) | fix | preserve response headers during redirect | + + + + + +# 21.2.18 (2026-07-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [61a810ab6](https://github.com/angular/angular-cli/commit/61a810ab615d1a26fc69f9e48be7cc6555a9c430) | fix | respect release age policy in update bootstrapping logic | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [a2b6116ed](https://github.com/angular/angular-cli/commit/a2b6116eddc7a71e4539817da623221ca552a35f) | fix | bump undici to 7.28.0 | +| [2510ee3c9](https://github.com/angular/angular-cli/commit/2510ee3c9aefdab5428938e78b9cfcea0ada1c45) | fix | bump vite to 7.3.6 | + + + + + +# 20.3.31 (2026-07-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [4ea787cd0](https://github.com/angular/angular-cli/commit/4ea787cd0dfc7226896643a6f8ccd99846c85c2c) | fix | bump undici to 7.28.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [df5a2cc2b](https://github.com/angular/angular-cli/commit/df5a2cc2bcf8d65d834164984ee5456718dfb75e) | fix | bump vite to 7.3.6 | + + + + + +# 22.0.4 (2026-06-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [46185ceac](https://github.com/angular/angular-cli/commit/46185ceacc86cc04819b991621b7bb4e0d865b5a) | fix | aggregate parallel worker performance timings on the main thread | +| [5d7e29c41](https://github.com/angular/angular-cli/commit/5d7e29c41ed3fe0abe77dcdc894d5b7875293cb5) | perf | dispose builder result context early in non-watch mode | + + + + + +# 21.2.17 (2026-06-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [9c366e8be](https://github.com/angular/angular-cli/commit/9c366e8be42495d1bf90033788b3beae77e5fc28) | fix | bump @babel/core to 7.29.7 | +| [6a06ce9e9](https://github.com/angular/angular-cli/commit/6a06ce9e9847b4ddd66b94b9ea4c6fbbfc28d066) | fix | bump esbuild to 0.28.1 | +| [637b84877](https://github.com/angular/angular-cli/commit/637b84877334de5acc6fcbe1ba4d62c7a6da420b) | fix | bump piscina to 5.2.0 | +| [e86d58a12](https://github.com/angular/angular-cli/commit/e86d58a128725eee9dd63dcd2ccd35b8ee4706f3) | fix | bump vite to 7.3.5 | + + + + + +# 20.3.30 (2026-06-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [36adca99c](https://github.com/angular/angular-cli/commit/36adca99ce72d59e5909709f53e239b1e79d703e) | fix | bump @babel/core to 7.29.7 | +| [7f236cfea](https://github.com/angular/angular-cli/commit/7f236cfea9023a77c6ccaf1648f388678017ce1e) | fix | bump esbuild to 0.28.1 | +| [94fb3c122](https://github.com/angular/angular-cli/commit/94fb3c122a14a3a8540a24e22f2f8ff87d130cb5) | fix | bump piscina to 5.2.0 | +| [1f283a41c](https://github.com/angular/angular-cli/commit/1f283a41c921b194c67c5bca05d4b5cff1d91857) | fix | bump vite to 7.3.5 | + + + + + +# 22.0.3 (2026-06-18) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [0eddea898](https://github.com/angular/angular-cli/commit/0eddea898d3bf4da8c9c5c27ec2ee79916e8be87) | fix | remove default workspace vscode mcp.json configuration | + + + + + +# 21.2.16 (2026-06-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [77c9047ac](https://github.com/angular/angular-cli/commit/77c9047acdf4b69ec6dd399af2dff7278e8014c7) | fix | update pacote to 21.5.1 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [d052e97da](https://github.com/angular/angular-cli/commit/d052e97dafe2e85fbc072fd11025c803ebb80ef1) | fix | prioritize options over environment variables in AngularNodeAppEngine | + + + + + +# 20.3.29 (2026-06-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [5f7c0328c](https://github.com/angular/angular-cli/commit/5f7c0328c3fe1a003ade44861d9aac98f485f014) | fix | update pacote to 21.5.1 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [a75d78e68](https://github.com/angular/angular-cli/commit/a75d78e681b10436971d8071450b8cfd93b2d72a) | fix | prioritize options over environment variables in AngularNodeAppEngine | + + + + + +# 22.0.2 (2026-06-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [136fc2714](https://github.com/angular/angular-cli/commit/136fc27149af74263ef519007f0a74f9f85c5b4d) | fix | support registry metadata fetching under bun package manager | +| [2653dd5c7](https://github.com/angular/angular-cli/commit/2653dd5c7d47149b61bfe6edf4ab1281347e89dd) | perf | implement semaphore backpressure throttling in PackageManager | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [0b4a48add](https://github.com/angular/angular-cli/commit/0b4a48add9d7218e698af0db974bd597bed8a121) | perf | implement semaphore backpressure throttling in JavaScriptTransformer | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [d996a27e9](https://github.com/angular/angular-cli/commit/d996a27e9744b473a7db56f81871731b8bdce585) | fix | avoid caching non-SSG page lookups | +| [285a34e42](https://github.com/angular/angular-cli/commit/285a34e42f1cdc512468de0041b232c7190e7d7e) | fix | correct grammar in console warning for redirected location headers | +| [c8088a536](https://github.com/angular/angular-cli/commit/c8088a536c2c747a273e37be682643e1b35e2f75) | fix | prioritize options over environment variables in AngularNodeAppEngine | + + + + + +# 20.3.28 (2026-06-11) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| [e3d564667](https://github.com/angular/angular-cli/commit/e3d5646679215d9b73a72e04d87aa7848f2c01d2) | fix | fallback to deprecated versions when resolving ranges if no non-deprecated version is found | +| [f12e17025](https://github.com/angular/angular-cli/commit/f12e17025a262f9432afd58971c47aec7dbfab25) | fix | remove forceAuth and unscoped credential parsing | + + + + + +# 21.2.15 (2026-06-11) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [42ac0ed0f](https://github.com/angular/angular-cli/commit/42ac0ed0ff8b98862b9df1cef048f463ddc2cd85) | fix | remove forceAuth and unscoped credential parsing | +| [c7a7f1955](https://github.com/angular/angular-cli/commit/c7a7f1955619717ca775f730d67e3311047537f8) | fix | support registry metadata fetching under bun package manager | + + + + + +# 22.0.1 (2026-06-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [b54e9a549](https://github.com/angular/angular-cli/commit/b54e9a549d30871f6017b1db4cf7a4ab5f3e02db) | fix | do not sort migrations of the same version alphabetically | +| [d33311612](https://github.com/angular/angular-cli/commit/d333116123c7d3d5e87713b7baac048b78f28517) | fix | fallback to local package.json for schematic detection on first run | +| [918102a93](https://github.com/angular/angular-cli/commit/918102a9373085394c41f10d9f5df3e3c17b263f) | fix | isolate temporary package installation from parent pnpm workspace | +| [b048b5f4a](https://github.com/angular/angular-cli/commit/b048b5f4a83d7b20095d79654b849808e7d58fdb) | fix | remove forceAuth and unscoped credential parsing | +| [277934035](https://github.com/angular/angular-cli/commit/277934035138c5af803e8daeebc2313f0a4cb5b3) | fix | validate registry option is a valid URL in ng add | +| [4510dae02](https://github.com/angular/angular-cli/commit/4510dae021ab25bb852eeed6415dbd52cfabfce5) | perf | optimize update schematic registry query counts by fetching package metadata lazily | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [c80012294](https://github.com/angular/angular-cli/commit/c8001229453211b37cd7bb12ed26a2deb9257fd5) | fix | fix browserMode option mapping in refactor-jasmine-vitest | +| [a9b6bd904](https://github.com/angular/angular-cli/commit/a9b6bd9042d6b859c384a6fc782541fca30dfb68) | fix | safely comment out multiline statements in refactor-jasmine-vitest | +| [12199df00](https://github.com/angular/angular-cli/commit/12199df00f2e3e8436ada13e04799e5825eb3f7b) | fix | use null objects and callbacks in karma-to-vitest migration | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [89d1be979](https://github.com/angular/angular-cli/commit/89d1be979f388d85e9c428bbf1df4e7fb4036dce) | fix | allow disabling Vitest isolation from builder | +| [d45b84be9](https://github.com/angular/angular-cli/commit/d45b84be9a607e49b391cb216cb6de7eca274931) | fix | exclude JSON imports from Vite dependency optimization | +| [e3cab4ddd](https://github.com/angular/angular-cli/commit/e3cab4dddade2538125e8a2f345f42c95e26aeae) | fix | prevent concurrent stylesheet bundling esbuild context leaks | +| [bd413b0eb](https://github.com/angular/angular-cli/commit/bd413b0eb156184ea432cbb7d4e6d7f6f70813ab) | fix | restrict application builder output paths to output directory | + + + + + +# 22.0.0 (2026-06-03) + +## Breaking Changes + +### + +- Node.js v20 is no longer supported. The minimum supported Node.js versions are now v22.22.0 and v24.13.1. +- The `@angular-devkit/architect-cli` package is no longer available. The `architect` CLI tool has been moved to the `@angular-devkit/architect` package. +- The experimental `@angular-devkit/build-angular:jest` and `@angular-devkit/build-angular:web-test-runner` builders have been removed. + +### @angular/build + +- The `@angular/build:dev-server (ng serve)` now assigns the highest priority to the `PORT` environment variable. This value will override any port configurations specified in `angular.json` or via the `--port` command-line flag. This includes the default port 4200. +- `istanbul-lib-instrument` is now an optional peer dependency. + Projects using karma with code coverage enabled will need to ensure that istanbul-lib-instrument is installed. Note: `ng update` will automatically add this dependency during the update process. + +### @angular/ssr + +- The server no longer falls back to Client-Side Rendering (CSR) when a request fails host validation. Requests with unrecognized 'Host' headers will now return a 400 Bad Request status code. Users must ensure all valid hosts are correctly configured in the 'allowedHosts' option. + +## Deprecations + +### @angular-devkit/build-angular + +- Webpack builders in build-angular are deprecated. Use @angular/build builders instead. + +### @angular-devkit/build-webpack + +- Webpack builders in build-webpack are deprecated. Use @angular/build builders instead. + +### @angular/ssr + +- CommonEngine APIs are deprecated in favor of AngularNodeAppEngine or AngularAppEngine. + +### @ngtools/webpack + +- @ngtools/webpack loader and plugin are deprecated. Use @angular/build instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [58c0978f6](https://github.com/angular/angular-cli/commit/58c0978f658ee5fa7232abd8e2eb7f146e4eb6bb) | feat | add support for Node.js 26.0.0 | +| [a5c7c0b5f](https://github.com/angular/angular-cli/commit/a5c7c0b5fda4ae0c00351ba34e5f39dab2c6baee) | fix | reflect new minimum supported Node version in ng.js | +| [a5e1e48db](https://github.com/angular/angular-cli/commit/a5e1e48db759e9ffcaa89f04504f5f93a1afdda4) | fix | update odd-numbered Node.js version warning condition for future releases | +| [93c3eb8fb](https://github.com/angular/angular-cli/commit/93c3eb8fb2a0d531f18779152b0a62e9b73dbb23) | fix | update zoneless migration tool to handle `ChangeDetectionStrategy.Eager` | +| [a39a33128](https://github.com/angular/angular-cli/commit/a39a33128fb56e9c65ea89e06c4f127252d3b220) | perf | cache root manifest and resolve restricted package exports in ng add | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [be60a63b7](https://github.com/angular/angular-cli/commit/be60a63b7b8fdea26bfd3329ce54d321334db2d2) | feat | add migrate-karma-to-vitest update migration | +| [43505066e](https://github.com/angular/angular-cli/commit/43505066e2350ca875051bb0f9991da8003c9247) | feat | add migration to add istanbul-lib-instrument | +| [b2f7a038b](https://github.com/angular/angular-cli/commit/b2f7a038b4a321e4e1b0b340cd09425f948c77ad) | feat | conditionally install istanbul coverage provider for Vitest migration | +| [d227e6985](https://github.com/angular/angular-cli/commit/d227e6985ef5540e0eea2571577ee2b9be0d3c64) | feat | migrate fake async to Vitest fake timers | +| [d2aa9ede5](https://github.com/angular/angular-cli/commit/d2aa9ede55a3e16b61ce6ae60dba6c8ea8954358) | feat | migrate fakeAsync's flush behavior when used in beforeEach | +| [f98cc82eb](https://github.com/angular/angular-cli/commit/f98cc82eb0f46986e61b4f94b57dcd36e4eaf215) | feat | rely on strict template default in generated workspaces | +| [c9f408153](https://github.com/angular/angular-cli/commit/c9f4081533f6f114846b88a152a9d5dc7363d680) | feat | set up fake timers in beforeEach instead of beforeAll | +| [de630c2fc](https://github.com/angular/angular-cli/commit/de630c2fcee22622942478c98117d8f473d8c894) | feat | stabilize refactor-jasmine-vitest schematic | +| [8d0805dd1](https://github.com/angular/angular-cli/commit/8d0805dd1750cb16af620811dc01b40e46ad030e) | feat | update TSConfig globals during karma to vitest migration | +| [470e1f937](https://github.com/angular/angular-cli/commit/470e1f937492e73971dea9c39af83368caf74e42) | fix | add istanbul-lib-instrument to application/library generator dependencies | +| [dc1238e5a](https://github.com/angular/angular-cli/commit/dc1238e5a4c9ab5902735e3d74bc7c5cc57553bd) | fix | add trusted-proxy-headers migration | +| [6572a6944](https://github.com/angular/angular-cli/commit/6572a69443356ff0022e6ce162915125fee0e3bb) | fix | default components to OnPush change detection | +| [aed407db8](https://github.com/angular/angular-cli/commit/aed407db8be6bc7591fb82f10c79586cbd072a8a) | fix | defer karma config deletion in Karma to Vitest migration | +| [4fbc60891](https://github.com/angular/angular-cli/commit/4fbc6089174142623fbd28cc68cede181074fab5) | fix | preserve Jasmine stub-by-default semantics for bare spies | +| [b3d838dfd](https://github.com/angular/angular-cli/commit/b3d838dfdb2adc3bd035b495f7f9457d742d73a4) | fix | replace deprecated `ChangeDetectionStrategy.Default` with `Eager` | +| [a7ac8e5f0](https://github.com/angular/angular-cli/commit/a7ac8e5f0a268994a8fcfebbf56f76e994b6207d) | fix | support spy call arguments migration in refactor-jasmine-vitest | +| [7fb59eaa6](https://github.com/angular/angular-cli/commit/7fb59eaa65a8d7e880b6f44d715b2aeaff9301ca) | fix | use service decorator in ng generate | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------- | +| [d0e9e8163](https://github.com/angular/angular-cli/commit/d0e9e81639be33c453f1ef66c7c1c8ea4fe543c2) | build | update minimum supported Node.js versions | +| [1f21e89d9](https://github.com/angular/angular-cli/commit/1f21e89d99c191642627df6842402644a1bf26ee) | refactor | remove `@angular-devkit/architect-cli` package | +| [b4885b851](https://github.com/angular/angular-cli/commit/b4885b851226709ae4146070122806e14a3d5eb9) | refactor | remove experimental Jest and Web Test Runner builders | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------- | +| [b7940dbcb](https://github.com/angular/angular-cli/commit/b7940dbcb40291be4de5b31e8a8001165459a7d4) | refactor | deprecate Webpack builders | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------- | +| [3d5daa45e](https://github.com/angular/angular-cli/commit/3d5daa45e3ade025c1bc0df35d2766563ccf7c03) | refactor | deprecate webpack and webpack-dev-server builders | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e9aa7967b](https://github.com/angular/angular-cli/commit/e9aa7967ba391e429ec1c04473efb4a2d38a62bf) | feat | add isolate option to unit-test builder | +| [fe720cab6](https://github.com/angular/angular-cli/commit/fe720cab64bbc8bcc2db583188e32ad938e63a23) | feat | add process.env.PORT support to the dev server | +| [0781f7498](https://github.com/angular/angular-cli/commit/0781f74986e773d143b9bdf5d3f0747de0df6b0a) | feat | add quiet option to suppress build noise in unit tests | +| [cd2ad3c19](https://github.com/angular/angular-cli/commit/cd2ad3c1958d9adadc214045b5e59c0c8f8b4499) | feat | enable chunk optimization by default with heuristics | +| [3007f46b7](https://github.com/angular/angular-cli/commit/3007f46b7e077227b17d8bb1090edd2f8ff19ae3) | feat | move istanbul-lib-instrument to optional peer dependency | +| [af2c7e944](https://github.com/angular/angular-cli/commit/af2c7e9444fba81d3b1fd2d37dc4412f8305b5ed) | feat | rename `experimentalPlatform` to `platform` in application builder | +| [58c7c7a9d](https://github.com/angular/angular-cli/commit/58c7c7a9d80fc6af5cf8b82a6d87f1d3cf3808c6) | feat | subresource integrity validation for dynamically loaded modules | +| [73233dc5f](https://github.com/angular/angular-cli/commit/73233dc5f41b570f5ac095b255a46de6cbfb54b7) | feat | support Istanbul coverage in Vitest runner | +| [414320d02](https://github.com/angular/angular-cli/commit/414320d02a090b7b36720051569cba7563bf9ac6) | feat | support runtime Zone.js detection in Vitest unit test runner | +| [b85ec6798](https://github.com/angular/angular-cli/commit/b85ec6798b5c74f778a69a657729e446a2156261) | fix | allow configuring Access-Control-Allow-Origin via headers option | +| [edfa782d5](https://github.com/angular/angular-cli/commit/edfa782d52fd971aebead8b96b6ca470a3f5123e) | fix | use dynamic TestComponentRenderer for Vitest | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------- | +| [5adc92541](https://github.com/angular/angular-cli/commit/5adc92541433be23fc2246db5a199cf5c0dc9e67) | fix | enforce explicit opt-in for proxy headers | +| [f85343925](https://github.com/angular/angular-cli/commit/f8534392552f4896ee9449939cdc705010331e3d) | fix | support all X-Forwarded-\* headers when trustProxyHeaders is true | +| [8471ba634](https://github.com/angular/angular-cli/commit/8471ba63433d87ba3e59af622ed9cfa89b732cd9) | fix | support server-side rendering configuration options | +| [50b16a65b](https://github.com/angular/angular-cli/commit/50b16a65b1be1f9c2ec11d578240a8884518d517) | refactor | deprecate CommonEngine APIs | +| [27cd35561](https://github.com/angular/angular-cli/commit/27cd355619aad140dfc221c6bd161f4a981e0f3b) | refactor | remove CSR fallback for invalid hosts | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------- | +| [547ca515b](https://github.com/angular/angular-cli/commit/547ca515b707c283489a3f088d86fc84807d830d) | refactor | deprecate @ngtools/webpack loader and plugin | + + + + + +# 21.2.14 (2026-06-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [aed448748](https://github.com/angular/angular-cli/commit/aed448748451b6b87c20e7bd9b3b1f822067f681) | fix | expand package groups for newly added peer dependencies in update schematic | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [d46c082fb](https://github.com/angular/angular-cli/commit/d46c082fb994138327eb36882373f63daeec8bc1) | fix | prevent esbuild service child process leakage | + + + + + +# 20.3.27 (2026-06-02) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [07c6c96ba](https://github.com/angular/angular-cli/commit/07c6c96baf2901f44322beb70b77c32dc0824e45) | fix | add support for configuring trusted proxy headers via environment variable | + + + + + +# 19.2.27 (2026-06-02) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [2c0dfc2ac](https://github.com/angular/angular-cli/commit/2c0dfc2ac8ce2f08741d25f687b4c605477978d6) | fix | add support for configuring trusted proxy headers via environment variable | + + + + + +# 21.2.13 (2026-05-27) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [3c6d26a31](https://github.com/angular/angular-cli/commit/3c6d26a316cd6aea455c19b249dc6852d84a698e) | fix | remove unconditional CORS wildcard from webpack dev-server | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [2b3e95517](https://github.com/angular/angular-cli/commit/2b3e95517358f8ef3482d5319d970f4774e45ad0) | fix | assert that asset input paths are within workspace root | + + + + + +# 21.2.12 (2026-05-20) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cbad57579](https://github.com/angular/angular-cli/commit/cbad57579adb5de7887985afbb2bf1f40adf3cb2) | fix | ignore virtual esbuild paths with (disabled): | + + + + + +# 21.2.11 (2026-05-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [bbd63b7a5](https://github.com/angular/angular-cli/commit/bbd63b7a5a1049bc56b9ddf6edf6563a1f2d9ace) | fix | robustly parse npm manifest from array | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [eafe1a719](https://github.com/angular/angular-cli/commit/eafe1a719fd3fecd5263e0a8371200b4b1ff4bb9) | fix | allow all hosts in common engine rendering options to prevent validation errors | +| [7a116a80d](https://github.com/angular/angular-cli/commit/7a116a80d7e6db341fd003737285d1a9db10ba6c) | fix | remove stateful flag from URL_PARAMETER_REGEXP | + + + + + +# 20.3.26 (2026-05-13) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [7cc1871ee](https://github.com/angular/angular-cli/commit/7cc1871ee50d123853ddf6bd89857b354d647462) | fix | allow all hosts in common engine rendering options to prevent validation errors | + + + + + +# 19.2.26 (2026-05-13) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [842fee029](https://github.com/angular/angular-cli/commit/842fee0291b787b63fdabcaaac5680b05d395075) | fix | allow all hosts in common engine rendering options to prevent validation errors | + + + + + +# 21.2.10 (2026-05-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [bb8611913](https://github.com/angular/angular-cli/commit/bb861191328fc2d25bd5ee99b0c8edc5e49d3a7d) | fix | restrict MCP workspace access to allowed client roots during resolution | + + + + + +# 19.2.25 (2026-04-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [49ae0ad2d](https://github.com/angular/angular-cli/commit/49ae0ad2d452ab7d7510297919125fe00081fa49) | fix | upgrade postcss to 8.5.12 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [2d53feca5](https://github.com/angular/angular-cli/commit/2d53feca58730def7e2fb79f9cabfd1c32b9351d) | fix | update esbuild to `0.28.0` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [02ce8bf26](https://github.com/angular/angular-cli/commit/02ce8bf2695e71c1684e231151b4ab5ccba94869) | fix | introduce trustProxyHeaders option to safely validate and sanitize proxy headers | + + + + + +# 21.2.9 (2026-04-29) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [233deef01](https://github.com/angular/angular-cli/commit/233deef01288c6aa39a048d6bd66a1f09595dc15) | fix | fix broken img ref in ai-tutor | +| [7cea9885c](https://github.com/angular/angular-cli/commit/7cea9885c64a747b391b74e6434cdf005c843766) | fix | introduce initial package manager workspace awareness | +| [5b1a5b743](https://github.com/angular/angular-cli/commit/5b1a5b7434323eb383df1f53c389fe9dc948a785) | fix | remove standalone true ref in ai tutor | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [e7abeb5c7](https://github.com/angular/angular-cli/commit/e7abeb5c74024daf125070c9b4f7f8d2426bab66) | fix | add missing imports for focus and skip APIs in refactor-jasmine-vitest | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [94023f62c](https://github.com/angular/angular-cli/commit/94023f62c5b51a669ff3187cc062057f82c79c52) | fix | introduce trustProxyHeaders option to safely validate and sanitize proxy headers | +| [5ffe5c309](https://github.com/angular/angular-cli/commit/5ffe5c309a92e7fd42f1059171924c5942f35c36) | fix | add support for configuring trusted proxy headers via environment variable | +| [930ada9b7](https://github.com/angular/angular-cli/commit/930ada9b73c5172d57830ce42a5a336ae9483a54) | fix | decode route segments when building and matching route tree | +| [0dc8a440c](https://github.com/angular/angular-cli/commit/0dc8a440ca9e4242edb69ba5a8147fef1dddc4f0) | fix | use router to normalize URLs for comparison | + + + + + +# 20.3.25 (2026-04-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [5e01ef40e](https://github.com/angular/angular-cli/commit/5e01ef40eb87deda79d18654fc696b64d18bf889) | fix | upgrade postcss to 8.5.12 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [6686848d9](https://github.com/angular/angular-cli/commit/6686848d946ca157f9b92b84db377e912266395e) | fix | introduce trustProxyHeaders option to safely validate and sanitize proxy headers | + + + + + +# 20.3.24 (2026-04-15) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [10c09c77b](https://github.com/angular/angular-cli/commit/10c09c77b75602293377b962b2a8397a2819036c) | fix | update esbuild to `0.28.0` | + + + + + +# 21.2.7 (2026-04-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [971041aa1](https://github.com/angular/angular-cli/commit/971041aa11e4546772e4677b77ceec2ad1374496) | fix | handle missing package manager during analytics initialization | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [365cce81d](https://github.com/angular/angular-cli/commit/365cce81dde91463d4f0049a69feaed018902585) | fix | preserve coverage ignore comments in development | +| [9f74660c3](https://github.com/angular/angular-cli/commit/9f74660c3688bb1f0d6a7608a136a30dbfe4d7e9) | fix | show clear error when styleUrl points to a TypeScript file | + + + + + +# 20.3.23 (2026-04-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [ccab02ba0](https://github.com/angular/angular-cli/commit/ccab02ba0413f25464a6e4cb5871716b221013b7) | fix | update vite to `7.3.2` | + + + + + +# 19.2.24 (2026-04-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [f4595d599](https://github.com/angular/angular-cli/commit/f4595d599001713ae978865d7f84e33e5a9a077a) | fix | update vite to `6.4.2` | + + + + + +# 21.2.6 (2026-04-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ea14f28cc](https://github.com/angular/angular-cli/commit/ea14f28ccfc6e5534eaef516bf1bfbe21582da04) | fix | fix sourceRoot resolution for MCP projects tool | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [9136eb376](https://github.com/angular/angular-cli/commit/9136eb37630d6315891b3c881cd0ba4037c3254c) | fix | ensure transitive SCSS partial errors are tracked in watch mode | +| [8186faa11](https://github.com/angular/angular-cli/commit/8186faa117803ffb6ac8e2c4cd6ab7873502308d) | fix | ensure Vitest mock patching is executed only once | +| [107d1a9e2](https://github.com/angular/angular-cli/commit/107d1a9e26fc59c7878254e563758818866f0f6e) | fix | preserve error stack traces during prerendering | +| [b7f457253](https://github.com/angular/angular-cli/commit/b7f4572533675729e87532bdc23509feb2f3a28d) | fix | scope CHROME_BIN executable path to individual playwright instances | + + + + + +# 21.2.5 (2026-03-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [cadf9b201](https://github.com/angular/angular-cli/commit/cadf9b201bd1055a6e3cc016eb01e0196b028080) | feat | support custom port in MCP devserver start tool | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [bbc255419](https://github.com/angular/angular-cli/commit/bbc255419b346e5152391b47f310922f86e9e383) | fix | allow underscores in host validation | +| [b1fe66a7f](https://github.com/angular/angular-cli/commit/b1fe66a7f8650ce021b4070394bc31848fc64ca5) | fix | patch Headers.forEach in cloneRequestAndPatchHeaders | + + + + + +# 20.3.22 (2026-03-27) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [5978eeeff](https://github.com/angular/angular-cli/commit/5978eeeff63cd62f1515d949eaad0b5e6f7c44cd) | fix | update picomatch to 4.0.4 | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [6e9b92612](https://github.com/angular/angular-cli/commit/6e9b926129a9dd79f01d47b7446411b8963ffb62) | fix | update picomatch to 4.0.4 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [6f209c26d](https://github.com/angular/angular-cli/commit/6f209c26dc5a454acd1cd76f25240c26978fa827) | fix | update picomatch to 4.0.4 | + + + + + +# 19.2.23 (2026-03-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [67cfbe32f](https://github.com/angular/angular-cli/commit/67cfbe32ff013d7c9763253c8ccd383d8ee2416c) | fix | update picomatch to 4.0.4 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [771b979e7](https://github.com/angular/angular-cli/commit/771b979e78af079242bea607470b3ad3b6162659) | fix | update picomatch to 4.0.4 | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [de2da4874](https://github.com/angular/angular-cli/commit/de2da4874026bb47d08de828b6b4b44fd7d9a3c4) | fix | update picomatch to 4.0.4 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [27a9ce4a7](https://github.com/angular/angular-cli/commit/27a9ce4a7446db0533e4a7ef64301eeccc5e21e1) | fix | update picomatch to 4.0.4 | + + + + + +# 21.2.4 (2026-03-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [a7787d092](https://github.com/angular/angular-cli/commit/a7787d0925559fe7731034856a872708bcfb78be) | fix | restore console methods after logger completes | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [7170599ab](https://github.com/angular/angular-cli/commit/7170599ab237691d9208c410363ef7e4ee50db2c) | fix | deduplicate and merge coverage excludes with vitest | +| [c73f13797](https://github.com/angular/angular-cli/commit/c73f13797afe57fcc98faf6361085e1dd5afae9b) | fix | prevent reporter duplicates by explicitly overriding Vitest configuration | +| [956ccaa71](https://github.com/angular/angular-cli/commit/956ccaa71ea8a3626e4139cf7e2f26ee637feeed) | fix | remove default for unit-test coverage option | +| [36978db7e](https://github.com/angular/angular-cli/commit/36978db7e494e4e5612aa2a8384199eeca7c4c2d) | fix | warn about performance of test.exclude in vitest configuration | +| [6ec36f5be](https://github.com/angular/angular-cli/commit/6ec36f5bee05d97c10ca8d91d5746621ffb1fdb9) | fix | warn when vitest watch config conflicts with builder | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [9bdf782c8](https://github.com/angular/angular-cli/commit/9bdf782c838ab5820ec905d689a62ffc3b3cabe3) | fix | apply forwarded prefix and vary header in accept-language redirects | +| [628c58672](https://github.com/angular/angular-cli/commit/628c586728748e1c367fa7e363299eb79b1566ca) | fix | support '\*' in allowedHosts and warn about security risks | + + + + + +# 20.3.21 (2026-03-19) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [1dc6992a5](https://github.com/angular/angular-cli/commit/1dc6992a5ae6c5a1f16f22f6c94690d5cf218c38) | fix | disallow x-forwarded-prefix starting with a backslash | +| [0a2ff0b2b](https://github.com/angular/angular-cli/commit/0a2ff0b2b3aceb228c9447c19fb762df742d7265) | fix | ensure unique values in redirect response Vary header | +| [cdbac82a8](https://github.com/angular/angular-cli/commit/cdbac82a85b35f24c70a062eeb8a13b521831019) | fix | support custom headers in redirect responses | + + + + + +# 21.2.3 (2026-03-18) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [1505164bb](https://github.com/angular/angular-cli/commit/1505164bb2703254a2b25a76c7b3a1ff2fd76a85) | fix | use parsed package name for migrate-only updates | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [75fa94cad](https://github.com/angular/angular-cli/commit/75fa94cad26b0947e687ef94d50653cb7651d18c) | fix | alias createRequire banner import to avoid duplicate binding | +| [d009aa1ec](https://github.com/angular/angular-cli/commit/d009aa1ec7411b67b61b81003eb6181cde6f306f) | fix | only use external packages for polyfills when no local files are present | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [f3e0e82c2](https://github.com/angular/angular-cli/commit/f3e0e82c2cecc3d9ebb5b8acc6e64d2d88c4efbd) | fix | disallow x-forwarded-prefix starting with a backslash | +| [b8bcd59b4](https://github.com/angular/angular-cli/commit/b8bcd59b40496369a57de0b0b39d85f323af30c7) | fix | ensure unique values in redirect response Vary header | +| [84385411d](https://github.com/angular/angular-cli/commit/84385411d4542d60d635aea9063c1fd751deb607) | fix | support custom headers in redirect responses | + + + + + +# 21.2.2 (2026-03-11) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------- | +| [8447d9132](https://github.com/angular/angular-cli/commit/8447d913280a8fa09a842d11193ce77527d0f7a6) | fix | conditionally quote package names when adding dependencies based on host requirements | +| [d2f209823](https://github.com/angular/angular-cli/commit/d2f209823a524a6effde4910017547675c7a6166) | fix | preserve exact version in ng add when requested | +| [28f4d684a](https://github.com/angular/angular-cli/commit/28f4d684ae12f0e0860bf0ace8851fdddad1c068) | perf | avoid redundant package version resolution in ng add | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [06010294f](https://github.com/angular/angular-cli/commit/06010294f8fe7a4843f802aafba51703ce810f61) | fix | allow any `CHROME_BIN` for vitest playwright provider | +| [8dec0c62b](https://github.com/angular/angular-cli/commit/8dec0c62ba40af339f4fd0fa34f20cbed545cd71) | fix | normalize line endings for CSP hash generation | +| [58688ebd7](https://github.com/angular/angular-cli/commit/58688ebd727fe295adcb538a33b525867caf82bd) | fix | pass process environment variables to prerender workers | +| [4ca61647f](https://github.com/angular/angular-cli/commit/4ca61647f208ec0ab9bc06f64583696b0619c259) | fix | resolve assets correctly during i18n prerendering | + + + + + +# 20.3.20 (2026-03-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [0fd6823af](https://github.com/angular/angular-cli/commit/0fd6823af0adec23f7c3f1d531f45f6432afe555) | fix | pass process environment variables to prerender workers | + + + + + +# 21.2.1 (2026-03-05) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [ae4c28d00](https://github.com/angular/angular-cli/commit/ae4c28d0083d948489f4ba38c571b7f955400226) | fix | correct dev dependency detection logic in `ng add` | +| [465073bc1](https://github.com/angular/angular-cli/commit/465073bc1b2b0e9fa594698651a9e0afe747a74a) | fix | disable npm update notifier in package manager host | +| [36270634f](https://github.com/angular/angular-cli/commit/36270634f6ff5ab15896a8c2b345659511a8a276) | fix | ensure group members are updated to targeted version | +| [d87dba6af](https://github.com/angular/angular-cli/commit/d87dba6af1116de0838d8683cd69fd31ed9811fd) | fix | ignore unknown files when formatting schematic changes | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [72d466aa0](https://github.com/angular/angular-cli/commit/72d466aa04d4d0cc4d654410bcb6dd44f0de3357) | fix | prevent adding test dependencies when minimal option is enabled | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [0019d1c8e](https://github.com/angular/angular-cli/commit/0019d1c8e1494295a754063dbf936e1cd40d05bd) | fix | update copy-webpack-plugin to v14.0.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------ | +| [6ad860863](https://github.com/angular/angular-cli/commit/6ad8608636ad48ae140cc7299a32e0358c761fcc) | fix | bundle polyfills to preserve execution order in dev server | +| [d17397375](https://github.com/angular/angular-cli/commit/d1739737564fbcc3e4c5a6c3369046cccf0f6120) | fix | conditionally allow `vi.mock` for non-relative imports | +| [0d49f86ed](https://github.com/angular/angular-cli/commit/0d49f86edf5592f0266c6d6689ab4d55b27b2d8d) | fix | resolve style include paths relative to `ng-package.json` in unit-test builder | +| [584f6a2d9](https://github.com/angular/angular-cli/commit/584f6a2d95ac4bdd9f20d918c6700ea79227cc92) | fix | treat empty browsers array as undefined in unit-test builder | +| [6699cdc9b](https://github.com/angular/angular-cli/commit/6699cdc9bfbabc3de2ff0cf03acfd6989dc5596c) | perf | fix memory leak in `ng serve` with i18n | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [43a9dfa66](https://github.com/angular/angular-cli/commit/43a9dfa663c386217c9a654f0e80af74823fcf6a) | fix | improve header validation logic | +| [dee3717b3](https://github.com/angular/angular-cli/commit/dee3717b3faae9ea75d0a5e53c925f915949b8d0) | fix | introduce DI token to signal route discovery process | + + + + + +# 20.3.19 (2026-03-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [0299b4d1a](https://github.com/angular/angular-cli/commit/0299b4d1aca13f11a06e2e92c593fe3e20906d23) | fix | update copy-webpack-plugin to v14.0.0 | + + + + + +# 20.3.18 (2026-02-26) + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [39596d529](https://github.com/angular/angular-cli/commit/39596d529f831f72a2134bc3c9ac163867ff5702) | fix | update `ajv` to `8.18.0` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [f668e2778](https://github.com/angular/angular-cli/commit/f668e2778c4c4dbecc8a1c6831c092f5512d1ec1) | fix | update rollup to 4.59.0 | + + + + + +# 19.2.22 (2026-02-26) + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [0a01aecd9](https://github.com/angular/angular-cli/commit/0a01aecd92a5dfa1a254429481906a88be725c5e) | fix | update `ajv` to `8.18.0` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [79f59412a](https://github.com/angular/angular-cli/commit/79f59412a17eeabd2ad5df1d614ed634354d1eee) | fix | update rollup to 4.59.0 | + + + + + +# 21.2.0 (2026-02-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [0dd04f289](https://github.com/angular/angular-cli/commit/0dd04f289e555a4a8af7bdadabe300da74701e3b) | feat | add markdown files to Prettier's formatting list | +| [fbae1b6ab](https://github.com/angular/angular-cli/commit/fbae1b6ab384186ae69e804c54815cea80e6a600) | feat | automatic formatting files modified by schematics | +| [91b9d281f](https://github.com/angular/angular-cli/commit/91b9d281fc88a242aa6e5dd5495e275990d926ef) | feat | integrate file formatting into update migrations | +| [98a24d040](https://github.com/angular/angular-cli/commit/98a24d0401f36f484dc9c4d8b0f5284ffa524f19) | feat | standardize MCP tools around workspace/project options | +| [d9cd609c5](https://github.com/angular/angular-cli/commit/d9cd609c5d13fe492b1f31973d9be518f8529387) | fix | correctly parse scoped packages in yarn classic list output | +| [5b05f2500](https://github.com/angular/angular-cli/commit/5b05f25005621828565585692b1d7a67c5f0fec8) | fix | enable shell option for Prettier execution on Windows platforms | +| [25b8a157d](https://github.com/angular/angular-cli/commit/25b8a157df70fb0d2c4e6c5438a50ec12e3abc0c) | fix | quote complex range specifiers in package manager | +| [6f29a8c35](https://github.com/angular/angular-cli/commit/6f29a8c35abb8928d4e7ea01958192dd2a83491d) | fix | renamed files by their new path in the schematic workflow | +| [201a036f2](https://github.com/angular/angular-cli/commit/201a036f204a6940f70a36a507a4a53d144b5768) | fix | simplify Angular version compatibility checks and add special handling for local builds of new major versions | +| [cdd26bb66](https://github.com/angular/angular-cli/commit/cdd26bb66d8ab334f76323c2b5cae1aa8ce815f6) | fix | validate package manager version using `semver.valid` and throw an error if invalid | +| [bc363af8b](https://github.com/angular/angular-cli/commit/bc363af8bc40f117a4e35ec9eb7eedf69f5b5b37) | perf | optimize package manager discovery with stat-based probing | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [aa7381efd](https://github.com/angular/angular-cli/commit/aa7381efd213eff70a8004731a7e2b06a60cb8c2) | feat | add a '.prettierrc' file to generated workspaces and add Prettier as dev dependency | +| [f80db6fb7](https://github.com/angular/angular-cli/commit/f80db6fb714aa326f6ed03a8a51090ca59ad0955) | feat | add ng-add support for Vitest browser providers | +| [5d1df50d8](https://github.com/angular/angular-cli/commit/5d1df50d8b84b453570ae5fd9ab6f949bbc11649) | fix | add actionable feedback to vitest-browser schematic | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [ece30f235](https://github.com/angular/angular-cli/commit/ece30f2359c2dc794b0c9272447f623a121e88b0) | feat | add headless option to unit-test builder | +| [cad7a7c0f](https://github.com/angular/angular-cli/commit/cad7a7c0ff3778f04820a99ad0aa9d74f1067fd5) | feat | run vitest browser with playwright with OS theme | +| [0b4982720](https://github.com/angular/angular-cli/commit/0b4982720e111bf5029bcf97f7e0ce2658c42d43) | fix | adjust sourcemap sources when Vitest wrapper is bypassed | +| [1f114a9e8](https://github.com/angular/angular-cli/commit/1f114a9e8b9bddd53e01016a2d7cb211a04eee48) | fix | bundle setup files in unit-test builder for Vitest | +| [fd5cb28c8](https://github.com/angular/angular-cli/commit/fd5cb28c8082417288a896b89bde659bb0dc92e2) | fix | explicitly fail when using Vitest runtime mocking | +| [dc899e8a5](https://github.com/angular/angular-cli/commit/dc899e8a530979de8e9579f2281b681e6f737a62) | fix | normalize `allowedHosts` in dev-server | +| [26bbea12f](https://github.com/angular/angular-cli/commit/26bbea12f872c18e59de05d3c51cc11dd0a09cda) | fix | serve extensionless assets without transformation | + + + + + +# 21.1.5 (2026-02-23) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [8695d6063](https://github.com/angular/angular-cli/commit/8695d6063e4c4123a37a3450c7db3a7c9d99050a) | fix | prevent open redirect via X-Forwarded-Prefix header | +| [e4d445ec6](https://github.com/angular/angular-cli/commit/e4d445ec674841dd1d374c83344c0a704ee3807c) | fix | validate host headers to prevent header-based SSRF | + + + + + +# 20.3.17 (2026-02-23) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [8700e18d7](https://github.com/angular/angular-cli/commit/8700e18d7cf175d80fe6ce6205589767b7870c1c) | fix | prevent open redirect via X-Forwarded-Prefix header | +| [67582a946](https://github.com/angular/angular-cli/commit/67582a946808d2c021cbcfacbf203ef58a6fbded) | fix | validate host headers to prevent header-based SSRF | + + + + + +# 19.2.21 (2026-02-23) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [288e22816](https://github.com/angular/angular-cli/commit/288e228161e3c0cceb392dd5fc24d07b129a3a64) | fix | prevent open redirect via X-Forwarded-Prefix header | +| [2a72d7483](https://github.com/angular/angular-cli/commit/2a72d7483d87ccdcfa0c5148f34a4c6ebb6c6cf9) | fix | validate host headers to prevent header-based SSRF | + + + + + +# 19.2.20 (2026-02-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [0e5421ba7](https://github.com/angular/angular-cli/commit/0e5421ba78814cf11e4d4510e930eaacc6458662) | fix | update webpack to 5.105.0 | + + + + + +# 21.1.4 (2026-02-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7a9dd6b47](https://github.com/angular/angular-cli/commit/7a9dd6b47e2191862c64355b10abaeead189759f) | fix | correctly resolve absolute setup file paths in Vitest | + + + + + +# 20.3.16 (2026-02-09) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [656888a25](https://github.com/angular/angular-cli/commit/656888a250af060c110ae87024b0e475b079c23d) | fix | update dependency @modelcontextprotocol/sdk to v1.26.0 | + + + + + +# 21.1.3 (2026-02-05) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [a18196a10](https://github.com/angular/angular-cli/commit/a18196a1096e5eb69cf64102943781d34c4389bf) | fix | warn when production configuration is missing for service worker | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [6d05d27ca](https://github.com/angular/angular-cli/commit/6d05d27ca097b16efb139bcee1c45b1b51dfe746) | fix | address Node.js deprecation DEP0190 | + + + + + +# 21.1.2 (2026-01-28) + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [e7458c81d](https://github.com/angular/angular-cli/commit/e7458c81d669296c767fca272f80054d3b434a72) | fix | Add boolean type inference for 'true' and 'false' string values in argument parsing | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [d66f1fe64](https://github.com/angular/angular-cli/commit/d66f1fe647560498d78e9db362a5fdf1ab492326) | fix | Add boolean type inference for 'true' and 'false' string values in argument parsing | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [80911af67](https://github.com/angular/angular-cli/commit/80911af673365af3bcb86760bebb4200967ca433) | fix | loosen Vitest dependency checks when runnerConfig is used | +| [2d30639d3](https://github.com/angular/angular-cli/commit/2d30639d3c5a0eb3a1f40ec4cd8fe157f28f19f5) | fix | support merging coverage thresholds with Vitest runnerConfig | + + + + + +# 21.1.1 (2026-01-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [151b69587](https://github.com/angular/angular-cli/commit/151b69587d982130bb80461e4fe143c8f047e68d) | fix | Remove nonexistent link from MCP response | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [9da6d8fa7](https://github.com/angular/angular-cli/commit/9da6d8fa7640f321547b0308accbfcafefee5b0b) | fix | correct vscode MCP configuration for new projects | +| [361758c75](https://github.com/angular/angular-cli/commit/361758c75c08d80100ab2307578ef74e8ec288e2) | fix | remove special characters from jasmine-vitest report filename | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [1b7e3307a](https://github.com/angular/angular-cli/commit/1b7e3307afb21224ade885e5be64f16c1de860c9) | fix | allow application assets in workspace root | +| [d1e596dc5](https://github.com/angular/angular-cli/commit/d1e596dc53b9a43063308fa775cd9ad8f60cad8b) | fix | prevent incorrect catch binding removal in downleveled for-await | +| [98ef0981a](https://github.com/angular/angular-cli/commit/98ef0981a2e3aee3c6fd2b6cf0561f6919487723) | fix | update undici to v7.18.2 | + + + + + +# 20.3.15 (2026-01-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [795d65413](https://github.com/angular/angular-cli/commit/795d654138701a03d4d793d3299ff4f33e427a03) | fix | update pacote to v21.0.4 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [ffc72cbc5](https://github.com/angular/angular-cli/commit/ffc72cbc52e23cb545476b3fdefc7e5f170eb55d) | fix | update webpack to version 5.104.1 | + + + + + +# 21.1.0 (2026-01-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [772e6efe7](https://github.com/angular/angular-cli/commit/772e6efe7acb2d2318a57ba77092a85fc286c51b) | feat | add 'test' and 'e2e' MCP tools | +| [8efb86318](https://github.com/angular/angular-cli/commit/8efb8631842401e219e20dd7955512d4a90a28a3) | feat | Add "all" as an experimental tool group | +| [c3c9ac506](https://github.com/angular/angular-cli/commit/c3c9ac5067275461e2d8caefba81ac9701949776) | feat | Add MCP tools for building and running devservers | +| [d635a6c63](https://github.com/angular/angular-cli/commit/d635a6c6335d0838fc0977f6742f6aa9f769c527) | feat | add signal forms lessons | +| [d8b76e93d](https://github.com/angular/angular-cli/commit/d8b76e93d3e9e4e7bd7ad6e12fdf59cd663cbb8e) | fix | correctly handle yarn classic tag manifest fetching | +| [7ab5c0b0a](https://github.com/angular/angular-cli/commit/7ab5c0b0a1c637f3e0adb71486e5e7e8716561e4) | fix | correctly spawn package managers on Windows in new abstraction | +| [348096623](https://github.com/angular/angular-cli/commit/348096623326857a5d8cf77d56712776e1190180) | fix | enhance list_projects MCP tool file system traversal and symlink handling | +| [316fca862](https://github.com/angular/angular-cli/commit/316fca8626d51b28ea8cd840f3815b7c6dfcfffa) | fix | handle array output from npm view in manifest parser | +| [032257a6d](https://github.com/angular/angular-cli/commit/032257a6d00360d2c4e6d5406409dcfa5b27d1d5) | fix | improve signal forms lesson examples in AI tutor | +| [18d74dde8](https://github.com/angular/angular-cli/commit/18d74dde8938dbe566df80753d5c148c19040179) | fix | rename mcp devserver tools to comply with naming spec | +| [1ad773671](https://github.com/angular/angular-cli/commit/1ad773671afa2849a966f9974cb30e7c8e8ed7d4) | fix | update dependency @modelcontextprotocol/sdk to v1.25.2 | +| [45d4f5668](https://github.com/angular/angular-cli/commit/45d4f5668018362f90fcc4cdc487470286f03c02) | fix | update yarn berry package manager configuration | +| [122ed27c9](https://github.com/angular/angular-cli/commit/122ed27c906613b7966cbda8fe2f36dbe6f60198) | fix | use project-local temporary directory in ng add | +| [a15db28b2](https://github.com/angular/angular-cli/commit/a15db28b29f6f43bef1ed1ca7c6a963d9943f801) | perf | cache resolved specific version in package manager abstraction | +| [240588b7e](https://github.com/angular/angular-cli/commit/240588b7e3c8698c83110793ab98d20caee4e1a4) | perf | optimize `ng add` version discovery | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [36cf3afb4](https://github.com/angular/angular-cli/commit/36cf3afb485a01f86c4c90f136b38a3cf338e313) | feat | add browserMode option to jasmine-vitest schematic | +| [e71a72ffd](https://github.com/angular/angular-cli/commit/e71a72ffdc426e26bfb4f0bb92e8f5795a621c18) | feat | generate detailed migration report for `refactor-jasmine-vitest` | +| [18cf6c51b](https://github.com/angular/angular-cli/commit/18cf6c51b72ce5c7f23012585ed992cf91cef5ed) | fix | add MCP configuration file to new workspaces | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [1eda0a99f](https://github.com/angular/angular-cli/commit/1eda0a99f89f625f8db1ecfe4873a7672e625401) | feat | directly support ng-packagr in unit-test builder | +| [87175f9dc](https://github.com/angular/angular-cli/commit/87175f9dcdb7349dc8701fa1d5cf61c1b8542d42) | feat | disable TestBed teardown during debugging in Vitest | +| [1e39c77a4](https://github.com/angular/angular-cli/commit/1e39c77a4fe272ccab1a1d8bd58eef1ce608a6c7) | fix | inject source-map-support for Vitest browser tests | +| [3fd7dcd76](https://github.com/angular/angular-cli/commit/3fd7dcd764be0d0afb9cd792d53268d6f314df83) | fix | normalize roots to POSIX in test discovery for Windows compatibility | +| [164e7dbbc](https://github.com/angular/angular-cli/commit/164e7dbbc2b06bbd5aab84937c903e0590591c60) | fix | resolve test files correctly on Windows when using non-C drives | +| [ad99e00ad](https://github.com/angular/angular-cli/commit/ad99e00ad7edd17e369777c8d38b4137ea736121) | fix | simplify SSL handling for `ng serve` with SSR ([#31722](https://github.com/angular/angular-cli/pull/31722)) | + + + + + +# 21.0.6 (2026-01-14) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [730ae6609](https://github.com/angular/angular-cli/commit/730ae6609b847802124a5c6e12c77522af54b967) | fix | handle platform destruction during rendering | + + + + + +# 21.0.5 (2026-01-07) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [249563749](https://github.com/angular/angular-cli/commit/24956374941f3753a24470bfdafaf6d3645a3ddb) | fix | use narrower types for new MCP TS SDK compatibility | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------- | +| [cbd0718b9](https://github.com/angular/angular-cli/commit/cbd0718b93c0d51331af772f578b6e0dc3dfbe9e) | fix | move 'provideZoneChangeDetection' to the root module | +| [33f7cf761](https://github.com/angular/angular-cli/commit/33f7cf761aa222e1754e039533831c729895e5e6) | fix | update application schematics for module-based apps to use 'provideZoneChangeDetection' | +| [37b14d1f7](https://github.com/angular/angular-cli/commit/37b14d1f7b18a607ada3e9a113c16f5e8a7669ef) | fix | update default app component message | +| [c37dccb09](https://github.com/angular/angular-cli/commit/c37dccb093f7b48027fac27bd78abe7f3e3e15ad) | fix | update default app component welcome message | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [2b9be3a7c](https://github.com/angular/angular-cli/commit/2b9be3a7cadc444e67d629c2a74c54f0c65f4a54) | fix | ensure correct project targeting during Vitest debugging | + + + + + +# 20.3.14 (2026-01-07) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [ff366499e](https://github.com/angular/angular-cli/commit/ff366499eff87e9943e25904fd06d109a0fa0075) | fix | update dependency @modelcontextprotocol/sdk to v1.25.2 | + + + + + +# 21.0.4 (2025-12-18) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [b671245b9](https://github.com/angular/angular-cli/commit/b671245b9d3ba98ac0f66dbd34f272539113be61) | fix | improve VS Code background compilation start/end detection | +| [85a28dec7](https://github.com/angular/angular-cli/commit/85a28dec771cce77a3ffee35f419b5fedca807b8) | fix | remove `inlineSources` from library tsconfig template | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [deb4fff61](https://github.com/angular/angular-cli/commit/deb4fff6196d2eb147e358a7143e2ada2b6114c9) | fix | add browser condition to resolver for vitest | +| [570ce8d3e](https://github.com/angular/angular-cli/commit/570ce8d3eeb280eeb6dca6ba54593c9325674741) | fix | allow non-prefixed requests when using SSR and base href | +| [4dd3c1a32](https://github.com/angular/angular-cli/commit/4dd3c1a324c8e90808cc1c5febf65c8fa49dd3b9) | fix | conditionally manage Vitest UI option | +| [4b8b7caec](https://github.com/angular/angular-cli/commit/4b8b7caece41f86746321a98786dfdff499582b6) | fix | ensure tests run when compilation error is resolved | +| [bef4fcecb](https://github.com/angular/angular-cli/commit/bef4fcecb6d116f9f022da845f06708cf29be02a) | fix | remove LmdbCacheStore export from private API | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [bb54747da](https://github.com/angular/angular-cli/commit/bb54747da69fb15b6c2ebb52b45a83cbff3231c8) | fix | add leading slash to well-known non-Angular URLs | +| [0cfe2e749](https://github.com/angular/angular-cli/commit/0cfe2e749f50b832c64bbba322eb0cef7ad40365) | fix | propagate status code to redirect | +| [eadadb848](https://github.com/angular/angular-cli/commit/eadadb848ca7fa45c4dda835af39400e017bbe1c) | fix | skip SSR processing for well-known non-Angular URLs like favicon.ico | + + + + + +# 21.0.3 (2025-12-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [5d85f416f](https://github.com/angular/angular-cli/commit/5d85f416f43b6bcd07b28ab920cb40c61a83ebdd) | fix | conditionally provide Zone.js change detection in the built-in test main file | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [778b4cffc](https://github.com/angular/angular-cli/commit/778b4cffc03e7c137940e3b8c89f290fd226cf17) | fix | Add custom middleware for to present an Angular-tailored message | +| [9b02ab2ee](https://github.com/angular/angular-cli/commit/9b02ab2ee0a36aa6aafd94ea8059b48679845860) | fix | Ensure disposal of close-javascript-transformer | +| [0fc7d576e](https://github.com/angular/angular-cli/commit/0fc7d576e53f45601fdbeb95f4a853ebceae4fad) | fix | ensure locale base href retains leading slash ([#32040](https://github.com/angular/angular-cli/pull/32040)) | +| [b141670a2](https://github.com/angular/angular-cli/commit/b141670a2453dd0ea5fe6aa22ddae7175893d813) | fix | inject testing polyfills in Karma unit-test executor | +| [88c18ce68](https://github.com/angular/angular-cli/commit/88c18ce68585726652b88b10ce090039fbe1829f) | fix | support NODE_EXTRA_CA_CERTS in SSR SSL plugin | + + + + + +# 21.0.2 (2025-12-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [f1a7116cd](https://github.com/angular/angular-cli/commit/f1a7116cdff1bd83b26b0d64cea14ec4e8084583) | fix | update `@modelcontextprotocol/sdk` to v1.24.0 | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [dc6d9469e](https://github.com/angular/angular-cli/commit/dc6d9469ea494bbfee7da191774e9fa3c0baf30a) | fix | remove lazy imports in node tasks | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [f8a1939fd](https://github.com/angular/angular-cli/commit/f8a1939fdf5abbc1de439d288d9357d4f92a72a9) | fix | add filename truncation to test discovery | +| [86dd3297f](https://github.com/angular/angular-cli/commit/86dd3297f7ad81788713cfd8dae48c0fad4b89ab) | fix | allow overriding Vitest coverage `reportsDirectory` option | + + + + + +# 20.3.13 (2025-12-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cfbb61602](https://github.com/angular/angular-cli/commit/cfbb61602daf32c5b942ea84702fc3638aa111e7) | fix | update `@modelcontextprotocol/sdk` to v1.24.0 | + + + + + +# 21.0.1 (2025-11-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [363496ae0](https://github.com/angular/angular-cli/commit/363496ae0d2850545274cd7fe4dc6902ccb64e10) | fix | ensure dependencies are resolved correctly for node modules directory check | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [2f58705cb](https://github.com/angular/angular-cli/commit/2f58705cb5389019ceb49616a0e4ec3f92a558ed) | fix | add missing imports for lifecycle hooks in jasmine-vitest migration | +| [c973bb9ca](https://github.com/angular/angular-cli/commit/c973bb9cafc8d59b901a9d763347f4b615257867) | fix | add mock names to createSpyObj transformation | +| [4534c9848](https://github.com/angular/angular-cli/commit/4534c9848745eea516bdb58d86914252c35b5b9c) | fix | do not set `esModuleInterop` and `moduleResolution` when module is `preserve` | +| [16d898e75](https://github.com/angular/angular-cli/commit/16d898e7587036d68786cebe764da08304559d41) | fix | fix migration of `jasmine.clock().mockDate()` | +| [21c3eac72](https://github.com/angular/angular-cli/commit/21c3eac726c198132af760ffacc0dab9dfccb430) | fix | handle createSpyObj without base name on refactor-jasmine-vitest | +| [b8c99aa4c](https://github.com/angular/angular-cli/commit/b8c99aa4c909647285d1dcc61a2bb97a36100c63) | fix | improve safety of done callback transformation | +| [4a71e06fc](https://github.com/angular/angular-cli/commit/4a71e06fcafaadbcb820d285c0c186aa0e92f158) | fix | silently skip when the build target already uses one of the new builders | +| [2ffdae421](https://github.com/angular/angular-cli/commit/2ffdae42149b0f3da44f96271af1bca6d09b7ed5) | fix | support testRunner option in library schematic | +| [145de4a58](https://github.com/angular/angular-cli/commit/145de4a584ce8f72746704547282299306d9bafb) | fix | warn about loose matching in arrayWithExactContents | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [d097df2d7](https://github.com/angular/angular-cli/commit/d097df2d7088dd2bb97643c3acfc1f977a767dd9) | fix | correct Vitest coverage path resolution for JSDOM on Windows | +| [cdb607ada](https://github.com/angular/angular-cli/commit/cdb607ada4bf9aaec6ed8aafd8826d782fd13109) | fix | correctly configure per-browser headless mode in Vitest runner | +| [244931ece](https://github.com/angular/angular-cli/commit/244931ece877a1cacd1cfce64314e04a52526f80) | fix | correctly invoke `isTTY` as a function | +| [54d542738](https://github.com/angular/angular-cli/commit/54d542738e23c275ac6827f19da92213c405f9e2) | fix | ensure correct URL joining for prerender routes | +| [a28b38bbe](https://github.com/angular/angular-cli/commit/a28b38bbeba0977e99142a15d1ecc77c15abc416) | fix | force dev-server to use HTTP/1.1 when using SSR with SSL | +| [59ff867f0](https://github.com/angular/angular-cli/commit/59ff867f0d2e7f7f88480deefa0ee470c037197a) | fix | normalize `--include` paths to posix | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [03e231216](https://github.com/angular/angular-cli/commit/03e231216d3b8ba0de81da53a446eff0c701658d) | fix | handle `X-Forwarded-Prefix` and `APP_BASE_HREF` in redirects | +| [3cac01882](https://github.com/angular/angular-cli/commit/3cac0188271175e12cc238c6610b542f3ae14db3) | fix | prevent redirect loop with encoded query parameters | + + + + + +# 20.3.12 (2025-11-25) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [25bb7e65c](https://github.com/angular/angular-cli/commit/25bb7e65c4fc7e401c658126c53b0b7a13d62965) | fix | ensure correct URL joining for prerender routes | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [cceb86296](https://github.com/angular/angular-cli/commit/cceb862969e541a5f54b689a6439e32773eafe65) | fix | handle `X-Forwarded-Prefix` and `APP_BASE_HREF` in redirects | +| [1abe68ad8](https://github.com/angular/angular-cli/commit/1abe68ad87f9b892734117a087b5775068bd232b) | fix | prevent redirect loop with encoded query parameters | + + + + + +# 21.0.0 (2025-11-19) + +## Breaking Changes + +### @angular/cli + +- The `ng` commands will no longer automatically detect and use `cnpm` as the package manager. As an alternative use the `.npmrc` file to ensure npm uses the cnpm registry. + +### @angular/build + +- - TypeScript versions older than 5.9 are no longer supported. +- The `javascriptEnabled` option for Less is no longer supported. Projects relying on inline JavaScript within Less files will need to refactor their stylesheets to remove this dependency. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e417c89f9](https://github.com/angular/angular-cli/commit/e417c89f9e9cfe0ce50ffbc72ef555793605aea1) | feat | Add `addTypeToClassName` option to relevant schematics | +| [ede5e52bc](https://github.com/angular/angular-cli/commit/ede5e52bc701c42948bd98826cd4fa901350015c) | feat | add `include` option to jasmine-to-vitest schematic | +| [c119910f4](https://github.com/angular/angular-cli/commit/c119910f4505e280eea83ea6647b6d279a46f36d) | feat | add AGENTS.md support to ai-config schematic | +| [d0d2a17b8](https://github.com/angular/angular-cli/commit/d0d2a17b8adb2c1ce6eee70494f5d2298622c40e) | feat | add Jasmine spy API transformations to jasmine-to-vitest schematic | +| [e7d955bed](https://github.com/angular/angular-cli/commit/e7d955bedd5ca6957903cb73f8ebe06823a808da) | feat | add matcher transformations to jasmine-to-vitest schematic | +| [629f5cb18](https://github.com/angular/angular-cli/commit/629f5cb181fee562645baf02b44ebb3b39f3fb06) | feat | add misc transformations to jasmine-to-vitest schematic | +| [4912f3990](https://github.com/angular/angular-cli/commit/4912f39906b11a3212f11d5a00d577e2a0bacab4) | feat | add Tailwind CSS option to application schematic and `ng new` | +| [2a518016d](https://github.com/angular/angular-cli/commit/2a518016d9585dd4d16f90102d5409459ebba024) | feat | Applications are zoneless by default | +| [2ffc527b1](https://github.com/angular/angular-cli/commit/2ffc527b1bbe237e9f732d3506ce3a75ca1ca9d0) | feat | configure Vitest for new projects and allow runner choice | +| [58474ec7d](https://github.com/angular/angular-cli/commit/58474ec7dd85fc34639c138d9b8d545affb50e3e) | feat | introduce initial jasmine-to-vitest unit test refactor schematic | +| [9f255f2b3](https://github.com/angular/angular-cli/commit/9f255f2b3cc435f3bea2f0266a137176ca599aef) | feat | set `packageManager` in `package.json` on new projects | +| [4e6c94f21](https://github.com/angular/angular-cli/commit/4e6c94f21e882c593cf11197900c29d693af9297) | feat | support different file name style guides in `ng new` | +| [77741f5ee](https://github.com/angular/angular-cli/commit/77741f5eec735f23b0f2865101471e045e4889b8) | fix | add 'update-typescript-lib' migration | +| [f89750b27](https://github.com/angular/angular-cli/commit/f89750b27866c307da546fe4f33da980693ca5c1) | fix | add `addImports` option to jasmine-vitest schematic | +| [9dab5780a](https://github.com/angular/angular-cli/commit/9dab5780a1befbd76ee9ba4c4e6ac2d3fd714bb9) | fix | add fixture.whenStable in spec files when zoneless apps | +| [8f0f6a5f1](https://github.com/angular/angular-cli/commit/8f0f6a5f113ffc9e81d99eeeba71f8054e2d3686) | fix | add migration to update `moduleResolution` to `bundler` | +| [e8feba9ee](https://github.com/angular/angular-cli/commit/e8feba9ee163f688c51d6463336474591e886647) | fix | add missing typeSeparator to main.ts.template file | +| [515b09c4f](https://github.com/angular/angular-cli/commit/515b09c4f28ef1c2eb911cb73135a6086dcab3c9) | fix | add Vitest config generation and runner checks | +| [0e83fe1a8](https://github.com/angular/angular-cli/commit/0e83fe1a87cc3dcbc9daa4440a050ae6aafc8042) | fix | add warnings and improve Karma config generation | +| [b91fa31f2](https://github.com/angular/angular-cli/commit/b91fa31f20b49ead021c72c271f67da38b340584) | fix | align Karma project generation with unified unit-test builder | +| [c967a447c](https://github.com/angular/angular-cli/commit/c967a447ce755fbf582ec35aa24bb6e0fa0043cf) | fix | correct spacing in application spec tsconfig | +| [00d941c43](https://github.com/angular/angular-cli/commit/00d941c433de718cf3c38033d5d68dd86f790291) | fix | correct style guide paths for standalone components | +| [e33e77d12](https://github.com/angular/angular-cli/commit/e33e77d12984446fe7bc77deb7438806817ba8a7) | fix | flag '--file-name-style-guide=2016' - wrong import in main.ts | +| [f35b9f331](https://github.com/angular/angular-cli/commit/f35b9f3310995b05d501f2abaec58dcd283e3aa0) | fix | improve comment preservation in jasmine-to-vitest | +| [6615fcf03](https://github.com/angular/angular-cli/commit/6615fcf037686cd96e97b469937b7f0736afaa77) | fix | issues in apps generated with '--file-name-style-guide=2016' flag | +| [e304821d5](https://github.com/angular/angular-cli/commit/e304821d5d789fab2725d3152612d3e5b6bd0dc7) | fix | make ai-config schematic non-destructive | +| [512ad282a](https://github.com/angular/angular-cli/commit/512ad282aecbfdf1e5c9e9700cc722addb949b68) | fix | preserve blank lines in jasmine-to-vitest schematic | +| [b524ba426](https://github.com/angular/angular-cli/commit/b524ba42625cd690177a300ca4843ef4edce035f) | fix | remove empty i18n-extract target for new projects | +| [8e6e0a293](https://github.com/angular/angular-cli/commit/8e6e0a2931bfb178e77cf2c9ca7f92a56c673449) | fix | remove explicit flag for host bindings | +| [afb4d3e37](https://github.com/angular/angular-cli/commit/afb4d3e377b11315a03563cb8c143c35d37f113a) | fix | remove extra space before async in spec templates | +| [b983ea8e5](https://github.com/angular/angular-cli/commit/b983ea8e5107420a910dbbc05c6b74f0ff6fbddd) | fix | respect skip-install for tailwind schematic | +| [54c4eae2a](https://github.com/angular/angular-cli/commit/54c4eae2aa49c6b45c41f0718a5915a10d426cb4) | fix | transform Jasmine type annotations in jasmine-to-vitest schematic | +| [14c0a9bac](https://github.com/angular/angular-cli/commit/14c0a9bacbb66b1db714ea7906c7d33f49c710fc) | perf | optimize AST traversal utilities | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [58d101d5e](https://github.com/angular/angular-cli/commit/58d101d5e78cf4c158dbaf52103639d56b84f9ed) | feat | add `--json` output to `ng version` | +| [d014630fa](https://github.com/angular/angular-cli/commit/d014630fad765ae3928b698122038cbe00d37102) | feat | add advanced filtering to MCP example search | +| [6d3a3c579](https://github.com/angular/angular-cli/commit/6d3a3c5799bde1bab5c3878e0783ffa6854e36ad) | feat | add ai-tutor mcp tool | +| [1c06b16a9](https://github.com/angular/angular-cli/commit/1c06b16a962d3c2cc122dc40e01c64bc8a8d754d) | feat | add builder info to `list_projects` MCP tool | +| [301b50da4](https://github.com/angular/angular-cli/commit/301b50da4cf99b3cd87940606121d076b4f241c6) | feat | add fallback support for packages without direct `ng add` functionality | +| [3040b777e](https://github.com/angular/angular-cli/commit/3040b777e40bc90fd1ed961e3d134875b3f9b464) | feat | add style language detection to list_projects tool | +| [45024e836](https://github.com/angular/angular-cli/commit/45024e836b4006cc48b18bb99d025ae1a572db12) | feat | add unit test framework detection to list_projects tool | +| [104c90768](https://github.com/angular/angular-cli/commit/104c90768000b3e0052ee7e7de2c5e04c1bffdaf) | feat | enhance `ng version` output with more details | +| [286b6204c](https://github.com/angular/angular-cli/commit/286b6204c825c990761a0d5e5b91bb439dd13655) | feat | make documentation search tool version-aware | +| [406315d09](https://github.com/angular/angular-cli/commit/406315d0939c62d9f4f39ce64b168e72bbdd588c) | feat | make find_examples tool version-aware | +| [68e711307](https://github.com/angular/angular-cli/commit/68e711307eae88a621698c2a9cc2abc30d44efc8) | feat | make get_best_practices tool version-aware | +| [50453fdee](https://github.com/angular/angular-cli/commit/50453fdeec4a00d88deada49d2dd0867bdb784fb) | feat | overhaul `ng version` command output | +| [1ee9ce3c9](https://github.com/angular/angular-cli/commit/1ee9ce3c93caff419f8095a91cf58601e3df3f74) | feat | promote MCP `find_examples` tool to a stable tool | +| [0d53e82d5](https://github.com/angular/angular-cli/commit/0d53e82d5ed8986603c2005fc06041dd076b08c6) | feat | provide detailed peer dependency conflict errors in ng add | +| [f513089e2](https://github.com/angular/angular-cli/commit/f513089e276acf5a7c4f6879a95e2d6ed78ae67d) | feat | remove direct support for `cnpm` | +| [c17d7a929](https://github.com/angular/angular-cli/commit/c17d7a929adccb77f3c2c33e70005f50032d8cae) | fix | add schema versioning and metadata to example database | +| [dbf1aaf70](https://github.com/angular/angular-cli/commit/dbf1aaf70bc3e3dd0de05d760bafacc43b34dce8) | fix | add snippet support to example search MCP tool | +| [dfb4242b3](https://github.com/angular/angular-cli/commit/dfb4242b347365f3a2c6d006f07a16c982ff4dbe) | fix | add vitest to version command output | +| [11cee1acb](https://github.com/angular/angular-cli/commit/11cee1acb59afbad1ef88d8340b5438f7dbefe57) | fix | correct boolean parsing in MCP example front matter | +| [122a8c0e2](https://github.com/angular/angular-cli/commit/122a8c0e27342db79eb4d38e23032548054709b9) | fix | correct frontmatter parsing in MCP examples tool | +| [431106559](https://github.com/angular/angular-cli/commit/431106559d6e75f5113876a3f92fdf4dc4b2114d) | fix | correct query in find_examples to prevent runtime error | +| [def412a55](https://github.com/angular/angular-cli/commit/def412a558d71cb51fa16d826418bd0ed0a085cf) | fix | enhance find_examples MCP tool with structured output | +| [0922a033f](https://github.com/angular/angular-cli/commit/0922a033f546b38f83d1cae524cf7237dd37a2ac) | fix | improve JSON schema parsing for command options | +| [f099c9157](https://github.com/angular/angular-cli/commit/f099c91570b3cd748d7138bd18a4898a345549db) | fix | improve list_projects MCP tool to find all workspaces in monorepos | +| [1be35b343](https://github.com/angular/angular-cli/commit/1be35b3433179481be85ea1cb892d66170e0aebe) | fix | promote zoneless migration MCP tool to stable | +| [e5aed6d65](https://github.com/angular/angular-cli/commit/e5aed6d655ed92ea6eb3ac03716b8a02a5f731d6) | fix | show planned actions in `ng add` dry run | +| [4deac3ec7](https://github.com/angular/angular-cli/commit/4deac3ec785b1a53156aac90441d0ed129df71ef) | fix | support multi-database search in find_examples MCP tool | +| [aeb49dd52](https://github.com/angular/angular-cli/commit/aeb49dd52bf88785a193fcb6caa0b36aaeef1d37) | perf | cache dependency lookups during `ng add` | +| [5e534090e](https://github.com/angular/angular-cli/commit/5e534090e25e00a9fafbce2867030e7fdb0efbf6) | perf | parallelize peer dependency checks in `ng add` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [6e395fc0c](https://github.com/angular/angular-cli/commit/6e395fc0c4505dd32b3237ea116e8db6bde25758) | fix | ensure vitest code coverage handles virtual files correctly | +| [53899511a](https://github.com/angular/angular-cli/commit/53899511afe5665541984085914a313390af6ce2) | fix | expand `jest` and `jest-environment-jsdom` to allow version 30 | +| [7a8c94615](https://github.com/angular/angular-cli/commit/7a8c94615164e114533fae0f84796a374dc1b47b) | fix | make zone.js optional in server and app-shell builders | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [00426e315](https://github.com/angular/angular-cli/commit/00426e3150c846913a5aa31510b5a1126df9e570) | feat | add --list-tests flag to unit-test builder | +| [a908bf3d4](https://github.com/angular/angular-cli/commit/a908bf3d4e8a59f3546f117bcc1f12fd69ad2d0b) | feat | add 'filter' option to unit-test builder | +| [3e0209d0a](https://github.com/angular/angular-cli/commit/3e0209d0a6bc6d7985d6294fc1430cdbe4d2d9a6) | feat | add `browserViewport` option for vitest browser tests | +| [3b7dabbf1](https://github.com/angular/angular-cli/commit/3b7dabbf1df9b2b6ca9ffc6c038abb6e40b6df2b) | feat | add advanced coverage options to unit-test builder | +| [c0b00d78e](https://github.com/angular/angular-cli/commit/c0b00d78ec37426f4474f473ddf9e627a0dd23df) | feat | add reporter output file option for unit-test | +| [66dd6dd83](https://github.com/angular/angular-cli/commit/66dd6dd835d6b489e6b4be2138aa443e11bfa076) | feat | allow options for unit test reporters | +| [a90bea5b5](https://github.com/angular/angular-cli/commit/a90bea5b51c6978441919ed2af85c090fe99fd38) | feat | support `.test.ts` files by default in unit test builder | +| [b2f048773](https://github.com/angular/angular-cli/commit/b2f048773c6014022983e7ccc52cb760619d8a1b) | fix | add --ui option for Vitest runner | +| [530d9270e](https://github.com/angular/angular-cli/commit/530d9270e87786594dd8d1956b0806a28650db25) | fix | add `define` option to dev-server | +| [b554bd73a](https://github.com/angular/angular-cli/commit/b554bd73a9c248d986ed718028edf52ab5da6ccf) | fix | add temporary directory cleanup for Vitest executor | +| [c6176f6df](https://github.com/angular/angular-cli/commit/c6176f6dffdae5c8d8708a1dd20fb51ca72e3c24) | fix | add upfront dependency validation for unit-test runners | +| [63c98741a](https://github.com/angular/angular-cli/commit/63c98741adcd21701b8bc572e9410cc1cf4f91c3) | fix | add webcontainer support for Vitest browser provider | +| [fcdbf6c19](https://github.com/angular/angular-cli/commit/fcdbf6c19b5a8549011aebec9e517feb12a99895) | fix | allow `globals` to be set to false | +| [542d52868](https://github.com/angular/angular-cli/commit/542d528683cc0e51fd5a55ed6dbf43168f7a51a5) | fix | allow custom runner configuration file for unit-test | +| [0505f954d](https://github.com/angular/angular-cli/commit/0505f954dcf3b3339749ff461592d46d8ecc5e23) | fix | allow unit-test progress option passthrough for building | +| [931c62d20](https://github.com/angular/angular-cli/commit/931c62d20915c6c772b61d76ab88657c0858f6bd) | fix | allow unit-test runner config with absolute path | +| [a11dd31f0](https://github.com/angular/angular-cli/commit/a11dd31f0c80a189e7dcb3172c89a731cfc34702) | fix | configure Vitest cache to use Angular cache | +| [abf003268](https://github.com/angular/angular-cli/commit/abf003268c6cb18f0944665b0b3f2794c9469c3e) | fix | correct Vitest builder watch mode execution | +| [f05ffd104](https://github.com/angular/angular-cli/commit/f05ffd104255e86fe93f3736e1430f940cb83007) | fix | correct Vitest coverage include handling for virtual files | +| [cd5c92b99](https://github.com/angular/angular-cli/commit/cd5c92b99a5d8e9cb991a2551f564353c3df0fbe) | fix | correct Vitest coverage reporting for test files | +| [07f712253](https://github.com/angular/angular-cli/commit/07f712253bb6c37d27ae7be9845f497002b0780c) | fix | correctly handle absolute paths and casing in test discovery | +| [bf468e1eb](https://github.com/angular/angular-cli/commit/bf468e1eb1050c60359b6f52692ce0db286982c2) | fix | direct check include file exists in unit-test discovery | +| [50e330d33](https://github.com/angular/angular-cli/commit/50e330d331fc8cfc4c12f7258012305ecb419d2d) | fix | disable glob directory expansion when finding tests | +| [49b65aba8](https://github.com/angular/angular-cli/commit/49b65aba8d7cd2839776e987366b981d7762760c) | fix | disable Vitest test isolation by default | +| [1529595d4](https://github.com/angular/angular-cli/commit/1529595d4a8d8ff9251d1680b1a23bf4ef817db0) | fix | drop support for TypeScript 5.8 | +| [a44f8fa94](https://github.com/angular/angular-cli/commit/a44f8fa94bbf6ce8cdee05552dc56124507c6971) | fix | dynamically select Vitest DOM environment | +| [ae35543af](https://github.com/angular/angular-cli/commit/ae35543af7f5b3a5328c39fd4617d61b48067357) | fix | enhance Vitest config merging and validation | +| [fec106b60](https://github.com/angular/angular-cli/commit/fec106b60553394aab51d713e5437a713085089b) | fix | enhance Vitest dependency externalization and pre-bundling | +| [f7c4a4c1d](https://github.com/angular/angular-cli/commit/f7c4a4c1dcd575dec82cc06597e3d6620b1be729) | fix | enhance Vitest resolution for optimal package loading | +| [ee5e127d5](https://github.com/angular/angular-cli/commit/ee5e127d551269fa9a3e39b9b28e38d7ab35806f) | fix | ensure `ɵgetOrCreateAngularServerApp` is always defined after errors | +| [0830f4fb5](https://github.com/angular/angular-cli/commit/0830f4fb549e2c45b1ef752dd42f002a1347d7c8) | fix | ensure TestBed cleanup hooks are always registered | +| [41b12509a](https://github.com/angular/angular-cli/commit/41b12509a9db8bca637e0c67d21301a75774129c) | fix | ensure TestBed setup is robust in non-isolated Vitest | +| [55145f582](https://github.com/angular/angular-cli/commit/55145f582253b4ecb47add7ff2ef459b7535dfdb) | fix | ensure Vitest setup files are executed in order | +| [3478aa332](https://github.com/angular/angular-cli/commit/3478aa332ef0241c04e7eeef9dd74b017292b2c4) | fix | exclude .angular from coverage instrumentation | +| [7c529c1bc](https://github.com/angular/angular-cli/commit/7c529c1bc606101ab8c506e0b7845d2e9f509db4) | fix | externalize Angular dependencies in Vitest runner | +| [69c3b1226](https://github.com/angular/angular-cli/commit/69c3b1226880835fd8087cea5684ababb92b1c05) | fix | improve error handling in unit-test builder | +| [bab5806c2](https://github.com/angular/angular-cli/commit/bab5806c281fd4cdd63b7969e691d703ed1e7680) | fix | introduce vitest-base.config for test configuration | +| [73621998f](https://github.com/angular/angular-cli/commit/73621998f91db189ad9b1ab006681404e30f7900) | fix | normalize paths for Vitest runner output files | +| [fa5c92346](https://github.com/angular/angular-cli/commit/fa5c92346d14a6ad03aa30ad6392fc649038605e) | fix | prioritize string type for runnerConfig schema | +| [d0787c11d](https://github.com/angular/angular-cli/commit/d0787c11d68841c36ef28bc3f15963406d1209a9) | fix | provide default excludes for vitest coverage | +| [ac10f323e](https://github.com/angular/angular-cli/commit/ac10f323ece9f4a35068e510f10786fbcb15adbb) | fix | relax requirement for files to be in TS compilation | +| [139758586](https://github.com/angular/angular-cli/commit/13975858683421a5712bbfccee57cf141a0b96f6) | fix | remove deprecated `javascriptEnabled` option for Less | +| [6576bb598](https://github.com/angular/angular-cli/commit/6576bb5985c18dca7cecd9509939c2a78bf9758a) | fix | remove explicit test isolation configuration | +| [9132e6af9](https://github.com/angular/angular-cli/commit/9132e6af9fd573d8b39c69a50b4b93e256145fd4) | fix | resolve browser provider packages using project resolver | +| [26127bd3b](https://github.com/angular/angular-cli/commit/26127bd3bb2c4b9aacf2a8f4c2cbdf732512bafb) | fix | resolve PostCSS plugins relative to config file | +| [dae732059](https://github.com/angular/angular-cli/commit/dae732059d17e9e374ac7635fbca9480751f70b3) | fix | serve build assets and styles in vitest | +| [705af2278](https://github.com/angular/angular-cli/commit/705af22788102eeade08404d357582c39de8900b) | fix | set coverage report directory to coverage/project-name | +| [0851d2eae](https://github.com/angular/angular-cli/commit/0851d2eae1e5b854a0a8a7df3a47b00693508a0f) | fix | show full aggregate errors from vitest | +| [cc2668f57](https://github.com/angular/angular-cli/commit/cc2668f5744588f9c3d847d2450dd1361e73c690) | fix | simplify SSL handling for `ng serve` with SSR ([#31723](https://github.com/angular/angular-cli/pull/31723)) | +| [907eabdd3](https://github.com/angular/angular-cli/commit/907eabdd3c7447ed2c211b6d6c2719b04443c545) | fix | support ESM PostCSS plugins | +| [62938e799](https://github.com/angular/angular-cli/commit/62938e79977d14045b7883d459d786dbb8d4d7ee) | fix | update vitest to 4.0.6 and remove coverage workaround | + + + + + +# 20.3.11 (2025-11-19) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [8053f2d92](https://github.com/angular/angular-cli/commit/8053f2d92a68a8bd01854eb81aa472249f5a83b2) | fix | ensure `ɵgetOrCreateAngularServerApp` is always defined after errors | + + + + + +# 20.3.10 (2025-11-12) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [c854a719b](https://github.com/angular/angular-cli/commit/c854a719bb3a8d92fe42c8fba4b0b1789081b21c) | fix | correct `tsconfig.spec.json` include for spec files | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------- | +| [b3908f68e](https://github.com/angular/angular-cli/commit/b3908f68ed2f05cee56cbf0d9895dcfc3dc0ac25) | fix | do not remove `@angular/localize` when having external packages ([#31721](https://github.com/angular/angular-cli/pull/31721)) | + + + + + +# 20.3.9 (2025-11-05) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [08e07e338](https://github.com/angular/angular-cli/commit/08e07e338edd799400e18cd62cd131b6d408f4cf) | fix | improve locale handling in app-engine | +| [683697ebc](https://github.com/angular/angular-cli/commit/683697ebc5e129d2b17bded9e4ff318d598e0bd3) | fix | improve route matching for wildcard routes | + + + + + +# 20.3.8 (2025-10-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [813cba9b9](https://github.com/angular/angular-cli/commit/813cba9b9bfe60e874595ce25608ca85a890f6bf) | fix | expand jest and jest-environment-jsdom to allow version 30 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [542973ab0](https://github.com/angular/angular-cli/commit/542973ab074ccd9a5f09f73ee7f2706a21db45fc) | fix | add adapters to new reporter | +| [f0885691d](https://github.com/angular/angular-cli/commit/f0885691d18b6575351774fcc50d746d981285f6) | fix | ensure locale data plugin runs before other plugins | +| [45e498f95](https://github.com/angular/angular-cli/commit/45e498f9576ff83eebe02deb235d36498ce06bde) | fix | handle redirects from guards during prerendering | + + + + + +# 19.2.19 (2025-10-29) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [4d8ea27a1](https://github.com/angular/angular-cli/commit/4d8ea27a1726709b8398a26915395e7611571dae) | fix | update vite to v6.4.1 | + + + + + +# 20.3.7 (2025-10-22) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [a31533cf4](https://github.com/angular/angular-cli/commit/a31533cf492048f62a41b9c09e53779269ee172d) | fix | respect `--force` option when schematic contains `host.create` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [8cdda111c](https://github.com/angular/angular-cli/commit/8cdda111cc0b343aa5eb6a7ccbad93302a543226) | fix | resolve Angular locale data namespace in esbuild | +| [5847ccc54](https://github.com/angular/angular-cli/commit/5847ccc545e54eb77a78b2435db7970faf748156) | fix | update `vite` to `7.11.1` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [3a28fb6a1](https://github.com/angular/angular-cli/commit/3a28fb6a13061215b881c49232db979fc3c2f641) | fix | correctly handle routes with matrix parameters | +| [5db6d6487](https://github.com/angular/angular-cli/commit/5db6d64870c7ce0b883722a07c828946b7d2217d) | fix | ensure server-side navigation triggers a redirect | + + + + + +# 20.3.6 (2025-10-15) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [5271547c8](https://github.com/angular/angular-cli/commit/5271547c80662de10cb3bcb648779a83f6efedfb) | fix | prevent malicious URL from overriding host | + + + + + +# 19.2.18 (2025-10-15) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [9136a5d13](https://github.com/angular/angular-cli/commit/9136a5d1302bb224ea245460ae29474bd2a3a10b) | fix | prevent malicious URL from overriding host | + + + + + +# 20.3.5 (2025-10-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [7f7140680](https://github.com/angular/angular-cli/commit/7f7140680b75ff6b41f7f04349fe10cd928f1a23) | fix | cleanup karma temporary directory after process exit | + + + + + +# 20.3.4 (2025-10-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [c94bf7ff0](https://github.com/angular/angular-cli/commit/c94bf7ff0845fe325c39737057ff1ed4ea553011) | fix | Out of the box support for PM2 | +| [465436c9f](https://github.com/angular/angular-cli/commit/465436c9fa21173befe5e39b61afb7f29435c2aa) | fix | use bracket notation for `process.env['pm_id']` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [bc6b63114](https://github.com/angular/angular-cli/commit/bc6b631146c719a337c937e95c7cc5ebca29254b) | fix | mark `InjectionToken` as pure for improved tree-shaking | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [e510ff828](https://github.com/angular/angular-cli/commit/e510ff828f033478d8e1720050a7b3d75d551e16) | fix | mark `InjectionToken` as pure for improved tree-shaking | + + + + + +# 20.3.3 (2025-09-24) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [b7f92da78](https://github.com/angular/angular-cli/commit/b7f92da7835c14b568d07dfb3313802704f28cfd) | fix | add `__screenshots__/` to `.gitignore` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [a4c9a2007](https://github.com/angular/angular-cli/commit/a4c9a2007ab3e33b2c97fa63f0df8f8662427031) | fix | avoid retaining rendered HTML in memory post-request | + + + + + +# 20.3.2 (2025-09-17) + + + + + +# 19.2.17 (2025-09-17) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [365d525b5](https://github.com/angular/angular-cli/commit/365d525b596b437ad0b1a74b1417eaae6aa8694e) | fix | update `vite` to `6.3.6` | + + + + + +# 20.3.1 (2025-09-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [be60be499](https://github.com/angular/angular-cli/commit/be60be4997ea0f7be3a4fb993f87b1bd29fc1493) | fix | add timestamp to bundle generation log | +| [d60f4e53d](https://github.com/angular/angular-cli/commit/d60f4e53d8f511d313e517161dc26eb3cc005f1c) | fix | update vite to version `7.1.5` | + + + + + +# 18.2.21 (2025-09-10) + +## Breaking Changes + +### @angular/ssr + +- The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. + + Before: + + ```ts + const bootstrap = () => bootstrapApplication(AppComponent, config); + ``` + + After: + + ```ts + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); + ``` + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [700e6bc01](https://github.com/angular/angular-cli/commit/700e6bc0177a3e345a88e31be22496cc3054349b) | fix | avoid extra tick in SSR builds | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [cccc91b91](https://github.com/angular/angular-cli/commit/cccc91b919b4a8365efce9ee691940e351349075) | fix | avoid extra tick in SSR dev-server builds | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [4af385201](https://github.com/angular/angular-cli/commit/4af385201bf8ba05352faec26c6efa866b69d999) | feat | introduce BootstrapContext for isolated server-side rendering | + + + + + +# 19.2.16 (2025-09-10) + +## Breaking Changes + +### @angular/ssr + +- The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. + + Before: + + ```ts + const bootstrap = () => bootstrapApplication(AppComponent, config); + ``` + + After: + + ```ts + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); + ``` + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [b0f4330a9](https://github.com/angular/angular-cli/commit/b0f4330a9a2f598b71f12d07e49b6c7c6891febd) | fix | avoid extra tick in SSR builds | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [ee5c5f823](https://github.com/angular/angular-cli/commit/ee5c5f823c87a36c9bcb92db2fc9b4e652dc16c2) | fix | avoid extra tick in SSR dev-server builds | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [32980f7e7](https://github.com/angular/angular-cli/commit/32980f7e7a5821bc9bd311dda6e134970e735722) | feat | introduce BootstrapContext for isolated server-side rendering | + + + + + +# 20.3.0 (2025-09-10) + +## Breaking Changes + +### @angular/ssr + +- The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. + + Before: + + ```ts + const bootstrap = () => bootstrapApplication(AppComponent, config); + ``` + + After: + + ```ts + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); + ``` + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [ef20a278d](https://github.com/angular/angular-cli/commit/ef20a278d1455b9cdffc5102b13d0b2206ef1ecb) | fix | align labels in ai-config schema | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f6ad41c13](https://github.com/angular/angular-cli/commit/f6ad41c134c7ae938ccda908967e7cc863b3db16) | fix | improve bun lockfile detection and optimize lockfile checks | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [1a7890873](https://github.com/angular/angular-cli/commit/1a789087344aa94d061839122e6a63efbfc9c905) | fix | avoid extra tick in SSR builds | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [5d46d6ec1](https://github.com/angular/angular-cli/commit/5d46d6ec114052715a8bd17761a4f258961ad26b) | fix | preserve names in esbuild for improved debugging in dev mode | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [7eacb4187](https://github.com/angular/angular-cli/commit/7eacb41878f5fdac8d40aedfcca6794b77eda5ff) | feat | introduce BootstrapContext for isolated server-side rendering | + + + + + +# 20.2.2 (2025-09-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [a793bbc47](https://github.com/angular/angular-cli/commit/a793bbc473dfaddf3fe6ed15805dc4fc84f52865) | fix | don't set a default for array options when length is 0 | +| [2736599e2](https://github.com/angular/angular-cli/commit/2736599e2f6c61032810d8e336c1646db4066392) | fix | set process title when running architect commands | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [5c2abffea](https://github.com/angular/angular-cli/commit/5c2abffea6cf3f672ee256a944dba56dd257665b) | fix | avoid extra tick in SSR dev-server builds | +| [f3c826853](https://github.com/angular/angular-cli/commit/f3c826853501c9cf6d07a1c8ee3363eb79f53005) | fix | maintain media output hashing with vitest unit-testing | + + + + + +# 20.2.1 (2025-08-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [3b693e09e](https://github.com/angular/angular-cli/commit/3b693e09e8148ef22031aab8f6bc70c928aabc03) | fix | correctly set default array values | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [6937123a3](https://github.com/angular/angular-cli/commit/6937123a393e2ba9221962b0174056c14437a988) | fix | directly resolve karma config template in migration | +| [5d6dd4425](https://github.com/angular/angular-cli/commit/5d6dd44259a0d89098c2a0c784e726b43ce32316) | fix | prevent AI config schematic from failing when 'none' and other AI tools are selected | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [e93919dea](https://github.com/angular/angular-cli/commit/e93919dea7df55a3aac2fa5c93c4560c50a2d749) | fix | correctly set default array values | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [06a6ddc10](https://github.com/angular/angular-cli/commit/06a6ddc102f5dc9018ec982f6e4cf56259cc4b52) | fix | correct JS/TS file paths when running under Bazel | +| [b6816b0cb](https://github.com/angular/angular-cli/commit/b6816b0cbaf1262d7015b9d7f7fb425f53995947) | fix | ensure karma polyfills reporter factory returns a value | + + + + + +# 20.2.0 (2025-08-20) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [b4de9a1bf](https://github.com/angular/angular-cli/commit/b4de9a1bf50a35404fb79eb3f120faafd0ce825a) | feat | add --experimental-tool option to mcp command | +| [755ba70fd](https://github.com/angular/angular-cli/commit/755ba70fd7ef38793d15797ba402020c375c3295) | feat | add --local-only option to mcp command | +| [59d7ef343](https://github.com/angular/angular-cli/commit/59d7ef343b6f1feea37a019935578c560d3d5e41) | feat | add --read-only option to mcp command | +| [4e92eb6f1](https://github.com/angular/angular-cli/commit/4e92eb6f17cb30259bc8e8d1979bbd9989bc5ad0) | feat | add modernize tool to the MCP server | +| [a3b25f675](https://github.com/angular/angular-cli/commit/a3b25f675283fdd8cc5689e3ec88f27aa1386390) | fix | add choices to command line parser when type is array and has an enum | +| [e19eee614](https://github.com/angular/angular-cli/commit/e19eee61404a9ca6268ebbc69f671a422d81df9b) | fix | address Node.js deprecation DEP0190 | +| [4ee6f327a](https://github.com/angular/angular-cli/commit/4ee6f327a206f8ff2ad5eeab43193df56b92b5e0) | fix | apply default to array types | +| [8ba6b0bcc](https://github.com/angular/angular-cli/commit/8ba6b0bcc8c8087875d14a0aefc6b7b52f39ce2a) | fix | use correct path for MCP get_best_practices tool | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [2e3cfd598](https://github.com/angular/angular-cli/commit/2e3cfd598c9366d0036a52cd18024317b33e6fca) | feat | add migration to remove default Karma configurations | +| [d80dae276](https://github.com/angular/angular-cli/commit/d80dae276e9554c13e0c37640d0db8acafc9d48b) | feat | add schematics to generate ai context files. | +| [ffe6fb916](https://github.com/angular/angular-cli/commit/ffe6fb916d496da1c6c20942f6e6b05a679b0f7d) | fix | allow AI config prompt to be skipped without selecting a value | +| [ae2802b7d](https://github.com/angular/angular-cli/commit/ae2802b7db358c5a3f0590feea212a768a710353) | fix | improve AI config prompt wording | +| [b017f84fd](https://github.com/angular/angular-cli/commit/b017f84fdaf36bc0fcad2241846665c73b52b6d8) | fix | improve coverage directory handling for Karma configuration comparisons | +| [6a79f9a75](https://github.com/angular/angular-cli/commit/6a79f9a75cdcbb0761c4044066748f4eb788a57f) | fix | zoneless is now stable | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [c43504d8d](https://github.com/angular/angular-cli/commit/c43504d8d96a4436ce71c23d957aec2d080106b8) | fix | address Node.js deprecation DEP0190 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [fb06bb505](https://github.com/angular/angular-cli/commit/fb06bb5050e92eb4d0f95d7774552d0902163f6a) | feat | add headless mode for vitest browser mode | + + + + + +# 20.1.6 (2025-08-13) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [584bc1d41](https://github.com/angular/angular-cli/commit/584bc1d4173e7f129aa20e829f1dfb03e1e0dc9e) | fix | add extra prettier config | +| [02b0506fd](https://github.com/angular/angular-cli/commit/02b0506fde638b89510e5a78b3d190ba60a8d6ba) | fix | correct configure the `typeSeparator` in the library schematic | + + + + + +# 20.1.5 (2025-08-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [48ca04474](https://github.com/angular/angular-cli/commit/48ca044745f49bc7fc365a621827294f4cc82c50) | fix | cache MCP best practices content and add tool annotations | + + + + + +# 20.1.4 (2025-07-30) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [2d753cc62](https://github.com/angular/angular-cli/commit/2d753cc62c9a801c40923a43e4af5f74b22700e0) | fix | skip workspace-specific tools when outside a workspace | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [42d72ef4d](https://github.com/angular/angular-cli/commit/42d72ef4d99380dbb1c0e03e3e3abfb2223fa539) | fix | skip vite transformation of CSS-like assets | + + + + + +# 20.1.3 (2025-07-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [ea5cd0e81](https://github.com/angular/angular-cli/commit/ea5cd0e81196467ea66f50c106cffec1cd8a1a56) | fix | update `vite` to `7.0.6` | + + + + + +# 20.1.2 (2025-07-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [96785224f](https://github.com/angular/angular-cli/commit/96785224f55291cd60553aead07ead10d9d2fbda) | fix | `define` option is being included multiple times in the JSON help | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [0d0040bdf](https://github.com/angular/angular-cli/commit/0d0040bdf58a82e18f7669363b6f149313524bfc) | fix | use crypto.randomUUID instead of Date.now for unique string in tmp file names | + + + + + +# 20.1.1 (2025-07-16) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [541b33f8d](https://github.com/angular/angular-cli/commit/541b33f8d977c1fe8f609099a8b8ed1c5f8e827e) | fix | emit a warning when `outputHashing` is set to `all` or `bundles` when HMR is enabled | +| [558a0fe92](https://github.com/angular/angular-cli/commit/558a0fe9275e68e0b768de3ee2e5bee0d6d84a6e) | fix | normalize code coverage include paths to POSIX | + + + + + +# 20.1.0 (2025-07-09) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [dc45c186e](https://github.com/angular/angular-cli/commit/dc45c186ec16e345b75ffcd57961a8e0cfd4b649) | feat | add initial MCP server implementation | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------- | +| [1c19e0dcd](https://github.com/angular/angular-cli/commit/1c19e0dcd4a87fbf542201e09a402a8fccdfcd88) | feat | use signal in app component | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [49a09737d](https://github.com/angular/angular-cli/commit/49a09737d5412c302d09b40de198251bb99789d1) | feat | provide partial custom postcss configuration support | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [1159cf081](https://github.com/angular/angular-cli/commit/1159cf08103081d2b851e59bc1c5fb200f114982) | feat | add code coverage reporters option for unit-test | +| [8f305ef0b](https://github.com/angular/angular-cli/commit/8f305ef0ba91ec9bf6417b7084965205cf5488e7) | feat | add dataurl, base64 loaders | +| [adfeee0a4](https://github.com/angular/angular-cli/commit/adfeee0a4c95a03d430054eeecd4cca1bdb0efeb) | fix | adjust coverage includes/excludes for unit-test vitest runner | +| [c19cd2985](https://github.com/angular/angular-cli/commit/c19cd2985cbf1ea8c1c15f020bc530d6768cb0fa) | fix | coverage reporter option | +| [8879716ca](https://github.com/angular/angular-cli/commit/8879716cac9b2134db2795b1810595ea56e9d421) | fix | expose unit test and karma builder API | +| [a415a4999](https://github.com/angular/angular-cli/commit/a415a4999f337f5bc3c0ee626aaba58b6c5ad4e1) | fix | improve default coverage reporter handling for vitest | +| [e0de8680d](https://github.com/angular/angular-cli/commit/e0de8680d1ea25aa71024d7b89beaa1e75889c47) | fix | inject zone.js/testing before karma builder execution | +| [2672f6ec1](https://github.com/angular/angular-cli/commit/2672f6ec17de6e05b19acda0e0b09a6715c9f83f) | fix | json and json-summary as vitest coverage reporters | +| [b67fdfd6b](https://github.com/angular/angular-cli/commit/b67fdfd6bc422bd6a46db923470579c760c5ec27) | fix | resolve "Controller is already closed" error in Karma | +| [2784883ec](https://github.com/angular/angular-cli/commit/2784883ecfb63e4aa6a6c69fd10e457316b4958c) | fix | support extra test setup files with unit-test vitest runner | +| [f177f5508](https://github.com/angular/angular-cli/commit/f177f5508adb23f604d9abb5f4a33f3af5f32561) | fix | support injecting global styles into vitest unit-tests | +| [130c65014](https://github.com/angular/angular-cli/commit/130c650146595f237bc3285302d0075ba0387546) | fix | use an empty array as default value for vitest exclude | +| [917af12ae](https://github.com/angular/angular-cli/commit/917af12aeb82b1437e7b43a03ae80b58a09f0224) | fix | use date/time based output path for vitest unit-test | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [21b5852f1](https://github.com/angular/angular-cli/commit/21b5852f120dd42ea4ae9fce043e04ec61da16dd) | fix | ensure `loadChildren` runs in correct injection context during route extraction | + + + + + +# 20.0.6 (2025-07-09) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [5542445d3](https://github.com/angular/angular-cli/commit/5542445d30685a2ebbf66d15848a5abc657863c8) | fix | remove constructor from service template | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [0836ad28f](https://github.com/angular/angular-cli/commit/0836ad28f8e4702f8ba12beef615d60c01a7947c) | fix | correctly remap Angular diagnostics | +| [c475e546b](https://github.com/angular/angular-cli/commit/c475e546bfdfee0c098e5198325b52a53882d034) | fix | exclude `@vitest/browser/context` from esbuild bundling | +| [1a2da161e](https://github.com/angular/angular-cli/commit/1a2da161e73f4f1fe876329adf8ed89f9044b404) | fix | failed to proxy error for assets | + + + + + +# 20.0.5 (2025-07-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [1ebd53df7](https://github.com/angular/angular-cli/commit/1ebd53df7168307f699a9f9ae8f5ef5b9bcf352c) | fix | remove unused `@vitejs/plugin-basic-ssl` dependency | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [05cebdbcd](https://github.com/angular/angular-cli/commit/05cebdbcd1466bf5c95eb724a784aeb8c7ac083f) | fix | proxy karma request from `/` to `/base` | + + + + + +# 20.0.4 (2025-06-25) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------- | +| [2316fe29d](https://github.com/angular/angular-cli/commit/2316fe29de57c593e5ccb8be612d3918b60d9761) | fix | add missing prettier config | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [309742289](https://github.com/angular/angular-cli/commit/30974228988d7ff96741fe0515c35275e8a6bc0a) | fix | avoid preloading unnecessary dynamic bundles | +| [82691b98f](https://github.com/angular/angular-cli/commit/82691b98fa458febf40a16beb91b24c4b6c519c9) | fix | ensure correct referer header handling in web request conversion | + + + + + +# 20.0.3 (2025-06-18) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [e90a808c0](https://github.com/angular/angular-cli/commit/e90a808c0100beb319bae36ca3b771ee2da89435) | fix | include `main.server.ts` in `tsconfig.files` when present | +| [5c48b8e0a](https://github.com/angular/angular-cli/commit/5c48b8e0ac38a108740ebb290dc1e666ce390806) | fix | reset module `typeSeparator` when generating applications | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [56f426e25](https://github.com/angular/angular-cli/commit/56f426e2548b86c00d4da19b9f7b5cf97dc79104) | fix | include custom bundle name scripts with karma | +| [dfe3a8b73](https://github.com/angular/angular-cli/commit/dfe3a8b7342dd492e42ec48052612255ba76c09b) | fix | increase worker idle timeout | +| [e6d27bd5e](https://github.com/angular/angular-cli/commit/e6d27bd5e3fe64f597621e0d5c08060cea64a302) | fix | set scripts option output as classic script for karma | + + + + + +# 20.0.2 (2025-06-11) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [bf64a0f2d](https://github.com/angular/angular-cli/commit/bf64a0f2dcc2cbd5dc56e575dd337c16f2a3342b) | fix | add `less` as a devDependency when selected as the style preprocessor | +| [cb258a3e1](https://github.com/angular/angular-cli/commit/cb258a3e1525cda985109692fb88449259119ff2) | fix | correctly detect modules using new file extension format | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [424f1cbbf](https://github.com/angular/angular-cli/commit/424f1cbbfb709b4d6f480e6321ec1a152813cf5c) | fix | do not consider internal Angular files as external imports | + + + + + +# 19.2.15 (2025-06-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [b120e1411](https://github.com/angular/angular-cli/commit/b120e1411c28c99defb34274a11f0fb54972178a) | fix | update dependency webpack-dev-server to v5.2.2 | + + + + + +# 18.2.20 (2025-06-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [f048078](https://github.com/angular/angular-cli/commit/f048078ab6012b5da4dff024c107f42f79693682) | fix | update dependency webpack-dev-server to v5.2.2 | + + + + + +# 20.0.1 (2025-06-04) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [0883248cb](https://github.com/angular/angular-cli/commit/0883248cbdebcad09393349a0a5d9487b2a452ae) | fix | improve Node.js version check and error messages | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [525ddcbd2](https://github.com/angular/angular-cli/commit/525ddcbd290525e4dac2547c352cf6c774d728a2) | fix | only overwrite JSON file if actually changed | +| [83c820e5a](https://github.com/angular/angular-cli/commit/83c820e5ab55d01662417a51e4cc8d094e409fc6) | fix | remove karma config devkit package usages during application migration | +| [87266b38a](https://github.com/angular/angular-cli/commit/87266b38a09ce783ac6d18f532ebe1f8ae5954c0) | fix | skip zone.js dependency for zoneless applications | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [e5efdc577](https://github.com/angular/angular-cli/commit/e5efdc577be913870b29173345b8194b87420474) | fix | also disable outputMode in vitest unit-tests | +| [5814393db](https://github.com/angular/angular-cli/commit/5814393dbb2f9227ce10f1df77a8deee06c7d1c5) | fix | resolve junit karma reporter output to workspace root | + + + + + +# 20.0.0 (2025-05-28) + +## Breaking Changes + +### @angular/cli + +- Node.js v18 is no longer supported with Angular. + + Before updating a project to Angular v20, the Node.js version must be + at least 20.11.1. For the full list of supported Node.js versions, + see https://angular.dev/reference/versions. + +- Node.js versions from 22.0 to 22.10 are no longer supported + +### @schematics/angular + +- `--server-routing` option has been removed from several schematics. Server routing will be used when using the application builder. + +### @angular-devkit/schematics + +- The `NodePackageLinkTask` has been removed without a replacement. Create a custom task if needed. + + Note: This does not affect application developers. + +### @angular/build + +- TypeScript versions less than 5.8 are no longer supported. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------- | +| [e03f2b899](https://github.com/angular/angular-cli/commit/e03f2b89992cb1e34a57f9cd5beef77674c116b6) | feat | Add global error listeners to new app generation | +| [1e137ca84](https://github.com/angular/angular-cli/commit/1e137ca848839402bc214fbccdc04243862d01d0) | feat | add migration to update `moduleResolution` to `bundler` | +| [26fd4ea73](https://github.com/angular/angular-cli/commit/26fd4ea73ad2a0148ae587d582134c68a0bf4b86) | feat | add migrations for server rendering updates | +| [5876577af](https://github.com/angular/angular-cli/commit/5876577af163b534846e720b0184558197dce741) | feat | Add prompt for new apps to be zoneless | +| [fdc6291dd](https://github.com/angular/angular-cli/commit/fdc6291dda4903f418667d415b05367390cf829d) | feat | add update migration to keep previous style guide generation behavior | +| [093c5a315](https://github.com/angular/angular-cli/commit/093c5a3152c4282d4afb51df40945283cc94d281) | feat | directly use `@angular/build` in new projects | +| [d6f594fe0](https://github.com/angular/angular-cli/commit/d6f594fe0f8f21d9c0e2abedb5c8433a1aa5c157) | feat | generate applications using TypeScript project references | +| [0ab1ddf63](https://github.com/angular/angular-cli/commit/0ab1ddf632b7305db28a2f87f5c6b099a44669f6) | feat | generate libraries using TypeScript project references | +| [18e13e2ce](https://github.com/angular/angular-cli/commit/18e13e2ceed931d29aa5582980c7d6d1f66c9787) | feat | remove `--server-routing` option | +| [03180fe03](https://github.com/angular/angular-cli/commit/03180fe0358662f8fd3255ad546994da3e3bda9c) | feat | use TypeScript module preserve option for new projects | +| [86d241629](https://github.com/angular/angular-cli/commit/86d241629ff51f0bb5988e81cac8658b01704d49) | fix | add `@angular/ssr` dependency only when `provideServerRendering` import has been updated | +| [9e6b9b537](https://github.com/angular/angular-cli/commit/9e6b9b5379d0448578b3bfb6100852dea7febe75) | fix | add type checking of host bindings to strict config | +| [8654b3fea](https://github.com/angular/angular-cli/commit/8654b3fea4e2ba5af651e6c2a4afddaf6fc42802) | fix | application migration should migrate karma builder package | +| [c557a19ef](https://github.com/angular/angular-cli/commit/c557a19ef4eed9f2d805bb235d3819c69a1aaef6) | fix | avoid empty polyfill option for new zoneless application | +| [90615a88b](https://github.com/angular/angular-cli/commit/90615a88b10535d7f0197008b9d48ceac4409c23) | fix | default component templates to not use `.ng.html` extension | +| [672ae14cd](https://github.com/angular/angular-cli/commit/672ae14cd21d02a3b4727e2febd88747b9e4c684) | fix | drop composite in tsconfig | +| [da6ef626f](https://github.com/angular/angular-cli/commit/da6ef626f960b187a7862f0caa3d8aed38224ac2) | fix | ensure app-shell schematic consistently uses `withAppShell` | +| [f126f8d34](https://github.com/angular/angular-cli/commit/f126f8d34b087dd3a916dfb93cd255aac4d6c309) | fix | ensure module discovery checks for an NgModule decorator | +| [dc2f65999](https://github.com/angular/angular-cli/commit/dc2f65999a64453a26b61c96080b732fdc4147c8) | fix | generate component templates with a `.ng.html` file extension | +| [23fc8e1e1](https://github.com/angular/angular-cli/commit/23fc8e1e176f23442876b086bff52dd5f35abbc0) | fix | generate components without a `.component` extension/type | +| [8d715fa94](https://github.com/angular/angular-cli/commit/8d715fa948d432b18d06bcf42eed3a7681383523) | fix | generate directives without a .directive extension/type | +| [5fc595144](https://github.com/angular/angular-cli/commit/5fc5951440c9306c4349fa3f8dbcb1b584441fe8) | fix | generate guards with a dash type separator | +| [040282d8f](https://github.com/angular/angular-cli/commit/040282d8fd5838266785997442c4f5a269666cf3) | fix | generate interceptors with a dash type separator | +| [070d60fb3](https://github.com/angular/angular-cli/commit/070d60fb383bb14d39f969942641253e54980fcf) | fix | generate modules with a dash type separator | +| [e6083b57b](https://github.com/angular/angular-cli/commit/e6083b57bb5b38db14264253095a9729738d22f2) | fix | generate pipes with a dash type separator | +| [92e193c0b](https://github.com/angular/angular-cli/commit/92e193c0b9a2b85b68d83c5f378d30fc8d10f13e) | fix | generate resolvers with a dash type separator | +| [bc0f07b48](https://github.com/angular/angular-cli/commit/bc0f07b484300848ee81c5719c58909b40f99deb) | fix | generate services without a .service extension/type | +| [ea1143ddd](https://github.com/angular/angular-cli/commit/ea1143ddd801b775828f0b62788f4cce0dd7e9ce) | fix | infer app component name and path in server schematic | +| [bcc0892a6](https://github.com/angular/angular-cli/commit/bcc0892a65f00e68709e84c380f448a5e0fd05e7) | fix | migrate `provideServerRoutesConfig` to `provideServerRendering` | +| [5e8c6494d](https://github.com/angular/angular-cli/commit/5e8c6494d3eb5a0f61e8b07de4c53233147e9d46) | fix | relative tsconfig paths in references | +| [381d35fe4](https://github.com/angular/angular-cli/commit/381d35fe40f062713eac550a12b58c30c1ec33a9) | fix | remove empty `scripts` option value from new applications | +| [148498c2b](https://github.com/angular/angular-cli/commit/148498c2bcd0feb495dc0aa14b6a4555ac01facb) | fix | Remove experimental from zoneless | +| [a910fe9ae](https://github.com/angular/angular-cli/commit/a910fe9ae0423146f6509c5b9c45c88415365c9f) | fix | remove explicit `outputPath` option value from generated applications | +| [901ab60d9](https://github.com/angular/angular-cli/commit/901ab60d9f63fcff17213dbf7fe17e4a46835974) | fix | remove explicit index option from new applications | +| [be6f13ec1](https://github.com/angular/angular-cli/commit/be6f13ec16f01851d38b900dbfc4df7ccfb94d16) | fix | remove setting files tsconfig field with SSR/Server generation | +| [661609e3e](https://github.com/angular/angular-cli/commit/661609e3e583198828baf236338db17b6222f4d8) | fix | set explicit type in library schematic | +| [0f7dc2cd8](https://github.com/angular/angular-cli/commit/0f7dc2cd8f76f928e64e734563a433ff6a0d478c) | fix | skip spec project reference for minimal ng new | +| [3cf6ab0f7](https://github.com/angular/angular-cli/commit/3cf6ab0f77b23b8717e79b7125ea930cb018ebc5) | fix | support using default browser option when not present | +| [b13805a77](https://github.com/angular/angular-cli/commit/b13805a77a5654a352a6c6f760965c326977ff14) | fix | use protected for class member | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [e513cd4aa](https://github.com/angular/angular-cli/commit/e513cd4aa218e5ab634f05c18b6aa90f223e096c) | fix | add Node.js 24 as supported version | +| [5e90c1b4e](https://github.com/angular/angular-cli/commit/5e90c1b4ec3f1d05ad00f2f854347a5bf8cb0860) | fix | remove Node.js v18 support | +| [787e510dc](https://github.com/angular/angular-cli/commit/787e510dccabf30589194fcefdb74a687dfa3945) | fix | update min Node.js support to 20.19, 22.12, and 24.0 | +| [64732534e](https://github.com/angular/angular-cli/commit/64732534ecb84d702bde2469466a05e765879f9a) | fix | update minimum supported Node.js 22 version to 22.11.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [a42e045ba](https://github.com/angular/angular-cli/commit/a42e045bab3bfbeb0bb69c3406fd0a76ae263cdf) | fix | respect i18nDuplicateTranslation option when duplicates exist | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------- | +| [e6be37601](https://github.com/angular/angular-cli/commit/e6be37601d57f884a1ddf2cc1ddecf51819b9f51) | refactor | remove deprecated `NodePackageLinkTask` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [d6ea6b09f](https://github.com/angular/angular-cli/commit/d6ea6b09f182433f859a78d4a4d38a9db521e593) | feat | add experimental vitest browser support to unit-testing | +| [12def3a2e](https://github.com/angular/angular-cli/commit/12def3a2e907ca8e7d530cea1b39bba90e153144) | feat | add experimental vitest unit-testing support | +| [c1de63300](https://github.com/angular/angular-cli/commit/c1de633007c423cfd9113cc781b5647e59306146) | feat | allow control of source map sources content for application builds | +| [31c81e9c6](https://github.com/angular/angular-cli/commit/31c81e9c6859e68d00828b345d996d1aff431b25) | feat | drop support for TypeScript older than 5.8 | +| [e80963036](https://github.com/angular/angular-cli/commit/e8096303659f4f02ac05fe8f655bb29bc12fda28) | feat | expand browser support policy to widely available Baseline | +| [3c9172159](https://github.com/angular/angular-cli/commit/3c9172159c72f3c8ea116557ba5bf917a15d2f07) | feat | integrate Chrome automatic workspace folders | +| [9b682e625](https://github.com/angular/angular-cli/commit/9b682e62519e761477e6266650239bf58026a9f4) | feat | support a default outputPath option for applications | +| [d067cedf0](https://github.com/angular/angular-cli/commit/d067cedf05051e3a0f237d50306e1e4c881a0328) | feat | support custom resolution conditions with applications | +| [f4be83119](https://github.com/angular/angular-cli/commit/f4be831197010a17394264bc74b1eb385ba95028) | feat | Support Sass package importers | +| [f36a27272](https://github.com/angular/angular-cli/commit/f36a27272f3f7e2673d692d73286280f4c6d357a) | fix | allow a default application `browser` option | +| [f42f5c14c](https://github.com/angular/angular-cli/commit/f42f5c14c0c51d7705bee7b67afc317c45fb9230) | fix | allow component HMR for templates with i18n | +| [e36bf964a](https://github.com/angular/angular-cli/commit/e36bf964a776b04f6a9193387692274865e1630b) | fix | allow TestBed provider configuration with vitest unit-testing | +| [769961e4a](https://github.com/angular/angular-cli/commit/769961e4a9a67f88f8fb4b7de80dea67825219f9) | fix | allow vitest-based unit testing to use watch option | +| [3e24a59a9](https://github.com/angular/angular-cli/commit/3e24a59a9db9f11a80fa616c68be4380c4816ed5) | fix | disable TypeScript `composite` option with Angular compiler | +| [b155ba1dc](https://github.com/angular/angular-cli/commit/b155ba1dcdbc3c506311e4434c37f1b4c77c7572) | fix | enable unit-test builder watch outside CI | +| [7bb1f8747](https://github.com/angular/angular-cli/commit/7bb1f87478d441e35b73b920c8bfcd4376a3422d) | fix | enable unit-test reporters option | +| [05485ede7](https://github.com/angular/angular-cli/commit/05485ede7b472f98120c51f28bd485eeb635bac2) | fix | ensure `com.chrome.devtools.json` is consistently served after initial run | +| [7877d9a97](https://github.com/angular/angular-cli/commit/7877d9a971dbef5025fdb9a40f49f36e9b42569d) | fix | ensure disabled vitest config loading | +| [c8c73185a](https://github.com/angular/angular-cli/commit/c8c73185a66c7c7825e30f7fcedbaacc9ca1c593) | fix | ensure matching coverage excludes with karma on Windows | +| [aec95042b](https://github.com/angular/angular-cli/commit/aec95042b4d690c25645af590788c608b4b353dc) | fix | exclude only source test files with unit-test vitest support | +| [5bea3de4c](https://github.com/angular/angular-cli/commit/5bea3de4cb2ffa26ad04aced22be3ff11f519f92) | fix | invalidate `com.chrome.devtools.json` if project is moved | +| [1cd65a08d](https://github.com/angular/angular-cli/commit/1cd65a08d5278134115f33ff0e666aee420faf8a) | fix | perform testing module cleanup when using Vitest | +| [c51a540ce](https://github.com/angular/angular-cli/commit/c51a540ce4fb191811d2be06a9937f11826b38a5) | fix | provide direct debugging support for unit test builder | +| [c7f2cb596](https://github.com/angular/angular-cli/commit/c7f2cb59684a264bb4ecab2024d8a8c58efbefa7) | fix | provide vitest globals in unit-test builder | +| [d2bfc6bd4](https://github.com/angular/angular-cli/commit/d2bfc6bd4eb0892e9eb6205838158142b716d21c) | fix | revert setup unit-test polyfills before TestBed init | +| [0d40cdecd](https://github.com/angular/angular-cli/commit/0d40cdecd0fdc1b03d2cafcdd5321db0d31b56ee) | fix | setup unit-test polyfills before TestBed init | +| [fa3dc6387](https://github.com/angular/angular-cli/commit/fa3dc6387db971be265c1c5391c71a23c62df15c) | fix | show unit-test error for missing vitest browser package | +| [247cd3352](https://github.com/angular/angular-cli/commit/247cd335217d9997995321b4b235c40480adadb3) | fix | show unit-test error for missing vitest package | +| [eee816f79](https://github.com/angular/angular-cli/commit/eee816f79b4464286dcecc16f53c06be8afd4ccf) | fix | use global unit-test hooks during TestBed init | +| [566de64cb](https://github.com/angular/angular-cli/commit/566de64cbeebeb532db3c0f4ed1dd607c31dedf1) | fix | use virtual module for Karma TestBed initialization | +| [52fbffcd7](https://github.com/angular/angular-cli/commit/52fbffcd7bb129720a10e6bf865e4e3a01f939d6) | fix | warn and remove jsdom launcher when used with karma | +| [5ff6188c4](https://github.com/angular/angular-cli/commit/5ff6188c4330b009201a64a23d0090bfcec0612f) | perf | directly check code for Angular partial linking | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [fa0a06f9f](https://github.com/angular/angular-cli/commit/fa0a06f9f92b28929fc775074245a0b97c3d9adc) | fix | support using default index option when not present | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------- | +| [33b9de3eb](https://github.com/angular/angular-cli/commit/33b9de3eb1fa596a4d5a975d05275739f2f7b8ae) | feat | expose `provideServerRendering` and remove `provideServerRouting` | +| [cdfc50c29](https://github.com/angular/angular-cli/commit/cdfc50c29a2aa6f32d172b505a0ef09e563dfc59) | feat | stabilize `AngularNodeAppEngine`, `AngularAppEngine`, and `provideServerRouting` APIs | +| [319b8e0c2](https://github.com/angular/angular-cli/commit/319b8e0c2a0cd30ab96576464b4172a1f76a97a6) | fix | manage unhandled errors in zoneless applications | +| [2d11e8e45](https://github.com/angular/angular-cli/commit/2d11e8e45b29cf879ee72ffbcf438198d73ffaba) | fix | return 302 when redirectTo is a function | +| [059c10eb4](https://github.com/angular/angular-cli/commit/059c10eb4df72b0d67f73783826e2bbae611ad35) | fix | SSR should work without `@angular/router` | +| [63428f3f1](https://github.com/angular/angular-cli/commit/63428f3f1e2ffd427011ea8a17b70f8829ae0bdf) | perf | flush headers prior to start rendering the HTML | +| [280693231](https://github.com/angular/angular-cli/commit/280693231e143aa09f841e3179317573a3576545) | perf | optimize response times by introducing header flushing | +| [6bd7b9b4a](https://github.com/angular/angular-cli/commit/6bd7b9b4a59240caa4f19185570aec8263d8a0a7) | perf | optimized request handling performance | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [427bd846f](https://github.com/angular/angular-cli/commit/427bd846f552b393cb969472a05488ac11d47e9f) | fix | disable TypeScript composite option with Angular compiler | + + + + + +# 19.2.14 (2025-05-28) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [a3504fd45](https://github.com/angular/angular-cli/commit/a3504fd45602ec73ce1781e46e6c92b6042a51da) | fix | HMR requires AOT do not show HMR enabled when using JIT | +| [5ce9f96a4](https://github.com/angular/angular-cli/commit/5ce9f96a4efeb4efabe3c161ab596d049a1edd97) | fix | include full metadata for AOT unit-testing | + + + + + +# 19.2.13 (2025-05-21) + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [ad2fb2959](https://github.com/angular/angular-cli/commit/ad2fb29597e22767618d046fef3fb54bf8e95b5d) | fix | remove `background_color` and `theme_color` from manifest | + + + + + +# 19.2.12 (2025-05-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [0098c38c6](https://github.com/angular/angular-cli/commit/0098c38c6d77310effa8c647e1bbfb32fb92afc5) | fix | properly handle Node.js `require()` errors with ESM modules | + + + + + +# 19.2.11 (2025-05-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [9eaf34405](https://github.com/angular/angular-cli/commit/9eaf344056b8772b623b0bfc27a66ad985941ae6) | fix | correctly set i18n subPath in webpack browser builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [cba66a85c](https://github.com/angular/angular-cli/commit/cba66a85c0bb26813d320281072495473a2d14e3) | fix | avoid attempting to watch bundler internal files | +| [009fc3776](https://github.com/angular/angular-cli/commit/009fc377636817a4dc178908245695d5cff29e75) | fix | avoid internal karma request cache for assets | +| [b43da3949](https://github.com/angular/angular-cli/commit/b43da39499ca477a896f7f957debb05ceed1372a) | perf | fix unnecessary esbuild rebuilds | + + + + + +# 19.2.10 (2025-04-30) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [067f1cba0](https://github.com/angular/angular-cli/commit/067f1cba031361f71c79b70af143c53c777e9f7d) | fix | update vite to 6.2.7 | + + + + + +# 17.3.17 (2025-04-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [743d85bac](https://github.com/angular/angular-cli/commit/743d85bacce03bcc454574e0ffa9f243ff6631dd) | fix | update http-proxy-middleware to v2.0.8 | + + + + + +# 19.2.9 (2025-04-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [de52cc2c8](https://github.com/angular/angular-cli/commit/de52cc2c813e49a06828ff9e9ef0543fa63a9929) | fix | update http-proxy-middleware to v3.0.5 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [cc5229a45](https://github.com/angular/angular-cli/commit/cc5229a4507848d4d2bcf7409ffa56a7c4b2a136) | fix | pass `preserveSymlinks` option to Karma esbuild builder | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [a4e415ea6](https://github.com/angular/angular-cli/commit/a4e415ea6ab204b6d5f5974c6f0a073d66c40faf) | fix | support `getPrerenderParams` for wildcard routes | + + + + + +# 18.2.19 (2025-04-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [01cc617bc](https://github.com/angular/angular-cli/commit/01cc617bc0e0a5a30c3b86f679494500a914c574) | fix | update http-proxy-middleware to v3.0.5 | + + + + + +# 19.2.8 (2025-04-16) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [4a8a4a083](https://github.com/angular/angular-cli/commit/4a8a4a0837af6a095a1e4ad6ae07436073324a7a) | fix | include `module` value check when adding custom conditions | +| [00cd0d123](https://github.com/angular/angular-cli/commit/00cd0d1235ed13781689ae4c4636371dab46b493) | fix | prevent nested CSS in components | +| [a297c4153](https://github.com/angular/angular-cli/commit/a297c4153fd72581cbcf8136c9524c415c561f53) | fix | properly resolve transitive external dependencies in vite-dev-server | +| [8ab033e8e](https://github.com/angular/angular-cli/commit/8ab033e8e56d26c75d8871f81291e702b8985adc) | fix | update vite to 6.2.6 | + + + + + +# 19.2.7 (2025-04-09) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7f1e8c677](https://github.com/angular/angular-cli/commit/7f1e8c6777dbf60e2a3864774a8c4140bb76f640) | fix | include component test metadata in development builds | +| [74cd4edd5](https://github.com/angular/angular-cli/commit/74cd4edd5bbf5ae03a910be036f6e7fa7db35642) | fix | skip normalization of relative externals | + + + + + +# 18.2.18 (2025-04-09) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [4245ca7b4](https://github.com/angular/angular-cli/commit/4245ca7b434e0aa859c805c459ce50238601b940) | fix | update vite to 5.4.17 | + + + + + +# 17.3.16 (2025-04-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [5aa53b40c](https://github.com/angular/angular-cli/commit/5aa53b40c34e1555548d201f840a5ffc01f14601) | fix | remove undici from dependencies | +| [fce61564d](https://github.com/angular/angular-cli/commit/fce61564ded8c476ef1c257d2844b1a1560af732) | fix | update vite to 5.4.17 | + + + + + +# 19.2.6 (2025-04-02) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [e5aec562f](https://github.com/angular/angular-cli/commit/e5aec562feb0d293e88d560ea4ec0720e90dbc11) | fix | properly resolve relative schematics when executed from a nested directory | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [76cfd364a](https://github.com/angular/angular-cli/commit/76cfd364a8b398153c09ce29c5672272ac0bce23) | fix | correctly handle `false` value in server option | +| [d69188c6b](https://github.com/angular/angular-cli/commit/d69188c6be2b851e3dfb84e2bd8d209062d7a283) | fix | update vite to 6.2.4 due to a security issues | + + + + + +# 18.2.17 (2025-04-02) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [247ceff7f](https://github.com/angular/angular-cli/commit/247ceff7f7d71901f51dbab1c1a5235d59e45847) | fix | update vite to 5.4.16 due to a security issues | + + + + + +# 17.3.15 (2025-04-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [0525fec61](https://github.com/angular/angular-cli/commit/0525fec6183c2972b97a6ad4d57e89aaa478a2de) | fix | update vite to 5.4.16 due to a security issues | + + + + + +# 19.2.5 (2025-03-26) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [20455e2a6](https://github.com/angular/angular-cli/commit/20455e2a64558fcbb11906cb414a99d3976645d6) | fix | correct handling of response/request errors | +| [32b1dcd91](https://github.com/angular/angular-cli/commit/32b1dcd91b9f351bb6baa54f52c81c465185e01b) | fix | handle undefined `getOrCreateAngularServerApp` during error compilation | +| [7552a9fec](https://github.com/angular/angular-cli/commit/7552a9fec971f64ff27d78754ed13654e9a56b43) | fix | normalize karma asset paths before lookup | +| [1eb5b4357](https://github.com/angular/angular-cli/commit/1eb5b43575ab9908122606b94c0aaa53718678aa) | fix | update vite to 6.2.3 | + + + + + +# 18.2.16 (2025-03-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [4267a80c5](https://github.com/angular/angular-cli/commit/4267a80c5cd1e9e6aaae0f9090e21c2d71a6887f) | fix | remove `@vitejs/plugin-basic-ssl` from dependencies | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [9c2904d0d](https://github.com/angular/angular-cli/commit/9c2904d0d3a7b2790b27d21c1ff23e6d8a01c4f0) | fix | update vite to 5.4.15 | + + + + + +# 17.3.14 (2025-03-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [cb8f859f1](https://github.com/angular/angular-cli/commit/cb8f859f181a325c15b91791c78f5326f22bb7f5) | fix | update vite to 5.4.15 | + + + + + +# 19.2.4 (2025-03-19) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [0a4e96bda](https://github.com/angular/angular-cli/commit/0a4e96bda054876332c5603a3bc972c3ec1eb0bf) | fix | replace `@angular/platform-browser-dynamic` with `@angular/platform-browser` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [b0b643e46](https://github.com/angular/angular-cli/commit/b0b643e46f1009be66423fdff568d042717c5e2b) | fix | ensure errors for missing component resources | +| [2cd763e89](https://github.com/angular/angular-cli/commit/2cd763e893788cfb38260d48eef40afa574a6a70) | fix | ensure relative karma stack traces for test failures | + + + + + +# 17.3.13 (2025-03-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [22901df02](https://github.com/angular/angular-cli/commit/22901df0261812a3408ff9d7a7690bf6b87ec2a3) | fix | update babel packages | + + + + + +# 19.2.3 (2025-03-13) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [5a739820b](https://github.com/angular/angular-cli/commit/5a739820be5cc7cb25e159a1f2283db92e741f78) | fix | update babel packages | + + + + + +# 18.2.15 (2025-03-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [255c8a50d](https://github.com/angular/angular-cli/commit/255c8a50d2214747c8121e963afcd96cbff39293) | fix | update babel packages | + + + + + +# 19.2.2 (2025-03-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [0ee24e29b](https://github.com/angular/angular-cli/commit/0ee24e29b9bb24e92ca3159a13a21fac78974fd7) | fix | record analytics for nested schematics | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [4575265f0](https://github.com/angular/angular-cli/commit/4575265f0b6dcfe81a729f60264e148d93302a10) | fix | exclude all entrypoints of a library from prebundling | +| [83fcffbb7](https://github.com/angular/angular-cli/commit/83fcffbb7d2ede1b08b4145dcedd46ef328bb2f8) | fix | handle postcss compilation errors gracefully | +| [78297ee47](https://github.com/angular/angular-cli/commit/78297ee47c9c381b08cd3649d369765c0b73d4f9) | fix | provide `extract-i18n` does not respect | +| [b18b9c8f2](https://github.com/angular/angular-cli/commit/b18b9c8f249df7b79caebc5ffca07198c14b9a72) | fix | remove duplicate prebundling warning | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [e6e8ce960](https://github.com/angular/angular-cli/commit/e6e8ce960a8048e7bfbaafa4ea013bb05d9897aa) | fix | prevent stream draining if `write` does not return a boolean | + + + + + +# 19.2.1 (2025-03-05) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [4c35b5721](https://github.com/angular/angular-cli/commit/4c35b5721b146d3c27f200c2688073c20dbe0a19) | fix | prevent accidental deletion of `main.ts` during application builder migration | +| [d7f9cb578](https://github.com/angular/angular-cli/commit/d7f9cb578d164aba830751cffb035bf8d962eca2) | fix | prevent error when tsconfig file is missing in application builder migration | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [3ebd7ca7c](https://github.com/angular/angular-cli/commit/3ebd7ca7caeb266308856f47af06bea641b1f8e8) | fix | improve error message when configuration is missing | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [c07330967](https://github.com/angular/angular-cli/commit/c0733096797d45a5cd3ffc18f89a5c75a521accb) | fix | allow component HMR with a service worker | +| [c989c91c3](https://github.com/angular/angular-cli/commit/c989c91c37cab9571bdfaa91cbd806acd9cf9d19) | fix | exclude component styles from 'any' and 'all' budget calculations | +| [96e5dcb5f](https://github.com/angular/angular-cli/commit/96e5dcb5f14b8d16520974b80bb531a190be2343) | fix | handle undefined `less` stylesheet sourcemap values | + + + + + +# 19.2.0 (2025-02-26) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [fe8d83a1f](https://github.com/angular/angular-cli/commit/fe8d83a1f6b5e212d6d51d8f042141c3792ed1cf) | fix | add additional checks for application builder usage | +| [adf4ea5d4](https://github.com/angular/angular-cli/commit/adf4ea5d4ccb252132301111153619178c5bdabe) | fix | remove animations module from ng new app | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [ef7ea536f](https://github.com/angular/angular-cli/commit/ef7ea536feae128b9fabaa124cde2bdad3802cba) | feat | add aot option to jest | +| [523d539c6](https://github.com/angular/angular-cli/commit/523d539c6633ab223723162f425e0ef2b7b4ff71) | feat | add aot option to karma | +| [a00a49a65](https://github.com/angular/angular-cli/commit/a00a49a65ae68e6e0f9856d8d0f4d9914031cd05) | feat | add aot to WTR schema | +| [2bae1a9c0](https://github.com/angular/angular-cli/commit/2bae1a9c0c9eff8087b67c7890b87dc1c279c809) | fix | support aot option for karma browser builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [11fab9c7d](https://github.com/angular/angular-cli/commit/11fab9c7dde950e46b2a23d239bb9e29b20f5eff) | feat | add application builder karma testing to package | +| [a5fcf8044](https://github.com/angular/angular-cli/commit/a5fcf804428b835cd79bd8fad55c16e614c2be3a) | fix | provide karma stack trace sourcemap support | +| [964fb778b](https://github.com/angular/angular-cli/commit/964fb778b7d9e4811a6987eddc4f0a010bb713f6) | fix | support per component updates of multi-component files | +| [f836be9e6](https://github.com/angular/angular-cli/commit/f836be9e676575fccd4d74eddbc5bf647f7ff1bd) | fix | support Vite `allowedHosts` option for development server | +| [0ddf6aafa](https://github.com/angular/angular-cli/commit/0ddf6aafaa65b3323dc4ee6251d75794ae862ec7) | fix | utilize bazel stamp instead of resolving peer dependency versions | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [9726cd084](https://github.com/angular/angular-cli/commit/9726cd084b76fe605405d562a18d8af91d6657d8) | feat | Add support for route matchers with fine-grained render mode control | + + + + + +# 19.1.9 (2025-02-26) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [2d361e9b0](https://github.com/angular/angular-cli/commit/2d361e9b0ae5409d7ab23f50b089da16497623c1) | fix | always disable JSON stats with dev-server | + + + + + +# 19.1.8 (2025-02-19) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [f76cee637](https://github.com/angular/angular-cli/commit/f76cee6378d1fb103a47c4c9006df344029491c9) | fix | correctly parse and resolve relative schematic collection names on Windows | +| [ceba7739c](https://github.com/angular/angular-cli/commit/ceba7739cc72835d080a3c2246209a635212a607) | fix | prefer installed package as fallback when listing package groups | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [c54b9996a](https://github.com/angular/angular-cli/commit/c54b9996adb23ebc0a5e1e159ac4a9b54cbf2f1a) | fix | pass missing options to Karma esbuild builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [2f60a24dd](https://github.com/angular/angular-cli/commit/2f60a24dd76b3345aef666e7a84099863349c53e) | fix | suppress asset missing warning for `/index.html` requests | +| [b8f7952b7](https://github.com/angular/angular-cli/commit/b8f7952b783a83649364107c78f0fb87ac7b3cf3) | fix | update critical CSS inlining to support `autoCsp` | + + + + + +# 19.1.7 (2025-02-12) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [de73b1c0c](https://github.com/angular/angular-cli/commit/de73b1c0c2d5748818d2e94f93f2640d4c6b949c) | fix | include default export for Express app | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [8890a5f76](https://github.com/angular/angular-cli/commit/8890a5f76c252fe383a632880df476e5f63ef931) | fix | always provide Vite client helpers with development server | +| [df1d38846](https://github.com/angular/angular-cli/commit/df1d388465b6f0d3aab5fb4f011cbbe74d3058f4) | fix | configure Vite CORS option | +| [a13a49d95](https://github.com/angular/angular-cli/commit/a13a49d95be61d2a2458962d57318f301dede502) | fix | exclude unmodified files from logs with `--localize` | +| [0826315fa](https://github.com/angular/angular-cli/commit/0826315fac1c3fd2d22aa0ea544bd59ef9ed8781) | fix | handle unlocalizable files correctly in localized prerender | +| [d2e1c8e9f](https://github.com/angular/angular-cli/commit/d2e1c8e9f5c03a410d8204a5f9b11b4ad9cc9eaa) | perf | cache translated i18n bundles for faster builds | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [f5d974576](https://github.com/angular/angular-cli/commit/f5d97457622897b41e73a859dd1f218fa962be15) | fix | accurately calculate content length for static pages with `\r\n` | +| [c26ea1619](https://github.com/angular/angular-cli/commit/c26ea1619095102b21176435af826cf53f0054b1) | fix | properly handle baseHref with protocol | + + + + + +# 17.3.12 (2025-02-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [d83237028](https://github.com/angular/angular-cli/commit/d832370285adccbf955963a5115cf9b9bf54a08d) | fix | update vite to version 5.4.14 | + + + + + +# 19.1.6 (2025-02-05) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [3f7042672](https://github.com/angular/angular-cli/commit/3f704267223d1881ea40e9de4e6381b9d0e43fe6) | fix | remove additional newline after standalone property | +| [e9778dba0](https://github.com/angular/angular-cli/commit/e9778dba0d75e7f528b600d51504a583485bd033) | fix | skip ssr migration when `@angular/ssr` is not a dependency | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [27f833186](https://github.com/angular/angular-cli/commit/27f8331865de35044ddeda7a8c05bb2700b0be6a) | fix | avoid pre-transform errors with Vite pre-bundling | +| [8f6ee7ed9](https://github.com/angular/angular-cli/commit/8f6ee7ed933ea7394e14fe46d141427839008040) | fix | ensure full rebuild after initial error build in watch mode | +| [2b9c00f68](https://github.com/angular/angular-cli/commit/2b9c00f686145a8613dc2ce7f494193622e02625) | fix | prevent fallback to serving main.js for unknown requests | +| [45abd15b7](https://github.com/angular/angular-cli/commit/45abd15b781bb5bb067a7a52e7a809bb9d141c75) | fix | prevent server manifest generation when no server features are enabled | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [5bf5e5fd2](https://github.com/angular/angular-cli/commit/5bf5e5fd20e3c33a274a936dd1ce00e07b860226) | fix | prioritize the first matching route over subsequent ones | + + + + + +# 19.1.5 (2025-01-29) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [14e3a71e4](https://github.com/angular/angular-cli/commit/14e3a71e46e12a556323fff48998794eecab9896) | fix | update library schematic to use `@angular-devkit/build-angular:ng-packagr` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [d53d25fc1](https://github.com/angular/angular-cli/commit/d53d25fc1b80388158643dbdd37aa49b0aa790e0) | fix | allow tailwindcss 4.x as a peer dependency | +| [bd9d379f0](https://github.com/angular/angular-cli/commit/bd9d379f0401a19d527dc896a96b2671b4c4ed76) | fix | disable TypeScript `removeComments` option | +| [e73e9102e](https://github.com/angular/angular-cli/commit/e73e9102e3098882dd76a8dbf800d4ba414e0e27) | fix | handle empty module case to avoid TypeError | +| [bb413456e](https://github.com/angular/angular-cli/commit/bb413456e95a9be49feba95415915ce2ef39b1b5) | fix | keep background referenced HMR update chunks | +| [2011d3428](https://github.com/angular/angular-cli/commit/2011d34286784156b8c09bb8c6d376d8f902bc00) | fix | support template updates that also trigger global stylesheet changes | +| [688019946](https://github.com/angular/angular-cli/commit/688019946358b176eacc872ece72987387a603f1) | fix | update vite to version 6.0.11 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [94643d54d](https://github.com/angular/angular-cli/commit/94643d54da1ddadcec1c169aa844a716bec612f6) | fix | enhance dynamic route matching for better performance and accuracy | +| [747557aa0](https://github.com/angular/angular-cli/commit/747557aa0aad00f1df2ce7912ab49775e19c60dc) | fix | redirect to locale pathname instead of full URL | +| [bbbc1eb7a](https://github.com/angular/angular-cli/commit/bbbc1eb7a0c295e0bc4aea95b7292ba484f8a360) | fix | rename `provideServerRoutesConfig` to `provideServerRouting` | + + + + + +# 18.2.14 (2025-01-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [9d34d28ec](https://github.com/angular/angular-cli/commit/9d34d28ec2965e1b9753556b2721d25ab05c655b) | fix | remove unused `vite` dependency | + + + + + +# 18.2.13 (2025-01-29) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [deeaf1883](https://github.com/angular/angular-cli/commit/deeaf18836efddfa1ee56a25e44944ba444d35ac) | fix | correctly select package versions in descending order during `ng add` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [fdddf2c08](https://github.com/angular/angular-cli/commit/fdddf2c0844081667a09f2ffe0b16f77384959b2) | fix | update vite to version 5.4.14 | + + + + + +# 19.1.4 (2025-01-22) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [aa6f0d051](https://github.com/angular/angular-cli/commit/aa6f0d051179d31aad2c3be7b79f9fda8de60f34) | fix | ensure collections can be resolved via test runner in pnpm workspaces | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------- | +| [ff8192a35](https://github.com/angular/angular-cli/commit/ff8192a355ca38edb34fb0cfe08ef133629f3f63) | fix | correct path for `/@ng/components` on Windows | +| [14d2f7ca0](https://github.com/angular/angular-cli/commit/14d2f7ca0e930fceeea53d307db79f0e963c1d53) | fix | include extracted routes in the manifest during prerendering | +| [c87a38f5b](https://github.com/angular/angular-cli/commit/c87a38f5b25b3cddd1b2a1ee4b443b10cf03b767) | fix | only issue invalid i18n config error for duplicate `subPaths` with inlined locales | +| [d50788cf9](https://github.com/angular/angular-cli/commit/d50788cf9f799ffbe9ba0edde425e6833623686d) | fix | replace deprecation of `i18n.baseHref` with a warning | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [bcc5fab75](https://github.com/angular/angular-cli/commit/bcc5fab750c0029e16ad91d277f88113a60b7fa1) | fix | prevent route matcher error when SSR routing is not used | +| [9bacf3981](https://github.com/angular/angular-cli/commit/9bacf3981995626cf935cf1620c391338de1c9df) | fix | properly manage catch-all routes with base href | +| [59c757781](https://github.com/angular/angular-cli/commit/59c75778112383816da2f729d5ae80705b5828fa) | fix | unblock route extraction with `withEnabledBlockingInitialNavigation` | + + + + + +# 19.1.3 (2025-01-20) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [7d8c34172](https://github.com/angular/angular-cli/commit/7d8c34172bf29fbf61c0c0114c419903804b6b38) | fix | allow asset changes to reload page on incremental updates | +| [9fa29af37](https://github.com/angular/angular-cli/commit/9fa29af374060a05a19b32d1f0dee954ec70f451) | fix | handle relative `@ng/components` | +| [c4de34703](https://github.com/angular/angular-cli/commit/c4de34703f8b17ac96e66f889fa0e3ffff524831) | fix | perform full reload for templates with `$localize` usage | + + + + + +# 19.1.2 (2025-01-17) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [939d1612a](https://github.com/angular/angular-cli/commit/939d1612add13bab9aed6cc77bce0e17555bfe3b) | fix | perform incremental background file updates with component updates | +| [304027207](https://github.com/angular/angular-cli/commit/30402720707b7a8b9042a6046692d62a768cdc64) | fix | prevent full page reload on HMR updates with SSR enabled | +| [148acbd58](https://github.com/angular/angular-cli/commit/148acbd58a13b1ba8c4a3349bd6042c24a9f93b5) | fix | reset component updates on dev-server index request | + + + + + +# 19.1.1 (2025-01-16) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [298506751](https://github.com/angular/angular-cli/commit/298506751f2b3788fa2def7f7b4012e9e5465047) | fix | resolve HMR-prefixed files in SSR with Vite | + + + + + +# 19.1.0 (2025-01-15) + +## Deprecations + +### @angular/build + +- The `baseHref` option under `i18n.locales` and `i18n.sourceLocale` in `angular.json` is deprecated in favor of `subPath`. + + The `subPath` defines the URL segment for the locale, serving as both the HTML base HREF and the directory name for output. By default, if not specified, `subPath` will use the locale code. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [02825eec5](https://github.com/angular/angular-cli/commit/02825eec53456384ba5b9c19f25dde3cfc95d796) | feat | use `@angular/build` package in library generation schematic | +| [88431b756](https://github.com/angular/angular-cli/commit/88431b7564d6757898744597a67fcdc178413128) | fix | application migration should migrate ng-packagr builder package | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [2b8a02bac](https://github.com/angular/angular-cli/commit/2b8a02bac098d4ac4f31b0e74bedfc739171e30b) | feat | require build schemas from modules | +| [fe1ae6933](https://github.com/angular/angular-cli/commit/fe1ae6933998104c144b2c8854f362289c8d91c6) | fix | avoid Node.js resolution for relative builder schema | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [ce7c4e203](https://github.com/angular/angular-cli/commit/ce7c4e203d0312d21d4a3d1955f9c97bdf3e06d2) | fix | handle Windows drive letter case insensitivity in path functions | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [2f55209dd](https://github.com/angular/angular-cli/commit/2f55209dd24602bdf8c27ef083f96b5f55166971) | fix | update `Rule` type to support returning a `Promise` of `Tree` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [2c9d7368f](https://github.com/angular/angular-cli/commit/2c9d7368fc30f3488152e35ac468db5f2a9432f2) | feat | add ng-packagr builder to the package | +| [0a570c0c2](https://github.com/angular/angular-cli/commit/0a570c0c2e64c61ce9969975a21c0d9aac8d9f3b) | feat | add support for customizing URL segments with i18n | +| [298b554a7](https://github.com/angular/angular-cli/commit/298b554a7a40465444b4c508e2250ecbf459ea47) | feat | enable component template hot replacement by default | +| [d350f357b](https://github.com/angular/angular-cli/commit/d350f357b2a74df828ec022e03754d59cc680848) | fix | correctly validate locales `subPath` | +| [8aa1ce608](https://github.com/angular/angular-cli/commit/8aa1ce60808c073634237d03045626d379a51183) | fix | handle loaders correctly in SSR bundles for external packages | +| [3b7e6a8c6](https://github.com/angular/angular-cli/commit/3b7e6a8c6e2e018a85b437256040fd9c8161d537) | fix | invalidate component template updates with dev-server SSR | +| [8fa682e57](https://github.com/angular/angular-cli/commit/8fa682e571dbba4bf249ceb3ca490c4ddd4d7fe5) | fix | remove deleted assets from output during watch mode | +| [48cae815c](https://github.com/angular/angular-cli/commit/48cae815cfd0124217c1b5bc8c92dfdb0b150101) | fix | skip vite SSR warmup file configuration when SSR is disabled | +| [ba16ad6b5](https://github.com/angular/angular-cli/commit/ba16ad6b56e9a1ae0f380141bc1e1253a75fcf6b) | fix | support incremental build file results in watch mode | +| [955acef3d](https://github.com/angular/angular-cli/commit/955acef3d504ac924bd813f401fa9b49edbd337b) | fix | trigger browser reload on asset changes with Vite dev server | +| [e74300a2c](https://github.com/angular/angular-cli/commit/e74300a2cbc666482992fa8d6dbfeef37f3a9db5) | fix | use component updates for component style HMR | +| [6a19c217e](https://github.com/angular/angular-cli/commit/6a19c217eaebf9c0bffba8482545efc375fd2a8a) | fix | warn when using both `isolatedModules` and `emitDecoratorMetadata` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| [8d7a51dfc](https://github.com/angular/angular-cli/commit/8d7a51dfc9658aa2f0f0c527435c05c2b10f34e5) | feat | add `modulepreload` for lazy-loaded routes | +| [41ece633b](https://github.com/angular/angular-cli/commit/41ece633b3d42ef110bf6085fe0783ab2a56efcd) | feat | redirect to preferred locale when accessing root route without a specified locale | +| [3feecddbb](https://github.com/angular/angular-cli/commit/3feecddbba0d0559da10a45ad4040faf8e9d5198) | fix | disable component boostrapping when running route extraction | +| [6edb90883](https://github.com/angular/angular-cli/commit/6edb90883733040d77647cf24dea7f53b1b6ceaa) | fix | throw error when using route matchers | + + + + + +# 19.0.7 (2025-01-08) + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [95c22aeff](https://github.com/angular/angular-cli/commit/95c22aeff4560a199416a20c3622301c5c690119) | fix | provide better error when builder is not defined | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [028652992](https://github.com/angular/angular-cli/commit/028652992f0f9cc6fec5de35be7ecf74ec3947c5) | fix | preserve css type for jasmine.css | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [f7522342a](https://github.com/angular/angular-cli/commit/f7522342a8dd99543422629db6dcf1228c5d7279) | fix | add asset tracking to application builder watch files | +| [e973643bf](https://github.com/angular/angular-cli/commit/e973643bfbe47447ca522ca59b07a94fe6c03e0b) | fix | do not mark Babel \_defineProperty helper function as pure | +| [881095eec](https://github.com/angular/angular-cli/commit/881095eec5cdc80fe79de8fdbe05ba985bf8210a) | fix | enable serving files with bundle-like names | +| [db10af0b3](https://github.com/angular/angular-cli/commit/db10af0b3a775619ac71acdd0258cc58e96e948c) | fix | fix incorrect budget calculation | +| [c822f8f15](https://github.com/angular/angular-cli/commit/c822f8f154b75a8b8e48e32953bee7ec2763fd13) | fix | handle relative URLs when constructing new URLs during server fetch | +| [b43c648b0](https://github.com/angular/angular-cli/commit/b43c648b02c181c1d98cd3293d89ad8b131a3f51) | fix | mitigate JS transformer worker execArgv errors | +| [1f2481a4f](https://github.com/angular/angular-cli/commit/1f2481a4f5155368aa571fc6679e3ef8af0ce56f) | fix | pass `define` option defined in application builder to Vite prebundling | +| [c94f568a4](https://github.com/angular/angular-cli/commit/c94f568a412384bb8e4b66928f41b60220c8b7f4) | fix | warn when `@angular/localize/init` is imported directly | + + + + + +# 19.0.6 (2024-12-18) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [db7421231](https://github.com/angular/angular-cli/commit/db7421231c3da7bbbfde72dc35642aaf005fbeca) | fix | jasmine.clock with app builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [5fbc105ed](https://github.com/angular/angular-cli/commit/5fbc105ed0cb76106916660d99fc53d7480dcbc8) | fix | force HTTP/1.1 in dev-server SSR with SSL | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [2f4df6b2b](https://github.com/angular/angular-cli/commit/2f4df6b2be458b3651df49f3bced923e8df4d547) | fix | correctly resolve pre-transform resources in Vite SSR without AppEngine | +| [0789a9e13](https://github.com/angular/angular-cli/commit/0789a9e133fed4edc29b108630b2cf91e157127e) | fix | ensure correct `Location` header for redirects behind a proxy | + + + + + +# 19.0.5 (2024-12-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [6c319e44c](https://github.com/angular/angular-cli/commit/6c319e44c707b93e430da93fe815ba839ab18ea1) | fix | fix webpack config transform for karma | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [251bd9f22](https://github.com/angular/angular-cli/commit/251bd9f226f73529e824b131fa8d08b77aa00d09) | fix | Fixing auto-csp edge cases where | +| [1047b8635](https://github.com/angular/angular-cli/commit/1047b8635699d55886fff28cbf02d36df224958d) | fix | handle external `@angular/` packages during SSR ([#29094](https://github.com/angular/angular-cli/pull/29094)) | +| [376ee9966](https://github.com/angular/angular-cli/commit/376ee996699a9610984f3d3e36b3331557dbeaca) | fix | provide component HMR update modules to dev-server SSR | +| [5ea9ce376](https://github.com/angular/angular-cli/commit/5ea9ce3760a191d13db08f5ae7448ce089e8eacd) | fix | use consistent path separators for template HMR identifiers | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [b3c6c7eb2](https://github.com/angular/angular-cli/commit/b3c6c7eb2cc796796d99758368706b0b8682ae69) | fix | include `Content-Language` header when locale is set | +| [4203efb90](https://github.com/angular/angular-cli/commit/4203efb90a38fe2f0d45fabab80dc736e8ca2b7b) | fix | disable component bootstrapping during route extraction | + + + + + +# 19.0.4 (2024-12-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [23667ed4a](https://github.com/angular/angular-cli/commit/23667ed4aa0bedbb591dc0284116402dc42ed95c) | fix | handle windows spec collisions | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [fc41f50b5](https://github.com/angular/angular-cli/commit/fc41f50b53bbffead017b420105eed5bd8573ac1) | fix | show error when Node.js built-ins are used during `ng serve` | +| [14451e275](https://github.com/angular/angular-cli/commit/14451e2754caff2c9800cca17e11ffa452575f09) | perf | reuse TS package.json cache when rebuilding | + + + + + +# 19.0.3 (2024-12-04) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [4e82ca180](https://github.com/angular/angular-cli/commit/4e82ca180b330199b3dffadd9d590c8245dc7785) | fix | correctly select package versions in descending order during `ng add` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------- | +| [28a51cc5e](https://github.com/angular/angular-cli/commit/28a51cc5e4a08f9e9627a1ec160ce462d18b88d2) | fix | add required type to `CanDeactivate` guard ([#29004](https://github.com/angular/angular-cli/pull/29004)) | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [f26e1b462](https://github.com/angular/angular-cli/commit/f26e1b462ab012b0863f0889bcd60f5e07ca6fd2) | fix | add timeout to route extraction | +| [ab4e77c75](https://github.com/angular/angular-cli/commit/ab4e77c75524d42485ac124f4786ab54bc6c404a) | fix | allow .json file replacements with application builds | +| [06690d87e](https://github.com/angular/angular-cli/commit/06690d87eb590853eed6166857c9c1559d38d260) | fix | apply define option to JavaScript from scripts option | +| [775e6f780](https://github.com/angular/angular-cli/commit/775e6f7808e6edb89d29b72ee5bdc6d2b26cb30e) | fix | avoid deploy URL usage on absolute preload links | +| [21f21eda3](https://github.com/angular/angular-cli/commit/21f21eda39c62e284c6cbee0d0ebfe271f605239) | fix | ensure correct handling of `index.output` for SSR | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [75cf47e71](https://github.com/angular/angular-cli/commit/75cf47e71b0584e55750d5350932494f689a7e96) | fix | apply HTML transformation to CSR responses | +| [5880a0230](https://github.com/angular/angular-cli/commit/5880a02306d9f81f030fcdc91fc6aaeb1986e652) | fix | correctly handle serving of prerendered i18n pages | +| [277b8a378](https://github.com/angular/angular-cli/commit/277b8a3786d40cb8477287dcb3ef191ec8939447) | fix | ensure compatibility for `Http2ServerResponse` type | + + + + + +# 19.0.2 (2024-11-25) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [2f53e2af5](https://github.com/angular/angular-cli/commit/2f53e2af55794795979232b0f3e95359299e1aee) | fix | skip SSR routing prompt in webcontainer | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [f9da163f8](https://github.com/angular/angular-cli/commit/f9da163f8852800763844ae89e85eaafe0c37f2b) | fix | minimize reliance on esbuild `inject` to prevent code reordering | +| [c497749e6](https://github.com/angular/angular-cli/commit/c497749e670e916e17a4e7fb0acb1abe26d9bd9a) | fix | prevent errors with parameterized routes when `getPrerenderParams` is undefined | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [c8cd90e0f](https://github.com/angular/angular-cli/commit/c8cd90e0f601a6baa05b84e45bbd37b4bf6049f5) | fix | handle nested redirects not explicitly defined in router config | + + + + + +# 19.0.1 (2024-11-21) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [b63123f20](https://github.com/angular/angular-cli/commit/b63123f20702bd53ea99888b83b4253810ae0a09) | fix | use stylePreprocessorOptions | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [74461da64](https://github.com/angular/angular-cli/commit/74461da6439b70b5348c99682842ae20043d9b61) | fix | ensure accurate content length for server assets | +| [1b4dcedd5](https://github.com/angular/angular-cli/commit/1b4dcedd594b5d9a1701cd8d1e9874742c05e47f) | fix | use `sha256` instead of `sha-256` as hash algorithm name | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [8bd2b260e](https://github.com/angular/angular-cli/commit/8bd2b260e2008f1ffc71af0e55b27884c3660c54) | fix | handle baseHref that start with `./` | + + + + + +# 19.0.0 (2024-11-19) + +## Breaking Changes + +### @schematics/angular + +- The app-shell schematic is no longer compatible with Webpack-based builders. + +### @angular-devkit/build-angular + +- The `browserTarget` option has been removed from the DevServer and ExtractI18n builders. `buildTarget` is to be used instead. +- Protractor is no longer supported. + + Protractor was marked end-of-life in August 2023 (see https://protractortest.org/). Projects still relying on Protractor should consider migrating to another E2E testing framework, several support solid migration paths from Protractor. + - https://angular.dev/tools/cli/end-to-end + - https://blog.angular.dev/the-state-of-end-to-end-testing-with-angular-d175f751cb9c + +### @angular-devkit/core + +- The deprecated `fileBuffer` function is no longer available. Update your code to use `stringToFileBuffer` instead to maintain compatibility. + + **Note:** that this change does not affect application developers. + +### @angular/build + +- The `@angular/localize/init` polyfill will no longer be added automatically to projects. To prevent runtime issues, ensure that this polyfill is manually included in the "polyfills" section of your "angular.json" file if your application relies on Angular localization features. + +### @angular/ssr + +- The `CommonEngine` API now needs to be imported from `@angular/ssr/node`. + + **Before** + + ```ts + import { CommonEngine } from '@angular/ssr'; + ``` + + **After** + + ```ts + import { CommonEngine } from '@angular/ssr/node'; + ``` + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [37693c40e](https://github.com/angular/angular-cli/commit/37693c40e3afc4c6dd7c949ea658bdf94146c9d8) | feat | add package manager option to blank schematic | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [a381a3db1](https://github.com/angular/angular-cli/commit/a381a3db187f7b20e5ec8d1e1a1f1bd860426fcd) | feat | add option to export component as default | +| [755f3a07f](https://github.com/angular/angular-cli/commit/755f3a07f5fe485c1ed8c0c6060d6d5c799c085c) | feat | add option to setup new workspace or application as zoneless mode | +| [cfca5442e](https://github.com/angular/angular-cli/commit/cfca5442ec01cc4eff4fe75822eb7ef780ccfef1) | feat | integrate `withEventReplay()` in `provideClientHydration` for new SSR apps | +| [292a4b7c2](https://github.com/angular/angular-cli/commit/292a4b7c2f62828606c42258db524341f4a6391e) | feat | update app-shell and ssr schematics to adopt new Server Rendering API | +| [b1504c3bc](https://github.com/angular/angular-cli/commit/b1504c3bcca4d4c313e5d795ace8b074fb1f8890) | fix | component spec with export default | +| [4b4e000dd](https://github.com/angular/angular-cli/commit/4b4e000dd60bb43df5c8694eb8a0bc0b45d1cf8d) | fix | don't show server routing prompt when using `browser` builder | +| [4e2a5fe15](https://github.com/angular/angular-cli/commit/4e2a5fe155006e7154326319ed39e77e5693d9b3) | fix | enable opt-in for new `@angular/ssr` feature | +| [fcf7443d6](https://github.com/angular/angular-cli/commit/fcf7443d626d1f3e828ebfad464598f7b9ddef12) | fix | explicitly set standalone:false | +| [7992218a9](https://github.com/angular/angular-cli/commit/7992218a9c22ea9469bd3386c7dc1d5efc6e51f9) | fix | remove `declaration` and `sourceMap` from default tsconfig | +| [9e6ab1bf2](https://github.com/angular/angular-cli/commit/9e6ab1bf231b35aceb989337fac55a6136594c5d) | fix | use default import for `express` | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [201b60e1d](https://github.com/angular/angular-cli/commit/201b60e1dd25b4abb7670e21d103b67d4eda0e14) | feat | handle string key/value pairs, e.g. --define | +| [b847d4460](https://github.com/angular/angular-cli/commit/b847d4460c352604e1935d494efd761915caaa3f) | fix | recommend optional application update migration during v19 update | +| [f249e7e85](https://github.com/angular/angular-cli/commit/f249e7e856bf16e8c5f154fdb8aff36421649a1b) | perf | enable Node.js compile code cache when available | +| [ecc107d83](https://github.com/angular/angular-cli/commit/ecc107d83bfdfd9d5dd1087e264892d60361625c) | perf | enable Node.js compile code cache when available | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [78f76485f](https://github.com/angular/angular-cli/commit/78f76485fe315ffd0262c1a3732092731235828b) | feat | merge object options from CLI | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- | +| [0a4ef3026](https://github.com/angular/angular-cli/commit/0a4ef302635e4665ae9881746867dd80ca0d2dc7) | feat | karma-coverage w/ app builder | +| [dcbdca85c](https://github.com/angular/angular-cli/commit/dcbdca85c7fe1a7371b8f6662e0f68e24d56102e) | feat | karma+esbuild+watch | +| [54594b5ab](https://github.com/angular/angular-cli/commit/54594b5abfa4c9301cc369e5dea5f76b71e51ab0) | feat | support karma with esbuild | +| [ea5ae68da](https://github.com/angular/angular-cli/commit/ea5ae68da9e7f2b598bae2ca9ac8be9c20ce7888) | fix | bring back style tags in browser builder | +| [476f94f51](https://github.com/angular/angular-cli/commit/476f94f51a3403d03ceb9f58ffb4a3564cc52e5a) | fix | fix --watch regression in karma | +| [25d928b4f](https://github.com/angular/angular-cli/commit/25d928b4fde00ae8396f6b9cfcd92b5254fc49aa) | fix | fix hanging terminal when `browser-sync` is not installed | +| [2ec877dd0](https://github.com/angular/angular-cli/commit/2ec877dd0dede8f3ee849fe83b4a4427bab96447) | fix | handle basename collisions | +| [ab6e19e1f](https://github.com/angular/angular-cli/commit/ab6e19e1f9a8781334821048522abe86b149c9c3) | fix | handle main field | +| [43e7aae22](https://github.com/angular/angular-cli/commit/43e7aae2284ff15e0282c9d9597c4f31cf1f60a4) | fix | remove double-watch in karma | +| [1e37b5939](https://github.com/angular/angular-cli/commit/1e37b59396a2f815d1671ccecc03ff8441730391) | fix | serve assets | +| [9d7613db9](https://github.com/angular/angular-cli/commit/9d7613db9bf8b397d5896fcdf6c7b0efeaffa5d5) | fix | zone.js/testing + karma + esbuild | +| [e40384e63](https://github.com/angular/angular-cli/commit/e40384e637bc6f92c28f4e572f986ca902938ba2) | refactor | remove deprecated `browserTarget` | +| [62877bdf2](https://github.com/angular/angular-cli/commit/62877bdf2b0449d8c12a167c59d0c24c77467f37) | refactor | remove Protractor builder and schematics | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------ | +| [0d8a1006d](https://github.com/angular/angular-cli/commit/0d8a1006d4629d8af1144065ea237ab30916e66e) | refactor | remove deprecated `fileBuffer` function in favor of `stringToFileBuffer` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------- | +| [b6951f448](https://github.com/angular/angular-cli/commit/b6951f4482418f65e4bd1c15d5f7f051c91d59db) | feat | add `sass` to `stylePreprocessorOptions` in application builder | +| [efb434136](https://github.com/angular/angular-cli/commit/efb434136d8c8df207747ab8fd87b7e2116b7106) | feat | Auto-CSP support as a part of angular.json schema | +| [816e3cb86](https://github.com/angular/angular-cli/commit/816e3cb868961c57a68783601b919370076c41dc) | feat | enable component stylesheet hot replacement by default | +| [3b00fc908](https://github.com/angular/angular-cli/commit/3b00fc908d4f07282e89677928e00665c8578ab5) | feat | introduce `outputMode` option to the application builder | +| [7d883a152](https://github.com/angular/angular-cli/commit/7d883a152e978112245a98f2f737764caa76ec0f) | feat | introduce `ssr.experimentalPlatform` option | +| [c48d6947e](https://github.com/angular/angular-cli/commit/c48d6947ed17eab19822a97492e3686bcf059494) | feat | set development/production condition | +| [f63072668](https://github.com/angular/angular-cli/commit/f63072668e44254da78170445ac2417c7bc1aa18) | feat | utilize `ssr.entry` during prerendering to enable access to local API routes | +| [bbc290133](https://github.com/angular/angular-cli/commit/bbc290133fc93186980ca3c43f221847ba8e858a) | feat | utilize `ssr.entry` in Vite dev-server when available | +| [5a7a2925b](https://github.com/angular/angular-cli/commit/5a7a2925b1f649eabbeb0a75452978cddb3f243d) | fix | add missing redirect in SSR manifest | +| [06e5176c2](https://github.com/angular/angular-cli/commit/06e5176c2d3b27aaeb117374a8ae402c6a4c6319) | fix | add warning when `--prerendering` or `--app-shell` are no-ops | +| [ecaf870b5](https://github.com/angular/angular-cli/commit/ecaf870b5cddd5d43d297f1193eb11b8f73757c0) | fix | always clear dev-server error overlay on non-error result | +| [f8677f6a9](https://github.com/angular/angular-cli/commit/f8677f6a9ba155b04c692814a1bc13f5cc47d94d) | fix | always record component style usage for HMR updates | +| [099e477a8](https://github.com/angular/angular-cli/commit/099e477a8f1bbcf9d0f415dc6fd4743107c967f7) | fix | avoid hashing development external component stylesheets | +| [3602bbb77](https://github.com/angular/angular-cli/commit/3602bbb77b8924e89978427d9115f0b1fd7d46b7) | fix | avoid overwriting inline style bundling additional results | +| [71534aadc](https://github.com/angular/angular-cli/commit/71534aadc403404e2dc9bc12054f32c3ed157db9) | fix | check referenced files against native file paths | +| [fed31e064](https://github.com/angular/angular-cli/commit/fed31e064611894934c86ed36e8b0808029d4143) | fix | correctly use dev-server hmr option to control stylesheet hot replacement | +| [b86bb080e](https://github.com/angular/angular-cli/commit/b86bb080e3a58a3320b2f68fb79edcdc98bfa7e9) | fix | disable dev-server websocket when live reload is disabled | +| [7c50ba9e2](https://github.com/angular/angular-cli/commit/7c50ba9e2faca445c196c69e972ac313547dda54) | fix | ensure `index.csr.html` is always generated when prerendering or SSR are enabled | +| [efb2232df](https://github.com/angular/angular-cli/commit/efb2232df5475699a44d0f76a70e2d7de4a71f5a) | fix | ensure accurate content size in server asset metadata | +| [18a8584ea](https://github.com/angular/angular-cli/commit/18a8584ead439d044760fe2abb4a7f657a0b10e3) | fix | ensure SVG template URLs are considered templates with external stylesheets | +| [7502fee28](https://github.com/angular/angular-cli/commit/7502fee28a057b53e60b97f55b5aff5281019f1b) | fix | Exclude known `--import` from execArgv when spawning workers | +| [2551df533](https://github.com/angular/angular-cli/commit/2551df533d61400c0fda89db77a93378480f5047) | fix | fully disable component style HMR in JIT mode | +| [c41529cc1](https://github.com/angular/angular-cli/commit/c41529cc1d762cf508eccf46c44256df21afe24f) | fix | handle `APP_BASE_HREF` correctly in prerendered routes | +| [87a90afd4](https://github.com/angular/angular-cli/commit/87a90afd4600049b184b32f8f92a0634e25890c0) | fix | incomplete string escaping or encoding | +| [1bb68ba68](https://github.com/angular/angular-cli/commit/1bb68ba6812236a135c1599031bf7e1b7e0d1d79) | fix | move lmdb to optionalDependencies | +| [a995c8ea6](https://github.com/angular/angular-cli/commit/a995c8ea6d17778af031c2f9797e52739ea4dc81) | fix | prevent prerendering of catch-all routes | +| [1654acf0f](https://github.com/angular/angular-cli/commit/1654acf0ff3010b619a22d11f17eec9975d8e2a2) | fix | relax constraints on external stylesheet component id | +| [0d4558ea5](https://github.com/angular/angular-cli/commit/0d4558ea516a4b8716f2442290e05354c502a49e) | fix | set `ngServerMode` during vite prebundling | +| [55d7f01b6](https://github.com/angular/angular-cli/commit/55d7f01b66f4867aad4598574582e8505f201c82) | fix | simplify disabling server features with `--no-server` via command line | +| [cf0228b82](https://github.com/angular/angular-cli/commit/cf0228b828fc43b1b33d48dc0977ff59abb597c2) | fix | skip wildcard routes from being listed as prerendered routes | +| [af52fb49b](https://github.com/angular/angular-cli/commit/af52fb49bdd913af8af9bfbe36be279fce70de39) | fix | synchronize import/export conditions between bundler and TypeScript | +| [6c618d495](https://github.com/angular/angular-cli/commit/6c618d495c54394eb2b87aee36ba5436c06557bd) | fix | update logic to support both internal and external SSR middlewares | +| [bfa8fec9b](https://github.com/angular/angular-cli/commit/bfa8fec9b17d421925a684e2b642dee70165a879) | fix | use named export `reqHandler` for server.ts request handling | +| [c8e1521a2](https://github.com/angular/angular-cli/commit/c8e1521a2bd5b47c811e5d7f9aea7f57e92a4552) | fix | workaround Vite CSS ShadowDOM hot replacement | +| [d6a34034d](https://github.com/angular/angular-cli/commit/d6a34034d7489c09753090642ade4c606cd98ece) | refactor | remove automatic addition of `@angular/localize/init` polyfill and related warnings | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| [92209dd2e](https://github.com/angular/angular-cli/commit/92209dd2e93af450e3fc657609efe95c6a6b3963) | feat | add `createRequestHandler` and `createNodeRequestHandler `utilities | +| [41fb2ed86](https://github.com/angular/angular-cli/commit/41fb2ed86056306406832317178ca5d94aa110e2) | feat | Add `getHeaders` Method to `AngularAppEngine` and `AngularNodeAppEngine` for handling pages static headers | +| [f346ee8a8](https://github.com/angular/angular-cli/commit/f346ee8a8819bb2eaf0ffb3d5523b00093be09e5) | feat | add `isMainModule` function | +| [d66aaa3ca](https://github.com/angular/angular-cli/commit/d66aaa3ca458e05b535bec7c1dcb98b0e9c5202e) | feat | add server routing configuration API | +| [bca568389](https://github.com/angular/angular-cli/commit/bca56838937f942c5ef902f5c98d018582188e84) | feat | dynamic route resolution using Angular router | +| [30c25bf68](https://github.com/angular/angular-cli/commit/30c25bf6885fefea6094ec1815e066e4c6ada097) | feat | export `AngularAppEngine` as public API | +| [455b5700c](https://github.com/angular/angular-cli/commit/455b5700c29845829235e17efec320e634553108) | feat | expose `writeResponseToNodeResponse` and `createWebRequestFromNodeRequest` in public API | +| [9692a9054](https://github.com/angular/angular-cli/commit/9692a9054c3cdbf151df01279c2d268332b1a032) | feat | improve handling of aborted requests in `AngularServerApp` | +| [576ff604c](https://github.com/angular/angular-cli/commit/576ff604cd739a9f41d588fa093ca2568e46826c) | feat | introduce `AngularNodeAppEngine` API for Node.js integration | +| [3c9697a8c](https://github.com/angular/angular-cli/commit/3c9697a8c34a5e0f3470bde73f11f9f32107f42e) | feat | introduce new hybrid rendering API | +| [4b09887a9](https://github.com/angular/angular-cli/commit/4b09887a9c82838ccb7a6c95d66225c7875e562b) | feat | move `CommonEngine` API to `/node` entry-point | +| [d43180af5](https://github.com/angular/angular-cli/commit/d43180af5f3e7b29387fd06625bd8e37f3ebad95) | fix | add missing peer dependency on `@angular/platform-server` | +| [74b3e2d51](https://github.com/angular/angular-cli/commit/74b3e2d51c6cf605abd05da81dc7b4c3ccd9b3ea) | fix | add validation to prevent use of `provideServerRoutesConfig` in browser context | +| [2640bf7a6](https://github.com/angular/angular-cli/commit/2640bf7a680300acf18cf6502c57a00e0a5bfda9) | fix | correct route extraction and error handling | +| [44077f54e](https://github.com/angular/angular-cli/commit/44077f54e9a95afa5c1f85cf198aaa3412ee08d8) | fix | designate package as side-effect free | +| [df4e1d360](https://github.com/angular/angular-cli/commit/df4e1d3607c2d5bf71d1234fa730e63cd6ab594b) | fix | enable serving of prerendered pages in the App Engine | +| [0793c78cf](https://github.com/angular/angular-cli/commit/0793c78cfcbfc5d55fe6ce2cb53cada684bcb8dc) | fix | ensure wildcard RenderMode is applied when no Angular routes are defined | +| [65b6e75a5](https://github.com/angular/angular-cli/commit/65b6e75a5dca581a57a9ac3d61869fdd20f7dc2e) | fix | export `RESPONSE_INIT`, `REQUEST`, and `REQUEST_CONTEXT` tokens | +| [4ecf63a77](https://github.com/angular/angular-cli/commit/4ecf63a777871bf214bf42fe1738c206bde3201c) | fix | export PrerenderFallback | +| [50df63196](https://github.com/angular/angular-cli/commit/50df631960550049e7d1779fd2c8fbbcf549b8ef) | fix | improve handling of route mismatches between Angular server routes and Angular router | +| [3cf7a5223](https://github.com/angular/angular-cli/commit/3cf7a522318e34daa09f29133e8c3444f154ca0b) | fix | initialize the DI tokens with `null` to avoid requiring them to be set to optional | +| [85df4011b](https://github.com/angular/angular-cli/commit/85df4011ba27254ddb7f22dae550272c9c4406dd) | fix | resolve `bootstrap is not a function` error | +| [e9c9e4995](https://github.com/angular/angular-cli/commit/e9c9e4995e39d9d62d10fe0497e0b98127bc8afa) | fix | resolve circular dependency issue from main.server.js reference in manifest | +| [64c52521d](https://github.com/angular/angular-cli/commit/64c52521d052f850aa7ea1aaadfd8a9fcee9c387) | fix | show error when multiple routes are set with `RenderMode.AppShell` | +| [280ebbda4](https://github.com/angular/angular-cli/commit/280ebbda4c65e19b83448a1bb0de056a2ee5d1c6) | fix | support for HTTP/2 request/response handling | +| [fb05e7f0a](https://github.com/angular/angular-cli/commit/fb05e7f0abd9d68ac03f243c7774260619b8a623) | fix | use wildcard server route configuration on the '/' route when the app router is empty | +| [12ff37adb](https://github.com/angular/angular-cli/commit/12ff37adbed552fc0db97251c30c889ef00e50f3) | perf | cache generated inline CSS for HTML | +| [1d70e3b46](https://github.com/angular/angular-cli/commit/1d70e3b4682806a55d6f7ddacbafcbf615b2a10c) | perf | cache resolved entry-points | +| [f460b91d4](https://github.com/angular/angular-cli/commit/f460b91d46ea5b0413596c4852c80d71d5308910) | perf | integrate ETags for prerendered pages | +| [e52ae7f6f](https://github.com/angular/angular-cli/commit/e52ae7f6f5296a9628cc4a517e82339ac54925bb) | perf | prevent potential stampede in entry-points cache | + + + + + +# 18.2.12 (2024-11-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [c3925ed7f](https://github.com/angular/angular-cli/commit/c3925ed7f8e34fd9816cf5a4e8d63c2c45d31d53) | fix | support default options for multiselect list x-prompt | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [c8bee8415](https://github.com/angular/angular-cli/commit/c8bee8415099dfa03d5309183ebbbaab73b2a0eb) | fix | allow .js file replacements in all configuration cases | +| [93f552112](https://github.com/angular/angular-cli/commit/93f552112c2bbd10bc0cee4afcae5b012242636c) | fix | improve URL rebasing for hyphenated Sass namespaced variables | + + + + + +# 18.2.11 (2024-10-30) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [87ec15ba2](https://github.com/angular/angular-cli/commit/87ec15ba266436b7b99b0629beaea3e487434115) | fix | show error message when error stack is undefined | + + + + + +# 18.2.10 (2024-10-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [7b775f4e0](https://github.com/angular/angular-cli/commit/7b775f4e008652777bbe7b788dabed02bcc70cc7) | fix | update `http-proxy-middleware` to `3.0.3` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [b1e5f51f9](https://github.com/angular/angular-cli/commit/b1e5f51f9111d7da56ebe64cad51936ad659782d) | fix | Address build issue in Node.js LTS versions with prerendering or SSR | + + + + + +# 17.3.11 (2024-10-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [8bad9cee0](https://github.com/angular/angular-cli/commit/8bad9cee08982fffa5ce8244148b491e66191ed8) | fix | update `http-proxy-middleware` to `2.0.7` | + + + + + +# 18.2.9 (2024-10-16) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [237f7c5d0](https://github.com/angular/angular-cli/commit/237f7c5d0355e0a90b23156d3aa97f4328c869e7) | fix | update browserslist config to include last 2 Android major versions | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [d749ba6a3](https://github.com/angular/angular-cli/commit/d749ba6a3c3dd7a90317bd9b46e858a842f27696) | fix | allow direct bundling of TSX files with application builder | +| [b91c82d89](https://github.com/angular/angular-cli/commit/b91c82d8997c0009ed4bbf5e9cd9c82cb1f7f755) | fix | avoid race condition in sass importer | + + + + + +# 18.2.8 (2024-10-09) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [b522002ff](https://github.com/angular/angular-cli/commit/b522002fff763cda2ae1c746efcb2638d0099184) | fix | add validation for component and directive class name | +| [dfd2d5c05](https://github.com/angular/angular-cli/commit/dfd2d5c0500777fa5aea91519f6657aed7f3b7d7) | fix | include `index.csr.html` in resources asset group | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [9445916f9](https://github.com/angular/angular-cli/commit/9445916f9b5b9da69623bf86735264d8a5f26fb3) | fix | `Ctrl + C` not terminating dev-server with SSR | +| [9b5cfaa8c](https://github.com/angular/angular-cli/commit/9b5cfaa8ce9d12bf450e7527d479ce7a879ea1b8) | fix | always generate a new hash for optimized chunk | + + + + + +# 18.2.7 (2024-10-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [3f98193d6](https://github.com/angular/angular-cli/commit/3f98193d6963464bd04b510c2d045938f1418ff3) | fix | support single quote setting in JetBrains IDEs | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| [8274184e1](https://github.com/angular/angular-cli/commit/8274184e1c6fa43cc5101018b6fa86fd636a90ba) | fix | add `animate` to valid self-closing elements | +| [2648e811e](https://github.com/angular/angular-cli/commit/2648e811e7c71e8a68d1eb418d7dcdae42ebf9ff) | fix | add few more SVG elements animateMotion, animateTransform, and feBlend etc. to valid self-closing elements | +| [736e126e4](https://github.com/angular/angular-cli/commit/736e126e4836e1c3df32c0ab9ee40e58c16503c0) | fix | separate Vite cache by project | + + + + + +# 18.2.6 (2024-09-25) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [9d0b67124](https://github.com/angular/angular-cli/commit/9d0b67124e4855c5c4a2101b64f8ed86f8624561) | fix | allow missing HTML file request to fallback to index | +| [5fea635b2](https://github.com/angular/angular-cli/commit/5fea635b20b29429e355072c5adc5bf2a597a267) | fix | update rollup to 4.22.4 | + + + + + +# 17.3.10 (2024-09-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [30489d8fd](https://github.com/angular/angular-cli/commit/30489d8fd1cf738162d95333fe462eea58ba460f) | fix | update vite to 4.1.8 | + + + + + +# 18.2.5 (2024-09-18) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [707431625](https://github.com/angular/angular-cli/commit/7074316257bd736e0d3393368fc93dec9604b49e) | fix | support HTTP HEAD requests for virtual output files | +| [1032b3da1](https://github.com/angular/angular-cli/commit/1032b3da1a0f3aaf63d2fd3cd8c6fd3b0d0b578c) | fix | update vite to `5.4.6` | + + + + + +# 16.2.16 (2024-09-18) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [12aca0060](https://github.com/angular/angular-cli/commit/12aca0060492c73cec1bbc231119dde6a4b52607) | fix | update vite to 4.5.5 | + + + + + +# 18.2.4 (2024-09-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [765309a2e](https://github.com/angular/angular-cli/commit/765309a2e1bcd3bb07ff87062fc2dc04e4bce16f) | fix | prevent transformation of Node.js internal dependencies by Vite | + + + + + +# 18.2.3 (2024-09-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [482076612](https://github.com/angular/angular-cli/commit/482076612cac4b6565fc1b5e89ff9ca207653f87) | fix | update `webpack-dev-middleware` to `7.4.2` | + + + + + +# 18.2.2 (2024-08-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [504b00b93](https://github.com/angular/angular-cli/commit/504b00b93b80eec4185838b426c0f6acaa3a148e) | fix | clear context in Karma by default for single run executions | +| [82b76086e](https://github.com/angular/angular-cli/commit/82b76086eb519c224981038dfa55b2ec3cfec0b4) | fix | update webpack to `5.94.0` | + + + + + +# 17.3.9 (2024-08-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [e2c5c034d](https://github.com/angular/angular-cli/commit/e2c5c034d96962fe6f358285e376630c71ac9673) | fix | clear context in Karma by default for single run executions | +| [88501f3d5](https://github.com/angular/angular-cli/commit/88501f3d5586f72ee0900b8d351af3d72bdc0dee) | fix | upgrade webpack to `5.94.0` | + + + + + +# 16.2.15 (2024-08-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f596a3d5d](https://github.com/angular/angular-cli/commit/f596a3d5def009b5130440113e3c9b450eb98040) | fix | clear context in Karma by default for single run executions | +| [56fa051bd](https://github.com/angular/angular-cli/commit/56fa051bd92ad47ea089499a488f3566a93375e6) | fix | upgrade webpack to `5.94.0` | + + + + + +# 18.2.1 (2024-08-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [05a274a01](https://github.com/angular/angular-cli/commit/05a274a01365c21f69c0412f3455acd14cc6ddc5) | fix | prevent bypassing select/checkbox prompts on validation failure | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [94e27c88b](https://github.com/angular/angular-cli/commit/94e27c88bb968589bc8b9b5d6536ce6c0ba0b24f) | fix | prevent bypassing select/checkbox prompts on validation failure | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [ddeb2b2b9](https://github.com/angular/angular-cli/commit/ddeb2b2b93eaa9d8b659d17357aa2b7a9dc509ce) | fix | remove outdated browser-esbuild option warning | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [83b2699ab](https://github.com/angular/angular-cli/commit/83b2699abbf58a7c90d2339fa4a01d67aa2d2d33) | fix | improve error message when an unhandled exception occurs during prerendering | +| [0be4038a5](https://github.com/angular/angular-cli/commit/0be4038a503626e2e9f44d68fe5599cc6028dd8e) | fix | support reading on-disk files during i18n extraction | + + + + + +# 18.2.0 (2024-08-14) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [4da922e4f](https://github.com/angular/angular-cli/commit/4da922e4f4e905a1274e70adca1d875c025b8b46) | feat | use isolatedModules in generated project | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [24aaf1e37](https://github.com/angular/angular-cli/commit/24aaf1e37f49735ce97fe72fced3d53b51d6b631) | feat | support import attribute based loader configuration | + + + + + +# 18.1.4 (2024-08-07) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [f8b092711](https://github.com/angular/angular-cli/commit/f8b092711481a5754ea93bce65d706d261873810) | fix | allow explicitly disabling TypeScript incremental mode | +| [f3a5970fc](https://github.com/angular/angular-cli/commit/f3a5970fca0a196b1ac905306257d65bab3b1325) | fix | lazy load Node.js inspector for dev server | + + + + + +# 18.1.3 (2024-07-31) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [a28615d7d](https://github.com/angular/angular-cli/commit/a28615d7dd3f6503f257756058fe182ce6f6b052) | fix | add CSP `nonce` attribute to script tags when inline critical CSS is disabled | +| [747a1447c](https://github.com/angular/angular-cli/commit/747a1447c1855a1bb918e5f55e4ba4182235f88a) | fix | prevent build failures with remote CSS imports when Tailwind is configured | +| [c0933f2c0](https://github.com/angular/angular-cli/commit/c0933f2c0354a13ba3f752f29b24054177697faa) | fix | resolve error with `extract-i18n` builder for libraries | + + + + + +# 18.1.2 (2024-07-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [5b9378a3b](https://github.com/angular/angular-cli/commit/5b9378a3be3a1c4f465ba471a120a2edd3a4d2f8) | fix | account for HTML base HREF for dev-server externals | +| [3e4ea77d7](https://github.com/angular/angular-cli/commit/3e4ea77d755edce2c88d55b76860e6e91fb03f3c) | fix | correctly detect comma in Sass URL lexer | +| [d868270f1](https://github.com/angular/angular-cli/commit/d868270f1baf0fd5f2c5677691cc9c4e88b47d8f) | fix | prevent redirection loop | +| [3573ac655](https://github.com/angular/angular-cli/commit/3573ac6555ead2afc34979e284426a0204fff42c) | fix | serve HTML files directly | + + + + + +# 18.1.1 (2024-07-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [4f6cee272](https://github.com/angular/angular-cli/commit/4f6cee2721b52427624370f3f81f3edc140774e7) | fix | skip undefined files when generating budget stats | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [96dc7e6ed](https://github.com/angular/angular-cli/commit/96dc7e6ed3317e354fae82d1b42b31250e96d89d) | fix | remove Vite "/@id/" prefix for explicit external dependencies | +| [bdef39801](https://github.com/angular/angular-cli/commit/bdef3980160db8c27ae103444a41275351434b78) | fix | resolve only ".wasm" files | + + + + + +# 18.1.0 (2024-07-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [6d266c146](https://github.com/angular/angular-cli/commit/6d266c146c9e141396236b93f2bfafcb261fd7aa) | fix | add fallbacks for migration package resolution | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [22e05dcb4](https://github.com/angular/angular-cli/commit/22e05dcb4a9ae963997c58fad86125ca51b19a36) | fix | generate new projects with ECMAScript standard class field behavior | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [687a6c7ec](https://github.com/angular/angular-cli/commit/687a6c7eca740a98129196908689a44c181b33a5) | feat | add `--inspect` option to the dev-server | +| [628d87a94](https://github.com/angular/angular-cli/commit/628d87a9474ad2792b69bfbc501a2c5960b27db9) | feat | support WASM/ES Module integration proposal | +| [3e359da8d](https://github.com/angular/angular-cli/commit/3e359da8dfdbfdb99be13f5c52a7e429c106d4ad) | fix | address rxjs undefined issues during SSR prebundling | +| [4ff914a16](https://github.com/angular/angular-cli/commit/4ff914a16525350050c5e8359fb59f9d6f243d27) | fix | allow additional module preloads up to limit | +| [fb8e3c39a](https://github.com/angular/angular-cli/commit/fb8e3c39a8b265003e98c8c6b5a9ec898223249f) | fix | allow top-level await in zoneless applications | +| [83b75af9f](https://github.com/angular/angular-cli/commit/83b75af9f74af0742a6a78cf7531866b7ba434b6) | fix | check inlineSourceMap option with isolated modules optimization | +| [cd97134a6](https://github.com/angular/angular-cli/commit/cd97134a6e1468c6806c2bd753c934ec91bc3927) | fix | normalize paths during module resolution in Vite | +| [13d2100dd](https://github.com/angular/angular-cli/commit/13d2100ddcc670d69f2d7754890b80eae2e7649a) | fix | read WASM file from script location on Node.js | +| [3091956f5](https://github.com/angular/angular-cli/commit/3091956f503754f313dbf98a8de6d21d3d01ebe3) | fix | support import attributes in JavaScript transformer | +| [dd94a831b](https://github.com/angular/angular-cli/commit/dd94a831b4ce1ca55289fca1818aa26195e81dbc) | perf | enable dependency prebundling for server dependencies | +| [3acb77683](https://github.com/angular/angular-cli/commit/3acb7768317bb05a9cd73fa64e081b5ca0326189) | perf | use direct transpilation with isolated modules | + + + + + +# 18.0.7 (2024-07-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [67bf90131](https://github.com/angular/angular-cli/commit/67bf9013151c4e6a13c91ecf4afd78c863d9e33f) | fix | make `ng update` to keep newline at the end of package.json | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [9b43ecbd0](https://github.com/angular/angular-cli/commit/9b43ecbd0395027548781a19345fbcd82261d4f4) | fix | reduce the number of max workers to available CPUs minus one | +| [03dad6806](https://github.com/angular/angular-cli/commit/03dad680676c4b2272b65a51dd62d74360e20b78) | fix | rollback terser to `5.29.2` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [fc928f638](https://github.com/angular/angular-cli/commit/fc928f6386061f34f7cd3ef6bb6d25aa4a33a800) | fix | correctly name entry points to match budgets | +| [2d51e8607](https://github.com/angular/angular-cli/commit/2d51e86077c4687224e931f49c82a907f5229ae5) | fix | redirect to path with trailing slash for asset directories | +| [16f1c1e01](https://github.com/angular/angular-cli/commit/16f1c1e010090596b7d7c3911f01728e3feecc4d) | fix | reduce the number of max workers to available CPUs minus one | + + + + + +# 18.0.6 (2024-06-26) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [98a8a8a78](https://github.com/angular/angular-cli/commit/98a8a8a781fd7901f2e1c1d2eb22975ac65f0329) | fix | show JavaScript cache store initialization warning | + + + + + +# 18.0.5 (2024-06-20) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [5c705e800](https://github.com/angular/angular-cli/commit/5c705e800c17237d82bc9065f22e30b720ddcec0) | fix | update schematics to use RouterModule when --routing flag is present | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [86e031dc7](https://github.com/angular/angular-cli/commit/86e031dc7ef6c736e431caf973aca1233d912060) | fix | use istanbul-lib-instrument directly for karma code coverage | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [bdd168f37](https://github.com/angular/angular-cli/commit/bdd168f37f7757e0c02971e21e90479555a6e703) | fix | add CSP nonce to script with src tags | +| [405c14809](https://github.com/angular/angular-cli/commit/405c1480917d50c677be178c817b845f30cc8cce) | fix | automatically resolve `.mjs` files when using Vite | +| [7360a346e](https://github.com/angular/angular-cli/commit/7360a346ed1b329c0620301ce0e0464d5569a42f) | fix | use Node.js available parallelism for default worker count | + + + + + +# 18.0.4 (2024-06-13) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------- | +| [791ef809d](https://github.com/angular/angular-cli/commit/791ef809d8dfec8fde844e916973a05b2eb5c9d9) | fix | do not reference sourcemaps in web workers and global stylesheet bundles when hidden setting is enabled | +| [20fc6ca05](https://github.com/angular/angular-cli/commit/20fc6ca057e5190155474e7377bf9f22aab597dd) | fix | generate module preloads next to script elements in index HTML | +| [3a1bf5c8a](https://github.com/angular/angular-cli/commit/3a1bf5c8a52d6ec1eb337f0937bf073de2ea0b62) | fix | Initiate PostCSS only once | +| [78c611754](https://github.com/angular/angular-cli/commit/78c6117544afa1aa69ef5485f1c3b77b1207f6f1) | fix | issue warning when auto adding `@angular/localize/init` | + + + + + +# 18.0.3 (2024-06-05) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| [b709d2a24](https://github.com/angular/angular-cli/commit/b709d2a243926f1ab01e8c8a78d68fc57ab4cab3) | fix | add `schema.json` options to parsed command, also when a version is passed to `ng add @` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [43a2a7d13](https://github.com/angular/angular-cli/commit/43a2a7d137328c1f6f865b76585a92f4e5058b13) | fix | avoid escaping rebased Sass URL values | +| [9acb5c7ca](https://github.com/angular/angular-cli/commit/9acb5c7ca8e6d14379e39f56d2498c0276214210) | fix | disable JS transformer persistent cache on web containers | +| [346df4909](https://github.com/angular/angular-cli/commit/346df490976e39d791db5ecfa14eab6a5ad8d99d) | fix | improve Sass rebaser ident token detection | +| [6526a5f59](https://github.com/angular/angular-cli/commit/6526a5f590fbc7f26b9e613af3b5c497e30603b5) | fix | watch all related files during a Sass error | + + + + + +# 18.0.2 (2024-05-29) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [9967c04b8](https://github.com/angular/angular-cli/commit/9967c04b86c6928509c80af7144b342616e9681b) | fix | check both application builder packages in SSR schematic | +| [92b48ab14](https://github.com/angular/angular-cli/commit/92b48ab144fbe5b8f89d371b0a8fa94d0d17b72c) | fix | set builders `assets` option correctly for new applications | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [3bb06c37d](https://github.com/angular/angular-cli/commit/3bb06c37dc20d7af358f007b9928de71f39545d2) | fix | disable Worker wait loop for Sass compilations in web containers | +| [c4cf35923](https://github.com/angular/angular-cli/commit/c4cf359233e1044864539383912b9ba0432e149d) | fix | print Sass `@warn` location | +| [352879804](https://github.com/angular/angular-cli/commit/3528798042a232779478bf82b4d4f6521fab4c30) | fix | support valid self-closing MathML tags in HTML index file | +| [476f3084a](https://github.com/angular/angular-cli/commit/476f3084aff333a45c4937950abdba65cd420eba) | fix | support valid self-closing SVG tags in HTML index file | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [acbffd236](https://github.com/angular/angular-cli/commit/acbffd2368d3c979b26a4541d3f44387fdba0651) | fix | set manifest `icons` location to match `assets` builder option | + + + + + +# 18.0.1 (2024-05-23) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [01842f515](https://github.com/angular/angular-cli/commit/01842f5154fe0ec41226d1dd28e099bf57f3d2c9) | fix | use angular.dev in readme | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [7d253e9cd](https://github.com/angular/angular-cli/commit/7d253e9cd0bb6df829fd4229465f4334d5c134bb) | fix | avoid rebasing URLs with function calls | +| [6b6a76a99](https://github.com/angular/angular-cli/commit/6b6a76a998980392d78e1cabc5e5fe4af0ced01c) | fix | disable persistent disk caching inside webcontainers by default | +| [ba70a50b6](https://github.com/angular/angular-cli/commit/ba70a50b6bc45a6b07ff24feed3b36915294063b) | fix | handle esbuild-browser `polyfills` option as `string` during `ng serve` | +| [706423aca](https://github.com/angular/angular-cli/commit/706423acad2c431c4125166d078dbad804719d95) | fix | only import persistent cache store with active caching | + + + + + +# 18.0.0 (2024-05-22) + +## Breaking Changes + +### @angular/cli + +- The `ng doc` command has been removed without a replacement. To perform searches, please visit www.angular.dev +- Node.js support for versions <18.19.1 and <20.11.1 has been removed. + +### @angular-devkit/build-angular + +- By default, the index.html file is no longer emitted in the browser directory when using the application builder with SSR. Instead, an index.csr.html file is emitted. This change is implemented because in many cases server and cloud providers incorrectly treat the index.html file as a statically generated page. If you still require the old behavior, you can use the `index` option to specify the `output` file name. + + ```json + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/my-app", + "index": { + "input": "src/index.html", + "output": "index.html" + } + } + } + } + ``` + +- The support for the legacy Sass build pipeline, previously accessible via `NG_BUILD_LEGACY_SASS` when utilizing webpack-based builders, has been removed. + +## Deprecations + +### @angular-devkit/schematics + +- `NodePackageLinkTask` in `@angular-devkit/schematics`. A custom task should be created instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- | +| [ac3019570](https://github.com/angular/angular-cli/commit/ac301957093d0689c98f7debe98fbb2546c9b442) | feat | add `ng dev` alias to `ng serve` | +| [4087728c3](https://github.com/angular/angular-cli/commit/4087728c3e6350d85d653e9d053249ff77e639e6) | feat | support for Node.js v22 | +| [41ab6c8c3](https://github.com/angular/angular-cli/commit/41ab6c8c3486d7cf7c41c18ae3b603376f647605) | fix | add `--version` option | +| [df4dde95d](https://github.com/angular/angular-cli/commit/df4dde95daa12d5b08b3c4e937f4b4048d645254) | fix | add `@angular/build` package to update group list | +| [1039f6d79](https://github.com/angular/angular-cli/commit/1039f6d7997523dd4657c5c2a06631e6075b7bc0) | fix | change update guide link to angular.dev | +| [f4670fcb1](https://github.com/angular/angular-cli/commit/f4670fcb1af20a53501b557fc0e6126afce766d5) | fix | eliminate prompts during `ng version` command | +| [a99ec6a54](https://github.com/angular/angular-cli/commit/a99ec6a5453fb732500ef7abff67f76511a74da3) | fix | keep cli package first in update package group metadata | +| [dd786d495](https://github.com/angular/angular-cli/commit/dd786d495ce6e7d759b0b225b2efe25fb5727d08) | fix | only add --version option on default command | +| [03eee0545](https://github.com/angular/angular-cli/commit/03eee0545095ff958ac86cb5dfad44692ef018ae) | refactor | remove `ng doc` command | +| [c7b208555](https://github.com/angular/angular-cli/commit/c7b208555e34cc5ebf9cf2d335d257e72297cae9) | refactor | remove support for Node.js versions <18.19.1 and <20.11.1 | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [b2ac5fac7](https://github.com/angular/angular-cli/commit/b2ac5fac7d66ccd027f766565fa17c6a3bb18e44) | feat | allow application migration to use new build package in projects where possible | +| [6530aa11b](https://github.com/angular/angular-cli/commit/6530aa11bed5ef67d611e8aed268bd20345cf0e6) | feat | replace `assets` with `public` directory | +| [725883713](https://github.com/angular/angular-cli/commit/72588371385bebeea1003dff4d1d0a2ca9854321) | feat | use eventCoalescing option by default (standalone bootstrap) | +| [508d97da7](https://github.com/angular/angular-cli/commit/508d97da76b5359bc8029888ff0e9cfc59a6139c) | feat | use ngZoneEventCoalescing option by default (module bootstrap) | +| [f452589e2](https://github.com/angular/angular-cli/commit/f452589e2c921448b76a138a5f34ba92ad05e297) | feat | use TypeScript bundler module resolution for new projects | +| [95a4d6ee5](https://github.com/angular/angular-cli/commit/95a4d6ee56d80dce012cf2306422bb7fd8e0e32d) | fix | add less dependency in application migration if needed | +| [c46aa084f](https://github.com/angular/angular-cli/commit/c46aa084f53be7ebdb8cc450bd81907222d00275) | fix | add postcss dependency in application migration if needed | +| [157329384](https://github.com/angular/angular-cli/commit/157329384809d723c428a043712a331493826748) | fix | add spaces around eventCoalescing option | +| [23cc337aa](https://github.com/angular/angular-cli/commit/23cc337aa34c919e344ab001f5efbb8fe9ce3c7c) | fix | keep deployUrl option when migrating to application builder | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ddd08efef](https://github.com/angular/angular-cli/commit/ddd08efefecfe9b74db6a866a1bed0216380a28a) | fix | resolve builder aliases from containing package | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | +| [53c319aaa](https://github.com/angular/angular-cli/commit/53c319aaa95049b8558df80e57fa0a6318003121) | feat | add support for the `poll` option in the library builder | +| [83d1d233a](https://github.com/angular/angular-cli/commit/83d1d233a2eded71fcdd5fec4b1a90bdd4dbf132) | feat | enhance Sass rebasing importer for resources URL defined in variables and handling of external paths | +| [d51cb598a](https://github.com/angular/angular-cli/commit/d51cb598a74aba313aee212656de506004a041e6) | feat | inject event-dispatch in SSR HTML page | +| [0b03829bc](https://github.com/angular/angular-cli/commit/0b03829bcefea5c250c6a9ff880a737fcc351b2e) | feat | move i18n extraction for application builder to new build system package | +| [4ffe07aa2](https://github.com/angular/angular-cli/commit/4ffe07aa24a0fc9ff48461e9c3664d96e92317cf) | feat | move Vite-based dev-server for application builder to new build system package | +| [d1c632af9](https://github.com/angular/angular-cli/commit/d1c632af9a98d4e8975f198cf205194e2ebff209) | feat | support native async/await when app is zoneless | +| [37fc7f0cc](https://github.com/angular/angular-cli/commit/37fc7f0ccf3b8e6f31a0c5b2eaf4aee52f439472) | fix | disable Vite prebundling when script optimizations are enabled | +| [2acf95a94](https://github.com/angular/angular-cli/commit/2acf95a94993e51876d4004d2c3bc0a04be0a419) | fix | do not generate an `index.html` file in the browser directory when using SSR. | +| [8a54875cb](https://github.com/angular/angular-cli/commit/8a54875cbb654f95d5213b2d84190bd3814d6810) | fix | handle wrapping of class expressions emitted by esbuild | +| [97973059e](https://github.com/angular/angular-cli/commit/97973059ec56a573629f7a367757773a3cfabe17) | refactor | remove Sass legacy implementation | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------- | +| [797584583](https://github.com/angular/angular-cli/commit/797584583138c9223bf238ae8f352e77575bd25a) | refactor | deprecate `NodePackageLinkTask` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [810d213e1](https://github.com/angular/angular-cli/commit/810d213e1813dd01620173f5f999dca7bccf8ea1) | feat | introduce new official build system package | +| [b7a0792b3](https://github.com/angular/angular-cli/commit/b7a0792b3286fc98d1343f55b5df89ddf13e36bc) | fix | add a maximum rendering timeout for SSG | +| [411115303](https://github.com/angular/angular-cli/commit/41111530349db1ac199c3ac1d4eccbde8b023123) | fix | add console note about development server raw file size | +| [921fa7cf4](https://github.com/angular/angular-cli/commit/921fa7cf4adc69d3cb6ec7dd5c8d7cace33a502e) | fix | add missing `ansi-colors` and `picomatch` dependencies | +| [791cf75af](https://github.com/angular/angular-cli/commit/791cf75afb0b3b5892c41296bc4049a2c10926e8) | fix | check both potential build packages in Angular version check | +| [4d7cd5e3e](https://github.com/angular/angular-cli/commit/4d7cd5e3ed303c53b2cc63720b9a577e2f46f170) | fix | correctly wrap class expressions with static properties or blocks emitted by esbuild | +| [57f448a0f](https://github.com/angular/angular-cli/commit/57f448a0f70c76c1a0ebbe941f82eec1d698e7d4) | fix | decode URL pathname decoding during SSG fetch | +| [940e382db](https://github.com/angular/angular-cli/commit/940e382db27474dba6479f57e4ffefee04cfca66) | fix | disable Vite prebundling when script optimizations are enabled | +| [70dbc7a6e](https://github.com/angular/angular-cli/commit/70dbc7a6e9a7f6d55aeb4e10e8e686b186e6cdf3) | fix | emit error for invalid self-closing element in index HTML | +| [44b401747](https://github.com/angular/angular-cli/commit/44b401747f78bab208ce863f9c08e7a12f01fe27) | fix | ensure input index HTML file triggers rebuilds when changed | +| [dff4deaeb](https://github.com/angular/angular-cli/commit/dff4deaeb366d0ff734ae02abdbaa1fcdcd901aa) | fix | ensure recreated files are watched | +| [17931166d](https://github.com/angular/angular-cli/commit/17931166d83a4b18d2f4eb81f8a445b2365c71aa) | fix | format sizes using decimal byte units consistently | +| [2085365e0](https://github.com/angular/angular-cli/commit/2085365e04c9b08dbf2024036b93609046f2f458) | fix | only generate shallow preload links for initial files | +| [33cd47c85](https://github.com/angular/angular-cli/commit/33cd47c85ea12df57ec7b244beccfa299c927765) | fix | properly configure headers for media resources and HTML page | +| [d10fece2c](https://github.com/angular/angular-cli/commit/d10fece2c17183e18d04733dec22459ced1cc1c8) | fix | properly rebase Sass url() values with leading interpolations | +| [3f2963835](https://github.com/angular/angular-cli/commit/3f2963835759fa3eed1faf64a7b87d5dcf8a6fa3) | perf | add persistent caching of JavaScript transformations | +| [a15eb7d1c](https://github.com/angular/angular-cli/commit/a15eb7d1c6a26f5d94da5566f8b4ac1810ea1361) | perf | improve rebuild time for file loader usage with prebundling | + + + + + +# 17.3.8 (2024-05-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [3ada6eb52](https://github.com/angular/angular-cli/commit/3ada6eb5256631ca3a951525fc9814ad0447a41f) | fix | clarify optional migration instructions during ng update | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------- | +| [4b6ba8df1](https://github.com/angular/angular-cli/commit/4b6ba8df1ab8f4801fba7ddc38812417e274d960) | fix | `SchematicTestRunner.runExternalSchematic` fails with "The encoded data was not valid for encoding utf-8" | + + + + + +# 17.3.7 (2024-05-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [998c72036](https://github.com/angular/angular-cli/commit/998c720363087f3f0b1748d3f875e5b536a3119d) | fix | decode URL pathname decoding during SSG fetch | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [1ab1c6c9e](https://github.com/angular/angular-cli/commit/1ab1c6c9e10ce938402355afed4602b76ac08a0e) | fix | use web standard error check for Deno support | + + + + + +# 17.3.6 (2024-04-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [dcec59799](https://github.com/angular/angular-cli/commit/dcec59799faac66bf25043984c11944479efcf4d) | fix | properly configure headers for media resources and HTML page | + + + + + +# 17.3.5 (2024-04-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [6191d06ca](https://github.com/angular/angular-cli/commit/6191d06ca735a8fd29f048f319f912076abec698) | fix | address `Unable to deserialize cloned data` issue with Yarn PnP | +| [0335d6a5d](https://github.com/angular/angular-cli/commit/0335d6a5df1c0b0706673e6152e3bf5eb65d166a) | fix | remove `type="text/css"` from `style` tag | + + + + + +# 17.3.4 (2024-04-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [1128bdd64](https://github.com/angular/angular-cli/commit/1128bdd642c3e60c67485970e5cd354b2fde0f98) | fix | ensure esbuild-based builders exclusively produce ESM output | + + + + + +# 16.2.14 (2024-04-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [1068c3c73](https://github.com/angular/angular-cli/commit/1068c3c733a7c52e7876d43454d0ff590c99b61b) | fix | update vite to `4.5.3` | + + + + + +# 17.3.3 (2024-04-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [a673baf5c](https://github.com/angular/angular-cli/commit/a673baf5ce385415ded23641a2dc5cdcae8f3f5c) | fix | Revert "fix(@schematics/angular): rename SSR port env variable" | + + + + + +# 17.3.2 (2024-03-25) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [935f931ee](https://github.com/angular/angular-cli/commit/935f931eea8ddd1cb86b2275b8e384bf51e9153e) | fix | rename SSR port env variable | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [c9d436000](https://github.com/angular/angular-cli/commit/c9d4360000e6134b936781be3b0d5cf1871d44d7) | fix | `Internal server error: Invalid URL` when using a non localhost IP | +| [59fba38ec](https://github.com/angular/angular-cli/commit/59fba38ec6181c8d9c7a0636fb514c4b25aaf0cd) | fix | ensure proper resolution of linked SCSS files | +| [27dd8f208](https://github.com/angular/angular-cli/commit/27dd8f208911dbb2eda6d64efd6d1ce8c463ce35) | fix | service-worker references non-existent named index output | +| [c12907d92](https://github.com/angular/angular-cli/commit/c12907d92f8a2268541fd3bf28857dbb216ec7c9) | fix | update `webpack-dev-middleware` to `6.1.2` | + + + + + +# 16.2.13 (2024-03-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [5ad507e3d](https://github.com/angular/angular-cli/commit/5ad507e3d4cb27fb275d255018b9b6e735835711) | fix | `update webpack-dev-middleware` to `6.1.2` | + + + + + +# 15.2.11 (2024-03-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [c6feb0bb0](https://github.com/angular/angular-cli/commit/c6feb0bb0247a1cf17e17325b8c42d0d6a7d1451) | fix | `update webpack-dev-middleware` to `6.1.2` | + + + + + +# 17.3.1 (2024-03-20) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [198ca9afc](https://github.com/angular/angular-cli/commit/198ca9afcc9a043d4329c2f4032f0b9cefa11a2e) | fix | improve Sass format clarity for application style option | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------- | +| [2809971a5](https://github.com/angular/angular-cli/commit/2809971a57966cf79965c84a933f70709334c16b) | fix | only generate `server` directory when SSR is enabled | +| [3f601a14e](https://github.com/angular/angular-cli/commit/3f601a14e70540f37ef6c6559a5cd50bb6b453d7) | fix | typo in allowedHosts warning for unsupported vite options | +| [79c44adac](https://github.com/angular/angular-cli/commit/79c44adac4184408cbd1dc07989796f155cfc70e) | perf | avoid transforming empty component stylesheets | +| [cc3167f30](https://github.com/angular/angular-cli/commit/cc3167f3012aa621a7fc9277a9c8a82601f7d914) | perf | reduce build times for apps with a large number of components when utilizing esbuild-based builders | + + + + + +# 17.3.0 (2024-03-13) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [5ab71fc92](https://github.com/angular/angular-cli/commit/5ab71fc92ba26f6255e5a5c00e374709ff58d19d) | feat | update CSS/Sass import/use specifiers in application migration | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [9ca3a5450](https://github.com/angular/angular-cli/commit/9ca3a54503574674eb107d4d2b507be7ecd727ee) | feat | add `deployUrl` to application builder | +| [3821344da](https://github.com/angular/angular-cli/commit/3821344da663ded52b773752abc805ed04e28f3c) | fix | ensure proper display of build logs in the presence of warnings or errors | +| [de2d05049](https://github.com/angular/angular-cli/commit/de2d050498e16efa75459f526b9973c9e1d67050) | fix | provide better error message when server option is required but missing | + + + + + +# 17.2.3 (2024-03-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [7cc8261fd](https://github.com/angular/angular-cli/commit/7cc8261fd2eb0ef1665faebec43cba447a64fd33) | fix | avoid implicit CSS file extensions when resolving | +| [259ec72d5](https://github.com/angular/angular-cli/commit/259ec72d521b3a660c54ec33aaf8bf7c838281e7) | fix | avoid marking component styles as media with no output media directory | +| [faffdfdce](https://github.com/angular/angular-cli/commit/faffdfdcebb3f71f7ef64b02114561985c592add) | fix | disable `deployUrl` when using vite dev-server | + + + + + +# 17.2.2 (2024-02-28) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [3394d3cf1](https://github.com/angular/angular-cli/commit/3394d3cf10996a93777edfb28d83cf4eb85ae40b) | fix | ensure all related stylesheets are rebuilt when an import changes | + + + + + +# 17.2.1 (2024-02-22) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [9e7c47b59](https://github.com/angular/angular-cli/commit/9e7c47b5945b368a6fd5e2544674d5a3afd63d65) | fix | allow `mts` and `cts` file replacement | +| [f2a2e9287](https://github.com/angular/angular-cli/commit/f2a2e92877298a30bc1042772be043d5db9ac729) | fix | provide Vite client code source map when loading | + + + + + +# 17.2.0 (2024-02-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [b3e206741](https://github.com/angular/angular-cli/commit/b3e206741c5b59b8563b7c60a1f66c49802549e7) | feat | add support to `bun` package manager | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [03e1aa790](https://github.com/angular/angular-cli/commit/03e1aa7904acfe9d12eaf3717d1b136159893a76) | feat | add support to `bun` package manager | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [7f57123fd](https://github.com/angular/angular-cli/commit/7f57123fd40b345d7880cb9e2eccd4757c0fb6ee) | feat | add define build option to application builder | +| [f4f535653](https://github.com/angular/angular-cli/commit/f4f535653a34c2a7c37c51c98680b6b1766c6d0d) | feat | add JSON build logs when using the application builder | +| [b59f663e5](https://github.com/angular/angular-cli/commit/b59f663e5715e009b05bf560637c1bca3b112784) | feat | allow control of Vite-based development server prebundling | +| [8f47f1e96](https://github.com/angular/angular-cli/commit/8f47f1e965b25f3d976eda701ae2e7b7e8cccfa3) | feat | provide default and abbreviated build target support for dev-server and extract-i18n | +| [7a12074dc](https://github.com/angular/angular-cli/commit/7a12074dc940f1aa8f5347071324fa0d2fd1300b) | feat | provide option to allow automatically cleaning the terminal screen during rebuilds | +| [7c522aa87](https://github.com/angular/angular-cli/commit/7c522aa8742cd936bb0dfd30773d88f3ef92d488) | feat | support using custom postcss configuration with application builder | +| [476a68daa](https://github.com/angular/angular-cli/commit/476a68daa9746d29d3f74f68307d982d1d66f94c) | fix | add output location in build stats | +| [5e6f1a9f4](https://github.com/angular/angular-cli/commit/5e6f1a9f4362e9b12db64c7c2e609a346b17963d) | fix | avoid preloading server chunks | +| [41ea985f9](https://github.com/angular/angular-cli/commit/41ea985f9317b11cfa6627a2d3f6b34ff4dbc134) | fix | display server bundles in build stats | +| [d493609d3](https://github.com/angular/angular-cli/commit/d493609d30e1f148a7efb72bd64227521a326fbb) | fix | downgrade copy-webpack-plugin to workaround Node.js support issue | +| [8d5af1d5c](https://github.com/angular/angular-cli/commit/8d5af1d5c78ce9aa996f6ba138b99d0bb5005d46) | fix | ensure correct `.html` served with Vite dev-server | +| [944cbcdb1](https://github.com/angular/angular-cli/commit/944cbcdb1af62855febc931fce92debf28a3b2a5) | fix | limit the number of lazy chunks visible in the stats table | +| [905e13633](https://github.com/angular/angular-cli/commit/905e13633071b1db25621ae9f2762a48fe010df1) | fix | support string as plugin option in custom postcss plugin config | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [da1c38c48](https://github.com/angular/angular-cli/commit/da1c38c486b07d5a1b2933f23f83c6231b512e0f) | fix | add `bun` to known package managers | + +### @angular/create + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [600498f2c](https://github.com/angular/angular-cli/commit/600498f2cd3e3251e7e6e3dd3505c5e943b2986a) | feat | add support to `bun` package manager | + + + + + +# 17.1.4 (2024-02-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [6d2168db9](https://github.com/angular/angular-cli/commit/6d2168db92fcba1ebf82498fed85cd2b596547d3) | fix | prevent BOM errors in `package.json` during `ng update` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [bf42d6df2](https://github.com/angular/angular-cli/commit/bf42d6df2f5eda45bec80bb60315152c03f4a3dc) | fix | bypass Vite prebundling for absolute URL imports | + + + + + +# 17.1.3 (2024-02-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [3de3aa170](https://github.com/angular/angular-cli/commit/3de3aa170f02352fe2adf61beea221b356a40843) | fix | allow `./` baseHref when using vite based server | +| [17f47a3c9](https://github.com/angular/angular-cli/commit/17f47a3c94b434a73b9fc698872ef6230f61c781) | fix | ensure WebWorker main entry is used in output code | + + + + + +# 17.1.2 (2024-01-31) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [6815f13e3](https://github.com/angular/angular-cli/commit/6815f13e3c87eff773aa914858293c75e4fae7d2) | fix | add `required` modules as externals imports | +| [a0e306098](https://github.com/angular/angular-cli/commit/a0e306098147cf5fb7b51264c18860767fdf6316) | fix | correctly handle glob negation in proxy config when using vite | +| [235c8403a](https://github.com/angular/angular-cli/commit/235c8403a5bf8a2032da72a504e8cee441dd2d82) | fix | handle regular expressions in proxy config when using Vite | +| [5332e5b2e](https://github.com/angular/angular-cli/commit/5332e5b2ea0c9757f717e386fb162392ef2327a4) | fix | resolve absolute `output-path` when using esbuild based builders | +| [3deb0d4a1](https://github.com/angular/angular-cli/commit/3deb0d4a102fb8d8fae7617b81f62706371e03f5) | fix | return 404 for assets that are not found | + + + + + +# 17.1.1 (2024-01-24) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [8ebb754c2](https://github.com/angular/angular-cli/commit/8ebb754c2e865ffd2c38f61d50a5f4be225a0fe5) | fix | update regex to validate the project-name | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [35ebf1efd](https://github.com/angular/angular-cli/commit/35ebf1efdfa438ea713020b847826621bba0ebfc) | fix | retain trailing comma when adding providers to app config | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [88de1da92](https://github.com/angular/angular-cli/commit/88de1da92919834f620a31d8a3e6a4e2ad1e2f07) | fix | `ENOENT: no such file or directory` on Windows during component rebuild | +| [4e2586aeb](https://github.com/angular/angular-cli/commit/4e2586aeb8ec11cf951f30bbfca6422f13cfd5cc) | fix | allow package file loader option with Vite prebundling | +| [aca1cfcda](https://github.com/angular/angular-cli/commit/aca1cfcda520d9a68bc01833453c81f38c133d37) | fix | do not add internal CSS resources files in watch | +| [53258f617](https://github.com/angular/angular-cli/commit/53258f617cf6c9068e069122029ff91c62d2109e) | fix | handle load event for multiple stylesheets and CSP nonces | +| [412fe6ec6](https://github.com/angular/angular-cli/commit/412fe6ec69bfcbb1e9fb09ccbb10a086b5166689) | fix | pre-transform error when using vite with SSR | +| [45dea6f44](https://github.com/angular/angular-cli/commit/45dea6f44cb27431e4767ce16df3e84c5b6d8f9c) | fix | provide actionable error message when server bundle is missing default export | +| [4e2b23f03](https://github.com/angular/angular-cli/commit/4e2b23f0321f3ec6edfd3e20e9bf95d799de5e7f) | fix | update dependency vite to v5.0.12 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [02d9d84c5](https://github.com/angular/angular-cli/commit/02d9d84c5da3def7e6b307b115e77233cfcf8d4b) | fix | handle load event for multiple stylesheets and CSP nonces | + + + + + +# 16.2.12 (2024-01-24) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [5fad40162](https://github.com/angular/angular-cli/commit/5fad401628f7ddbc412d7e761a4300724f078bde) | fix | update dependency vite to v4.5.2 | + + + + + +# 17.1.0 (2024-01-17) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------- | +| [b513d89b7](https://github.com/angular/angular-cli/commit/b513d89b77dd50891a5f02ec59d1a2bffa0d36db) | feat | add optional migration to use application builder | +| [a708dccff](https://github.com/angular/angular-cli/commit/a708dccff34f62b625332555005bbd8f41380ec2) | feat | update SSR and application builder migration schematics to work with new `outputPath` | +| [4469e481f](https://github.com/angular/angular-cli/commit/4469e481fc4f74574fdd028513b57ba2300c3b34) | fix | do not trigger NPM install when using `---skip-install` and `--ssr` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [e0b274b8f](https://github.com/angular/angular-cli/commit/e0b274b8ff4d164061ca7b60248bb85ceee8f65d) | feat | add option to retain CSS special comments in global styles | +| [204794c4f](https://github.com/angular/angular-cli/commit/204794c4f8e87882af974144fff642762930b4d3) | feat | add support for `--no-browsers` in karma builder | +| [4784155bd](https://github.com/angular/angular-cli/commit/4784155bd62cfac9b29327167093e70c9c6bee41) | feat | add wildcard option for `allowedCommonJsDependencies` | +| [3b93df42d](https://github.com/angular/angular-cli/commit/3b93df42daf9eda9215ea65d8ed0efd1ef203a09) | feat | allow configuring loaders for custom file extensions in application builder | +| [cc246d50e](https://github.com/angular/angular-cli/commit/cc246d50ea8d92289c8be8dc58b376358a899ad6) | feat | allow customization of output locations | +| [15a669c1e](https://github.com/angular/angular-cli/commit/15a669c1efdc8ac18507232d6cb29794c82b94cc) | feat | allowing control of index HTML initial preload generation | +| [47a064b14](https://github.com/angular/angular-cli/commit/47a064b146d06ee7498e3aacb2f17a6283be4504) | feat | emit external sourcemaps for component styles | +| [68dae539a](https://github.com/angular/angular-cli/commit/68dae539adfa12d6088f96ac5c9f224d9bb52e17) | feat | initial experimental implementation of `@web/test-runner` builder | +| [f6e67df1c](https://github.com/angular/angular-cli/commit/f6e67df1c4f286fb1fe195b75cdaab4339ad7604) | feat | inline Google and Adobe fonts located in stylesheets | +| [364a16b7a](https://github.com/angular/angular-cli/commit/364a16b7a6d903cb176f7db627fec126b8aa05f9) | feat | move `browser-sync` as optional dependency | +| [ccba849e4](https://github.com/angular/angular-cli/commit/ccba849e48287805bd8253a03f88d5f44b2b23ae) | feat | support keyboard command shortcuts in application dev server | +| [329d80075](https://github.com/angular/angular-cli/commit/329d80075bc788de0c8e757fbd8cd69120fbec99) | fix | alllow `OPTIONS` requests to be proxied when using `vite` | +| [49ed9a26c](https://github.com/angular/angular-cli/commit/49ed9a26cb87ae629d7d4167277f7e5c4ee066f7) | fix | emit error when using prerender and app-shell builders with application builder | +| [6473b0160](https://github.com/angular/angular-cli/commit/6473b01603b55d265489840cbf32697ad663aeeb) | fix | ensure all configured assets can be served by dev server | +| [874e576b5](https://github.com/angular/angular-cli/commit/874e576b523ba675f85011388e4ce3fcc38992fa) | fix | filter explicit external dependencies for Vite prebundling | +| [2a02b1320](https://github.com/angular/angular-cli/commit/2a02b1320449e0562041bbba86e42048665402e5) | fix | fix normalization of the application builder extensions | +| [9906ab7b4](https://github.com/angular/angular-cli/commit/9906ab7b4714e1fca040f875dd91f0279f688abe) | fix | normalize asset source locations in Vite-based development server | +| [ceffafe1a](https://github.com/angular/angular-cli/commit/ceffafe1a3c8cad469b718e466e771e1d396ab14) | fix | provide better error messages for failed file reads | +| [6d7fdb952](https://github.com/angular/angular-cli/commit/6d7fdb952d49dda1301af229af138d834161c2f9) | fix | show diagnostic messages after build stats | +| [4e1f0e44d](https://github.com/angular/angular-cli/commit/4e1f0e44dca106fa299b5dd0e4145c2c3a99ab4f) | fix | the request url "..." is outside of Vite serving allow list for all assets | +| [bd26a18e7](https://github.com/angular/angular-cli/commit/bd26a18e7a9512bdad15784a19f42aaca8aec303) | fix | typo in preloadInitial option description | +| [125fb779f](https://github.com/angular/angular-cli/commit/125fb779ff394f388c2d027c1dda4a33bd8caa62) | perf | reduce TypeScript JSDoc parsing in application builder | + + + + + +# 17.0.10 (2024-01-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [ed1e130da](https://github.com/angular/angular-cli/commit/ed1e130dad7f9b6629f7bd31f8f0590814d0eb57) | fix | retain existing EOL when updating JSON files | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [09c32c678](https://github.com/angular/angular-cli/commit/09c32c678221746458db50f1c2f7eb92264abb16) | fix | retain existing EOL when adding imports | +| [a5c339eaa](https://github.com/angular/angular-cli/commit/a5c339eaa73eb73e2b13558a363e058500a2cfba) | fix | retain existing EOL when updating JSON files | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [3dc4db7d7](https://github.com/angular/angular-cli/commit/3dc4db7d78649eef99a2e60b1faec8844815f8e4) | fix | retain existing EOL when updating workspace config | + + + + + +# 17.0.9 (2024-01-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [446dfb76a](https://github.com/angular/angular-cli/commit/446dfb76a5e2a53542fae93b4400133bf7d9552e) | fix | add prerender and ssr-dev-server schemas in angular.json schema | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [88d6ca4a5](https://github.com/angular/angular-cli/commit/88d6ca4a545c2d3e35822923f2aae03f43b2e3e3) | fix | replace template line endings with platform specific | + + + + + +# 17.0.8 (2023-12-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [6dba26a0b](https://github.com/angular/angular-cli/commit/6dba26a0b33ee867923c4505decd86f183e0e098) | fix | `ng e2e` and `ng lint` prompt requires to hit Enter twice to proceed on Windows | +| [0b48acc4e](https://github.com/angular/angular-cli/commit/0b48acc4eaa15460175368fdc86e3dd8484ed18b) | fix | re-add `-d` alias for `--dry-run` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [99b026ede](https://github.com/angular/angular-cli/commit/99b026edece990e7f420718fd4967e21db838453) | fix | add missing property "buildTarget" to interface "ServeBuilderOptions" | +| [313004311](https://github.com/angular/angular-cli/commit/3130043114d3321b1304f99a4209d9da14055673) | fix | do not generate standalone component when using `ng generate module` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [cf11cdf6c](https://github.com/angular/angular-cli/commit/cf11cdf6ce7569e2da5fa3bc76e20d19c719ce4c) | fix | add missing tailwind `@screen` directive in matcher | +| [aa6c757d7](https://github.com/angular/angular-cli/commit/aa6c757d701b7f95896c8f1643968ee030b179af) | fix | construct SSR request URL using server resolvedUrls | +| [0662048d4](https://github.com/angular/angular-cli/commit/0662048d4abbcdc36ff74d647bb7d3056dff42a8) | fix | ensure empty optimized Sass stylesheets stay empty | +| [d1923a66d](https://github.com/angular/angular-cli/commit/d1923a66d9d2ab39831ac4cd012fa0d2df66124b) | fix | ensure external dependencies are used by Web Worker bundling | + + + + + +# 16.2.11 (2023-12-21) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ----- | -------------------------------- | +| [e0e011fc4](https://github.com/angular/angular-cli/commit/e0e011fc47f2383f9be0b432066c1438ddab7103) | build | update dependency vite to v4.5.1 | + + + + + +# 17.0.7 (2023-12-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------ | +| [3df3e583c](https://github.com/angular/angular-cli/commit/3df3e583c8788511598bbe406012196a2882ee49) | fix | `baseHref` with trailing slash causes server not to be accessible without trailing slash | +| [ef1178188](https://github.com/angular/angular-cli/commit/ef1178188a145a1277197a33a304910e1024c365) | fix | allow vite to serve JavaScript and TypeScript assets | +| [385eb77d2](https://github.com/angular/angular-cli/commit/385eb77d2645a1407dbc7528e90a506f9bb2952f) | fix | cache loading of component resources in JIT mode | +| [4b3af73ac](https://github.com/angular/angular-cli/commit/4b3af73ac934a24dd2b022604bc01f00389d87a1) | fix | ensure browser-esbuild is used in dev server with browser builder and forceEsbuild | +| [d1b27e53e](https://github.com/angular/angular-cli/commit/d1b27e53ed9e23a0c08c13c22fc0b4c00f3998b2) | fix | ensure port 0 uses random port with Vite development server | +| [f2f7d7c70](https://github.com/angular/angular-cli/commit/f2f7d7c7073e5564ddd8a196b6fcaab7db55b110) | fix | file is missing from the TypeScript compilation with JIT | +| [7b8d6cddd](https://github.com/angular/angular-cli/commit/7b8d6cddd0daa637a5fecdea627f4154fafe23fa) | fix | handle updates of an `npm link` library from another workspace when `preserveSymlinks` is `true` | +| [c08c78cb8](https://github.com/angular/angular-cli/commit/c08c78cb8965a52887f697e12633391908a3b434) | fix | inlining of fonts results in jagged fonts for Windows users | +| [930024811](https://github.com/angular/angular-cli/commit/9300248114282a2a425b722482fdf9676b000b94) | fix | retain symlinks to output platform directories on builds | +| [3623fe911](https://github.com/angular/angular-cli/commit/3623fe9118be14eedd1a04351df5e15b3d7a289a) | fix | update ESM loader to work with Node.js 18.19.0 | + + + + + +# 17.0.6 (2023-12-06) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [da5d39471](https://github.com/angular/angular-cli/commit/da5d39471751cd92f6c21936aefc1f7157b4973b) | fix | enable TypeScript `skipLibCheck` in new workspace | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [048512874](https://github.com/angular/angular-cli/commit/048512874bf9cc022cc9a8ab70f35fc60d9982f5) | fix | app-shell generation incorrect content when using the application builder | +| [f9e982c44](https://github.com/angular/angular-cli/commit/f9e982c4458fc022d34039b9c082471c7ce29c07) | fix | check namespaced Sass variables when rebasing URLs | +| [a1e8ffa9d](https://github.com/angular/angular-cli/commit/a1e8ffa9df3a8eb6af2a8851385ed8927e3c0c64) | fix | correctly align error/warning messages when spinner is active | +| [46d88a034](https://github.com/angular/angular-cli/commit/46d88a034343dc93dd0c467afc08c824da427fef) | fix | handle watch updates on Mac OSX when using native FSEvents API | +| [4594407ae](https://github.com/angular/angular-cli/commit/4594407ae214ce49985a5df315cae3ac8107147d) | fix | improve file watching on Windows when using certain IDEs | +| [aa9e7c615](https://github.com/angular/angular-cli/commit/aa9e7c615529cb9dd6dccd862674cadac0372f08) | fix | normalize locale tags with Intl API when resolving in application builder | +| [a8dbf1da2](https://github.com/angular/angular-cli/commit/a8dbf1da27faf772a4df382b1301e95c32d1ba89) | fix | watch symlink when using `preserveSymlinks` option | +| [e3820cb6c](https://github.com/angular/angular-cli/commit/e3820cb6c7cf131d890882f9e94b8f23c4cbb6a3) | perf | only enable advanced optimizations with script optimizations | + + + + + +# 17.0.5 (2023-11-29) + +Rolling back [bbbe13d67](https://github.com/angular/angular-cli/commit/bbbe13d6782ba9d1b80473a98ea95bc301c48597) which appears to break file watching on Mac devices. + + + + + +# 17.0.4 (2023-11-29) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7a2823080](https://github.com/angular/angular-cli/commit/7a2823080c61df3515d85f7aa35ee83f57e80e2d) | fix | remove CommonModule import from standalone components | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [0634a4e40](https://github.com/angular/angular-cli/commit/0634a4e40f1b2e4c0a076814f3e1b242ccf1a588) | fix | avoid native realpath in application builder | +| [22880d9cb](https://github.com/angular/angular-cli/commit/22880d9cbf70fffb6cc685b3a9ad82ca741a56fe) | fix | correct set locale when using esbuild based builders | +| [a0680672f](https://github.com/angular/angular-cli/commit/a0680672fd369dc6fba2433441d086e53bebb0a2) | fix | correctly watch files when app is in a directory that starts with a dot | +| [bbbe13d67](https://github.com/angular/angular-cli/commit/bbbe13d6782ba9d1b80473a98ea95bc301c48597) | fix | improve file watching on Windows when using certain IDEs | +| [27e7c2e1b](https://github.com/angular/angular-cli/commit/27e7c2e1b4f514843c2c505b7fe1b3cef126a101) | fix | propagate localize errors to full build result | +| [7455fdca0](https://github.com/angular/angular-cli/commit/7455fdca01bd4af00248bb1769945dc088c59063) | fix | serve assets from the provided `serve-path` | +| [657a07bd6](https://github.com/angular/angular-cli/commit/657a07bd6ba138a209c2a1540ea4d200c60e0f66) | fix | treeshake unused class that use custom decorators | +| [77474951b](https://github.com/angular/angular-cli/commit/77474951b59605a2c36a8bd890376f9e28131ee4) | fix | use workspace real path when not preserving symlinks | + + + + + +# 17.0.3 (2023-11-21) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [450dd29a1](https://github.com/angular/angular-cli/commit/450dd29a13da9930fede96732b29c9c04e1c0cf5) | fix | default to watching project root on Windows with application builder | +| [8072b8574](https://github.com/angular/angular-cli/commit/8072b8574a84a97277e8c83ebbbdde076b79a910) | fix | ensure service worker hashes index HTML file for application builder | +| [d99870740](https://github.com/angular/angular-cli/commit/d998707406c7a191a191f71d07a9491481c8ad56) | perf | only create one instance of postcss when needed | + + + + + +# 17.0.2 (2023-11-20) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [023645185](https://github.com/angular/angular-cli/commit/02364518571a2b73be945a0036bbfa39e336330c) | fix | always normalize AOT file reference tracker paths | +| [3b99980bd](https://github.com/angular/angular-cli/commit/3b99980bd02c875a37d1603ae7468558fe7ef4c3) | fix | emit root files when `localize` is enabled when using the esbuild based builders | +| [ef3e3abb8](https://github.com/angular/angular-cli/commit/ef3e3abb8e29a9274e9d1f5fc5c18f01de6fd76f) | fix | ensure watch file paths from TypeScript are normalized | +| [d11b36fe2](https://github.com/angular/angular-cli/commit/d11b36fe207d8a38cb4a1001667c63ecd17aba0c) | fix | normalize paths in ssr sourcemaps to posix when using vite | +| [62d51383a](https://github.com/angular/angular-cli/commit/62d51383acfd8cdeedf07b34c2d78f505ff2e3a8) | fix | only include vendor sourcemaps when using the dev-server when the option is enabled | +| [d28ba8a73](https://github.com/angular/angular-cli/commit/d28ba8a7311ea3345b112a47d6f1e617fb691643) | fix | remove browser-esbuild usage warning | + + + + + +# 17.0.1 (2023-11-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [5267e6055](https://github.com/angular/angular-cli/commit/5267e605567aba798ee00322f14e3a48eae68b48) | fix | handle packages with no version | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [d9f7d439e](https://github.com/angular/angular-cli/commit/d9f7d439eba879f8fffaacd258d832c407dfd90f) | fix | add helper script to spawn SSR server from `dist` | +| [a80926cdb](https://github.com/angular/angular-cli/commit/a80926cdb6b4d99a65549fcfba2ab094a5835480) | fix | html indentation | +| [f7f62c9d6](https://github.com/angular/angular-cli/commit/f7f62c9d6988e6801981592f56137cd02bfe2316) | fix | remove `downlevelIteration` from `tsconfig.json` for new workspaces | +| [7cb57317d](https://github.com/angular/angular-cli/commit/7cb57317d2b78e9a1f947c9f11175a7d381275fc) | fix | use href property binding for links | +| [731917cd0](https://github.com/angular/angular-cli/commit/731917cd00b366bbec4f184ee9064b307eba59ce) | fix | use styleUrl | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [15dd71aba](https://github.com/angular/angular-cli/commit/15dd71abac77ec5e1c092bebb86edffa3999937a) | fix | `deleteOutputPath` when using `esbuild-builder` | +| [fa4d8ff31](https://github.com/angular/angular-cli/commit/fa4d8ff31ef64738e45078c0e7be471591361442) | fix | add actionable error when file replacement is missing | +| [160a91160](https://github.com/angular/angular-cli/commit/160a91160ff3677d9e2d3d413ae360c4e1957c53) | fix | add support for vendor sourcemaps when using the dev-server | +| [5623c193e](https://github.com/angular/angular-cli/commit/5623c193e4cccbf6783f7e3faaf0a6c2fb086b34) | fix | cache stylesheet load errors with application builder | +| [1a5538e0c](https://github.com/angular/angular-cli/commit/1a5538e0c9cc121fa1608eb99e941bc3a5f59ad6) | fix | disable Worker wait loop for TS/NG parallel compilation in web containers | +| [883771946](https://github.com/angular/angular-cli/commit/883771946a36a42ebfe23d32b393513309b16c82) | fix | do not process ssr entry-point when running `ng serve` | +| [d3b549167](https://github.com/angular/angular-cli/commit/d3b54916705e57f017597917d9aea1f71f2ba95a) | fix | empty output directory instead of removing | +| [596f7639a](https://github.com/angular/angular-cli/commit/596f7639a6c7fe00c9088e32739578cc374a31e2) | fix | ensure compilation errors propagate to all bundle actions | +| [d900a5217](https://github.com/angular/angular-cli/commit/d900a5217a75accf434a95ad90300ec5005a23a8) | fix | maintain current watch files after build errors | +| [21549bdeb](https://github.com/angular/angular-cli/commit/21549bdeb97b23f7f37110d579513f3102dc60e8) | fix | prerender default view when no routes are defined | +| [4c251647b](https://github.com/angular/angular-cli/commit/4c251647b8fdb3b128ca3252c83aaa71ecc48e88) | fix | rewire sourcemap back to original source root | + + + + + +# 17.0.0 (2023-11-08) + +## Breaking Changes + +### @schematics/angular + +- Routing is enabled by default for new applications when using `ng generate application` and `ng new`. The `--no-routing` command line option can be used to disable this behaviour. +- `ng g interceptor` now generate a functional interceptor by default. or guard by default. To generate a class-based interceptor the `--no-functional` command flag should be used. +- `rootModuleClassName`, `rootModuleFileName` and `main` options have been removed from the public `pwa` and `app-shell` schematics. +- App-shell and Universal schematics deprecated unused `appId` option has been removed. + +### @angular-devkit/build-angular + +- Node.js v16 support has been removed + + Node.js v16 is planned to be End-of-Life on 2023-09-11. Angular will stop supporting Node.js v16 in Angular v17. + For Node.js release schedule details, please see: https://github.com/nodejs/release#release-schedule + +### @angular-devkit/schematics + +- deprecated `runExternalSchematicAsync` and `runSchematicAsync` methods have been removed in favor of `runExternalSchematic` and `runSchematic`. + +## Deprecations + +### @angular-devkit/build-angular + +- The `browserTarget` in the dev-server and extract-i18n builders have been deprecated in favor of `buildTarget`. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [f4e7fa873](https://github.com/angular/angular-cli/commit/f4e7fa87350ea1162287114796e0e04e2af101c4) | fix | add `@angular/ssr` as part of the ng update `packageGroup` | +| [1f7156b11](https://github.com/angular/angular-cli/commit/1f7156b112606410ab9ea1cd3f178a762566b96b) | fix | add Node.js 20 as supported version | +| [4b9a87c90](https://github.com/angular/angular-cli/commit/4b9a87c90469481dc3dd0da4d1506521b4203255) | fix | ignore peer mismatch when updating @nguniversal/builders | +| [f66f9cf61](https://github.com/angular/angular-cli/commit/f66f9cf612bed49b961f1f8a8e4deef05fd5ef40) | fix | remove Node.js 16 from supported checks | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------- | +| [741cca73c](https://github.com/angular/angular-cli/commit/741cca73c129ff05e7229081d50762a054c09a8d) | feat | add `ng new --ssr` | +| [3938863b9](https://github.com/angular/angular-cli/commit/3938863b9900fcfe574b3112d73a8f34672f38bd) | feat | add migration to migrate from `@nguniversal` to `@angular/ssr` | +| [dc6b6eaf6](https://github.com/angular/angular-cli/commit/dc6b6eaf6f8af0d2b3f31cea77dc9a63ff845e3c) | feat | add migration to replace usages of `@nguniversal/builders` | +| [6979eba3c](https://github.com/angular/angular-cli/commit/6979eba3c9d46fd5fc2622d28636c48dbcbbe1c6) | feat | enable hydration when adding SSR, SSG or AppShell | +| [1a6a139aa](https://github.com/angular/angular-cli/commit/1a6a139aaf8d5a6947b399bbbd48bbfd9e52372c) | feat | enable routing by default for new applications | +| [ac0db6697](https://github.com/angular/angular-cli/commit/ac0db6697593196692e5b87e1e724be6de0ef0a0) | feat | enable standalone by default in new applications | +| [a189962a5](https://github.com/angular/angular-cli/commit/a189962a515051fd77e20bf8dd1815086a0d12ef) | feat | generate functional interceptors by default | +| [ae45c4ab8](https://github.com/angular/angular-cli/commit/ae45c4ab8103ba8ebc2686e71dbf7d0394b1ee92) | feat | update `ng new` generated application | +| [3f8aa9d8c](https://github.com/angular/angular-cli/commit/3f8aa9d8c7dc7eff06516c04ba08764bb044cb6b) | feat | update` ng new` to use the esbuild application builder based builder | +| [03a1eaf01](https://github.com/angular/angular-cli/commit/03a1eaf01c009d814cb476d2db53b2d0a4d58bcd) | fix | account for new block syntax in starter template | +| [eb0fc7434](https://github.com/angular/angular-cli/commit/eb0fc7434539d3f5a7ea3f3c4e540ac920b10c19) | fix | add missing express `REQUEST` and `RESPONSE` tokens | +| [ecdcff2db](https://github.com/angular/angular-cli/commit/ecdcff2db2b205443a585dd5dd118dbd50613883) | fix | add missing icons in ng-new template | +| [175944672](https://github.com/angular/angular-cli/commit/17594467218b788ebb27d8d16ffb0b555fcf71ee) | fix | do not add unnecessary dependency on `@angular/ssr` during migration | +| [23c4c5e42](https://github.com/angular/angular-cli/commit/23c4c5e4293ef770d555b8b2bd449ad32d1537d4) | fix | enable TypeScript `esModuleInterop` by default for ESM compliance | +| [d60a6e86a](https://github.com/angular/angular-cli/commit/d60a6e86a48f15b3ddf89943dad31ee267f67648) | fix | noop workspace config migration when already executed | +| [e516a4bdb](https://github.com/angular/angular-cli/commit/e516a4bdb7f6bb87f556e58557e57db6f7e65845) | fix | pass `ssr` option to application schematics | +| [419b5c191](https://github.com/angular/angular-cli/commit/419b5c1917c45dc115b107479d5066b9193497fa) | fix | remove `baseUrl` from `tsconfig.json` | +| [0368b23f2](https://github.com/angular/angular-cli/commit/0368b23f2e5d8ca9c6191a2db956dc6850daebfc) | fix | use @types/node v18 | +| [b15e82758](https://github.com/angular/angular-cli/commit/b15e827580d6d3159c49521eb9b5d2b6d8ca2502) | refactor | remove deprecated appId option | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------- | +| [c48982dc1](https://github.com/angular/angular-cli/commit/c48982dc1d01d11be54ffb0b1469e3b0557f3920) | feat | add `buildTarget` option to dev-server and `extract-i18n` builders | +| [1fb0350eb](https://github.com/angular/angular-cli/commit/1fb0350eb7370ef6f72acc4e20c4d0bee8bf0b29) | feat | add initial support for bundle budgets to esbuild builders | +| [8168ae2a8](https://github.com/angular/angular-cli/commit/8168ae2a892dd012707bd294ffd26d0a070c0b5d) | feat | apply global CSS updates without a live-reload when using `vite` | +| [91019bde2](https://github.com/angular/angular-cli/commit/91019bde2af5fb9dff6426ba24098271d8ac4889) | feat | enable localize support for SSR with application builder | +| [3c0719bde](https://github.com/angular/angular-cli/commit/3c0719bde244c45d71881d35899e5ee6206c09ee) | feat | initial i18n extraction support for application builder | +| [8bce80b91](https://github.com/angular/angular-cli/commit/8bce80b91b953c391ef8e45fec7f887f8d8521aa) | feat | initial support for application Web Worker discovery with esbuild | +| [49f07a84d](https://github.com/angular/angular-cli/commit/49f07a84d6f6120388d9fc48a2514d3398986e49) | feat | standardize application builder output structure | +| [c3a87a60e](https://github.com/angular/angular-cli/commit/c3a87a60e0d3cdcae9f4361c2cf21c7ea29bd7de) | feat | support basic web worker bundling with esbuild builders | +| [9e425308a](https://github.com/angular/angular-cli/commit/9e425308a0c146b685e452a106cbdf3e02bddd00) | feat | support component style budgets in esbuild builders | +| [771e036d5](https://github.com/angular/angular-cli/commit/771e036d5ce3d436736d3c8b261050d633b3ef29) | feat | support deploy URL option for `browser-esbuild` builder | +| [c5f3ec71f](https://github.com/angular/angular-cli/commit/c5f3ec71f536e7ebb1c8cd0d7523b42e58f9611a) | feat | support i18n inlining with esbuild-based builder | +| [fd62a9315](https://github.com/angular/angular-cli/commit/fd62a9315defb89b4bea996d256887a6ec7b4327) | feat | support i18n with service worker and app-shell with esbuild builders | +| [5898f72a9](https://github.com/angular/angular-cli/commit/5898f72a97c29d38b9e8b8ca23255f9fbce501e5) | feat | support namedChunks option in application builder | +| [8f9a0d70c](https://github.com/angular/angular-cli/commit/8f9a0d70cdf692b19574410cebb4d029056263fc) | feat | support standalone apps route discovery during prerendering | +| [6b08efa6f](https://github.com/angular/angular-cli/commit/6b08efa6ffd988e08e3db471ffe3214a029de116) | fix | account for arrow function IIFE | +| [2f299fc7b](https://github.com/angular/angular-cli/commit/2f299fc7b5f00056054a06574e65ae311cd3ce0c) | fix | account for styles specified as string literals and styleUrl | +| [9994b2dde](https://github.com/angular/angular-cli/commit/9994b2dde801b2f74fb70152eb73225283da32a3) | fix | add a maximum rendering timeout for SSR and SSG during development | +| [da4e19145](https://github.com/angular/angular-cli/commit/da4e19145b341dccdd5174cc7bc821e5025514b1) | fix | address a path concatenation on Windows | +| [9d4d11cc4](https://github.com/angular/angular-cli/commit/9d4d11cc43f2ae149ee8bfcf28285a1f62594ef7) | fix | allow SSR compilation to work with TS allowJs option | +| [e3c5b91e8](https://github.com/angular/angular-cli/commit/e3c5b91e8a09c8a7dd940655087b69a8949cb2cc) | fix | automatically include known packages in vite prebundling | +| [ca38ee34c](https://github.com/angular/angular-cli/commit/ca38ee34c6267e32b8ee74db815f929896f1baba) | fix | avoid binary content in architect results with browser-esbuild | +| [657f78292](https://github.com/angular/angular-cli/commit/657f78292b4c78db5a43a172087a078820812323) | fix | avoid dev server update analysis when build fails with vite | +| [2c33f09db](https://github.com/angular/angular-cli/commit/2c33f09db0561f344a26dd4f4304a9098e0ee13f) | fix | avoid dev-server proxy rewrite normalization when invalid value | +| [b182be8aa](https://github.com/angular/angular-cli/commit/b182be8aa7ff5fd3cddc0bcac5f4e45e9ed9cf2e) | fix | avoid in-memory prerendering ESM loader errors | +| [0c982b993](https://github.com/angular/angular-cli/commit/0c982b993b69f4a4b52002cc65ad7ba3b0b9d591) | fix | avoid repeat error clear in vite development server | +| [e41e2015b](https://github.com/angular/angular-cli/commit/e41e2015bfc37672fb67014ae38f31b63f0bb256) | fix | avoid spawning workers when there are no routes to prerender | +| [2d2e79921](https://github.com/angular/angular-cli/commit/2d2e79921a72c4fafad673abe501ba10400403d2) | fix | clean up internal Angular state during rendering SSR | +| [83020fc32](https://github.com/angular/angular-cli/commit/83020fc3291715802c28c5f7dcf7a261bc7f32cd) | fix | clear diagnostic cache when external templates change with esbuild builders | +| [c12f98f94](https://github.com/angular/angular-cli/commit/c12f98f948b1c10594f9d00f4ebf87630fe3cc47) | fix | conditionally enable deprecated Less stylesheet JavaScript support | +| [e10f49efa](https://github.com/angular/angular-cli/commit/e10f49efa8ac96e72bbc441423a730fd172c9f1d) | fix | convert AOT compiler exceptions into diagnostics | +| [667f43af6](https://github.com/angular/angular-cli/commit/667f43af6d91025424147f6e9ac94800f463da1d) | fix | correctly resolve polyfills when `baseUrl` URL is not set to root | +| [d46fb128a](https://github.com/angular/angular-cli/commit/d46fb128a51f172da72ab403ec97213099f43de8) | fix | disable dependency optimization for SSR | +| [1b384308c](https://github.com/angular/angular-cli/commit/1b384308c65ff67b8eac7f3b6407e19ce3db46fa) | fix | disable parallel TS/NG compilation inside WebContainers | +| [070da72c4](https://github.com/angular/angular-cli/commit/070da72c481b881538d6f5ff39955a3da7eb5126) | fix | do not perform advanced optimizations on `@angular/common/locales/global` | +| [508c7606e](https://github.com/angular/angular-cli/commit/508c7606ea2fa8e84d5243992abb59db1b75af49) | fix | do not print `Angular is running in development mode.` in the server console when running prerender in dev mode | +| [e817656f6](https://github.com/angular/angular-cli/commit/e817656f601eaaf910271d5bb2c2230ddb8ed864) | fix | do not print `Angular is running in development mode.` in the server console when running prerender in dev mode | +| [f806e3498](https://github.com/angular/angular-cli/commit/f806e3498b5a4fced7a515258fad30821f3e866c) | fix | elide setClassDebugInfo calls | +| [188a00f3e](https://github.com/angular/angular-cli/commit/188a00f3e466c6c31c7671c63ffc91ccda4590c9) | fix | elide setClassMetadataAsync calls | +| [05ce9d697](https://github.com/angular/angular-cli/commit/05ce9d697a723dcac7a5d24a14f4d11f8686851a) | fix | ensure all SSR chunks are resolved correctly with dev server | +| [d392d653c](https://github.com/angular/angular-cli/commit/d392d653cba67db28eddd003dfec6dcb9b192a95) | fix | ensure correct web worker URL resolution in vite dev server | +| [1a6aa4378](https://github.com/angular/angular-cli/commit/1a6aa437887d2fc5d08c833efc0ca792f6157350) | fix | ensure css url() prefix warnings support Sass rebasing | +| [52f595655](https://github.com/angular/angular-cli/commit/52f595655c69bb6a1398b030cf937b0d92d49864) | fix | ensure i18n locale data is included in SSR application builds | +| [3ad028bb4](https://github.com/angular/angular-cli/commit/3ad028bb442a8978a4f45511cab9bb515764b930) | fix | ensure localize polyfill and locale specifier are injected when not inlining | +| [3e5a99c2c](https://github.com/angular/angular-cli/commit/3e5a99c2c438152a0b930864dcad660a6ea1590a) | fix | ensure recalculation of component diagnostics when template changes | +| [fa234a418](https://github.com/angular/angular-cli/commit/fa234a4186c9d408bfb52b3a649d307f93d0b9b3) | fix | ensure secondary Angular compilations are unblocked on start errors | +| [c0c7dad77](https://github.com/angular/angular-cli/commit/c0c7dad77dd59a387dbcc643a52ee1ed634727ab) | fix | ensure that externalMetadata is defined | +| [ac7caa426](https://github.com/angular/angular-cli/commit/ac7caa4264c7a68467903528deca4a6f579ee15c) | fix | ensure unique internal identifiers for inline stylesheet bundling | +| [1f73bcc49](https://github.com/angular/angular-cli/commit/1f73bcc49abd9f136a18dc6329e2f50a7565eb76) | fix | ensure Web Worker code file is replaced in esbuild builders | +| [23a722b79](https://github.com/angular/angular-cli/commit/23a722b791a64bae32dc925160f2c3d1942955fc) | fix | exclude node.js built-ins from vite dependency optimization | +| [fd2c4c324](https://github.com/angular/angular-cli/commit/fd2c4c324dcfedc81af41351b52ed4c8e41f48fc) | fix | expose ssr-dev-server builder in the public api | +| [9eb58cf7a](https://github.com/angular/angular-cli/commit/9eb58cf7a51c0b7950f80b474890fb2ebd685977) | fix | fail build on non bundling error when using the esbuild based builders | +| [a3e9efe80](https://github.com/angular/angular-cli/commit/a3e9efe80f6e77c8bf80f6a2d37f4488f780503b) | fix | fully track Web Worker file changes in watch mode | +| [b9505ed09](https://github.com/angular/angular-cli/commit/b9505ed097d60eadae665d4664199e3d4989c864) | fix | generate a file containing a list of prerendered routes | +| [192a2ae6b](https://github.com/angular/angular-cli/commit/192a2ae6bd8bdeab785f1ed8e60c5e4213801dd3) | fix | handle HTTP requests to assets during prerendering | +| [19191e32b](https://github.com/angular/angular-cli/commit/19191e32bab9a2927b4feb5074e14165597fbf6d) | fix | handle HTTP requests to assets during SSG in dev-server | +| [8981d8c35](https://github.com/angular/angular-cli/commit/8981d8c355ec9154fcdcdad3a66e1b789d1079b0) | fix | improve sharing of TypeScript compilation state between various esbuild instances during rebuilds | +| [5a3ae0159](https://github.com/angular/angular-cli/commit/5a3ae0159faa81558537012a0ceba07b5ad1b88b) | fix | in vite skip SSR middleware for path with extensions | +| [f87f22d3f](https://github.com/angular/angular-cli/commit/f87f22d3f1436678ca1e07cc10874a012ae55e60) | fix | keep dependencies pre-bundling validate between builds | +| [0da87bf1c](https://github.com/angular/angular-cli/commit/0da87bf1c94c6caf711204fcdd9a3973d766bd6e) | fix | limit concurrent output file writes with application builder | +| [391ff78cb](https://github.com/angular/angular-cli/commit/391ff78cb0f29212c476ca36940b77839b84075e) | fix | log number of prerendered routes in console | +| [c46f312ad](https://github.com/angular/angular-cli/commit/c46f312adb06ae4a8293a07aa441514030052e93) | fix | media files download files in vite | +| [87425a791](https://github.com/angular/angular-cli/commit/87425a791fbdb44b3504e7e6d4b000b1df92c494) | fix | normalize paths when invalidating stylesheet bundler | +| [d4f37da50](https://github.com/angular/angular-cli/commit/d4f37da50ce2890a2b86281e5a373beab349b630) | fix | only show changed output files in watch mode with esbuild | +| [0d54f2d20](https://github.com/angular/angular-cli/commit/0d54f2d20bfd6d55615c0ab3537b5af0aeb008ee) | fix | only watch used files with application builder | +| [1f299ff2d](https://github.com/angular/angular-cli/commit/1f299ff2de3c80bf9cb3dc4b6a5ff02e81c1a94f) | fix | prebundle dependencies for SSR when using Vite | +| [58bd3971f](https://github.com/angular/angular-cli/commit/58bd3971fd2a95a5da1a87deddfe2416f3d636d6) | fix | process nested tailwind usage in application builder | +| [60ca3c82d](https://github.com/angular/angular-cli/commit/60ca3c82d28d0168b2f608a44a701ad8ad658369) | fix | provide server baseUrl result property in Vite-based dev server | +| [0c20cc4dc](https://github.com/angular/angular-cli/commit/0c20cc4dc5fe64221533d0a4cbe9d907881c85ae) | fix | re-add TestBed compileComponents in schematics to support defer block testing | +| [9453a2380](https://github.com/angular/angular-cli/commit/9453a23800f40a33b16fd887e3aa0817448134b1) | fix | remove CJS usage warnings for inactionable packages | +| [5bf7022c4](https://github.com/angular/angular-cli/commit/5bf7022c4749f1298de61ef75e36769bbb8aba12) | fix | remove support for Node.js v16 | +| [c27ad719f](https://github.com/angular/angular-cli/commit/c27ad719f2cb1b13f76f8fce033087a9124e646d) | fix | remove unactionable error overlay suggestion from Vite-based dev server | +| [263271fae](https://github.com/angular/angular-cli/commit/263271fae3f664da9d396192152d22a9b6e3ef09) | fix | resolve and load sourcemaps during prerendering to provide better stacktraces | +| [651e3195f](https://github.com/angular/angular-cli/commit/651e3195ffe06394212c8d8d275289ac05ea5ef5) | fix | resolve and load sourcemaps when using vite dev server with prerendering and ssr | +| [b78508fc8](https://github.com/angular/angular-cli/commit/b78508fc80bb9b2a3aec9830ad3ae9903d25927b) | fix | several fixes to assets and files writes in browser-esbuild builder | +| [c4c299bce](https://github.com/angular/angular-cli/commit/c4c299bce900b27556eaf2e06838a52f16990bb6) | fix | silence xhr2 not ESM module warning | +| [f7f6e97d0](https://github.com/angular/angular-cli/commit/f7f6e97d0f3540badb68813c39ce0237e4dcc9e3) | fix | skip checking CommonJS module descendants | +| [c11a0f0d3](https://github.com/angular/angular-cli/commit/c11a0f0d36f6cbffdf0464135510bda454efb08b) | fix | support custom index option paths in Vite-based dev server | +| [6c3d7d1c1](https://github.com/angular/angular-cli/commit/6c3d7d1c10907d8d57b5f84f298b324d6f972226) | fix | update `ssr` option definition | +| [4e89c3cae](https://github.com/angular/angular-cli/commit/4e89c3cae43870a10ef58de5ebdc094f5a06023e) | fix | use a dash in bundle names | +| [83b4b2567](https://github.com/angular/angular-cli/commit/83b4b25678ba6b8082d580a2d75b0f02a9addc2a) | fix | use browserslist when processing global scripts in application builder | +| [ca4d1634f](https://github.com/angular/angular-cli/commit/ca4d1634f7fa2070f53f5978387ea68cc875c986) | fix | use component style load result caching information for file watching | +| [34947fc64](https://github.com/angular/angular-cli/commit/34947fc64953f845d33ffb1c52f236869a040c9d) | fix | use incremental component style bundling only in watch mode | +| [ec160fe4e](https://github.com/angular/angular-cli/commit/ec160fe4e89cb89b93278cfac63877093dd19392) | fix | warn if using partial mode with application builder | +| [559e89159](https://github.com/angular/angular-cli/commit/559e89159150a10728272081b7bbda80fe708093) | fix | Windows Node.js 20 prerendering failure ([#26186](https://github.com/angular/angular-cli/pull/26186)) | +| [2cbec36c7](https://github.com/angular/angular-cli/commit/2cbec36c7286cdbbbd547433061421d7fe7762cc) | perf | cache polyfills virtual module result | +| [e06e95f73](https://github.com/angular/angular-cli/commit/e06e95f73a35e2cc7cb00a44ce3633b4c99c8505) | perf | conditionally add Angular compiler plugin to polyfills bundling | +| [61f409cbe](https://github.com/angular/angular-cli/commit/61f409cbe4a7bf59711ef0cfa3b7365a8df3016d) | perf | disable ahead of time prerendering in vite dev-server | +| [01ab16c5d](https://github.com/angular/angular-cli/commit/01ab16c5d5678a135a5af5640ad2ba7c33a00452) | perf | fully avoid rebuild of component stylesheets when unchanged | +| [99d9037ee](https://github.com/angular/angular-cli/commit/99d9037eee2eabd7b5ec2d8f01146578ef6b5860) | perf | only perform a server build when either prerendering, app-shell or ssr is enabled | +| [c013a95e2](https://github.com/angular/angular-cli/commit/c013a95e2f38a5c2435b22c3338bf57b03c84ebf) | perf | only rebundle browser polyfills on explicit changes | +| [e68a662bc](https://github.com/angular/angular-cli/commit/e68a662bc0e636082e43b4f3c894585174366f4d) | perf | only rebundle global scripts/styles on explicit changes | +| [28d9ab88f](https://github.com/angular/angular-cli/commit/28d9ab88fe81898ec7591608816c77455c9a61bf) | perf | only rebundle server polyfills on explicit changes | +| [6d3942723](https://github.com/angular/angular-cli/commit/6d3942723d824382e52a8f06e03dcbc3d6d8eff6) | perf | optimize server or browser only dependencies once | +| [2e8e9d802](https://github.com/angular/angular-cli/commit/2e8e9d8020aa01107a3ee6b31942d9d53d6f73cd) | perf | patch `fetch` to load assets from memory | +| [49fe74e24](https://github.com/angular/angular-cli/commit/49fe74e241d75456c65a7cd439b9eb8842e9d6d7) | perf | reduce CLI loading times by removing critters from critical path | +| [07e2120da](https://github.com/angular/angular-cli/commit/07e2120dab741fda11debc0fe777a5ef888dcaad) | perf | remove JavaScript transformer from server polyfills bundling | +| [c28475d30](https://github.com/angular/angular-cli/commit/c28475d30b08138ddddb9903acaa067cf8ab2ef6) | perf | reuse esbuild generated output file hashes | +| [59c22aa4c](https://github.com/angular/angular-cli/commit/59c22aa4cadd7bc6da20acfd3632c834824044e2) | perf | start SSR dependencies optimization before the first request | +| [223a82f5f](https://github.com/angular/angular-cli/commit/223a82f5f02c8caaf34ce49ee3ddde22a75e65c1) | perf | use incremental bundling for component styles in esbuild builders | +| [4b67d2afd](https://github.com/angular/angular-cli/commit/4b67d2afd3a2d4be188a7313b3fe4ea5c07907b6) | perf | use single JS transformer instance during dev-server prebundling | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- | +| [f600bbc97](https://github.com/angular/angular-cli/commit/f600bbc97d30a003b9d41fa5f67590d3955e6375) | refactor | remove deprecated `runExternalSchematicAsync` and `runSchematicAsync` | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [81e4917ce](https://github.com/angular/angular-cli/commit/81e4917ceca89759770a76d63b932f380d83685c) | fix | replace Angular logos | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [dcf3fddff](https://github.com/angular/angular-cli/commit/dcf3fddff2fa4cf3433c5d726be9f514ba41e827) | feat | add performance profiler to `CommonEngine` | +| [6224b0599](https://github.com/angular/angular-cli/commit/6224b0599fd60f61c537aa602fb89079197a6e2d) | fix | correctly set config URL | +| [8d033841d](https://github.com/angular/angular-cli/commit/8d033841d1785944f60ccd425e413865c9caf581) | fix | enable `prerender` and `ssr` for all build configuration | +| [ee0991bed](https://github.com/angular/angular-cli/commit/ee0991beddc96160f9ba7e27b29def54868f3490) | fix | enable performance profiler option name | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [f43754570](https://github.com/angular/angular-cli/commit/f437545705d41c781498b8e7724293455cf3edf9) | feat | add automated preconnects for image domains | +| [4fe03266a](https://github.com/angular/angular-cli/commit/4fe03266a9232346ec49defa98d9eb3a8d88b1ff) | fix | account for arrow function IIFE | +| [828030da0](https://github.com/angular/angular-cli/commit/828030da0fa9e82fa784c4f55e3c089c7c601e98) | fix | account for styles specified as string literals and styleUrl | +| [16428fc97](https://github.com/angular/angular-cli/commit/16428fc97ae64627f790346e6b54b94a67c7202c) | fix | adjust static scan to find image domains in standlone components | +| [486becdbb](https://github.com/angular/angular-cli/commit/486becdbbaec7cacfa42bd66882efe720473b0f6) | fix | remove setClassDebugInfo calls | +| [89f21ac8c](https://github.com/angular/angular-cli/commit/89f21ac8c4309614a59cda5a8ebc3b3fbc663932) | fix | remove setClassMetadataAsync calls | +| [8899fb9e3](https://github.com/angular/angular-cli/commit/8899fb9e36556debe3b262f27c1b6e94c4963144) | fix | skip transforming empty inline styles in Webpack JIT compilations | + + + + + +# 16.2.10 (2023-11-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [bab3672cd](https://github.com/angular/angular-cli/commit/bab3672cdaf4875cf83f94e34abdef29cffe2686) | fix | normalize exclude path | + + + + + +# 16.2.8 (2023-10-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [44275601b](https://github.com/angular/angular-cli/commit/44275601ba0e4c7b8c24f8184a33d09350a0fbef) | fix | remove the need to specify `--migrate-only` when `--name` is used during `ng update` | + + + + + +# 16.2.7 (2023-10-19) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [f1a0c3361](https://github.com/angular/angular-cli/commit/f1a0c3361a6caa27bdf5cc07315d8bf2b6424b11) | fix | change Twitter logo to X | + + + + + +# 16.2.6 (2023-10-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [c6ea25626](https://github.com/angular/angular-cli/commit/c6ea2562683cc6e640136a02760db9363ded4352) | fix | fully downlevel async/await when using vite dev-server with caching enabled | + + + + + +# 15.2.10 (2023-10-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [05213c95b](https://github.com/angular/angular-cli/commit/05213c95b032dd64fdc73ed33af695e9f19b5d09) | fix | update dependency postcss to v8.4.31 | + + + + + +# 14.2.13 (2023-10-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [1ca44dcd9](https://github.com/angular/angular-cli/commit/1ca44dcd9d79916db70180da37b962c2672a76a8) | fix | update dependency postcss to v8.4.31 | + + + + + +# 16.2.5 (2023-10-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| [933358186](https://github.com/angular/angular-cli/commit/93335818689a67557942ab27ec8cc5b96f2a5abe) | fix | do not print `Angular is running in development mode.` in the server console when using dev-server | +| [493bd3906](https://github.com/angular/angular-cli/commit/493bd390679889359a05b2f23b74787647aee341) | fix | update dependency postcss to v8.4.31 | + + + + + +# 16.2.4 (2023-09-27) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [5dc7fb1a1](https://github.com/angular/angular-cli/commit/5dc7fb1a1849a427ceedb06404346de370c91083) | fix | update `@angular/cli` version specifier to use `^` | + + + + + +# 16.2.3 (2023-09-20) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [39643bee1](https://github.com/angular/angular-cli/commit/39643bee1522e0313be612b564f2b96ec45007ec) | fix | correctly re-point RXJS to ESM on Windows | +| [d8d116b31](https://github.com/angular/angular-cli/commit/d8d116b318377d51f258a1a23025be2d41136ee3) | fix | several windows fixes to application builder prerendering | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [f1195d035](https://github.com/angular/angular-cli/commit/f1195d0351540bdcc7d3f3e7cf0761389eb3d569) | fix | fix recursion in webpack resolve | + + + + + +# 16.2.2 (2023-09-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [e3a40a49a](https://github.com/angular/angular-cli/commit/e3a40a49aa768c6b0ddce24ad47c3ba50028963c) | fix | support dev server proxy pathRewrite field in Vite-based server | + + + + + +# 16.2.1 (2023-08-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [221ab2483](https://github.com/angular/angular-cli/commit/221ab2483a5504b0ad864a18dc5a4dbeb8c0748e) | fix | display warning when using `resourcesOutputPath` with esbuild builder | +| [fe752ad87](https://github.com/angular/angular-cli/commit/fe752ad87b8588e2a1ee1611953b36d5c004e673) | fix | encode Sass package resolve directories in importer URLs | +| [82b0f94fd](https://github.com/angular/angular-cli/commit/82b0f94fdacc5f4665d00eeb1c93fcfc104b0cc8) | fix | handle HMR updates of global CSS when using Vite | +| [6a48a11b8](https://github.com/angular/angular-cli/commit/6a48a11b8c218796e4b778bd00d453fc0ac0c48e) | fix | update vite to be able to serve app-shell and SSG pages | +| [fdb16f7cd](https://github.com/angular/angular-cli/commit/fdb16f7cd4327980436ddb1ce190c67c86588d2d) | fix | use correct type for `extraEntryPoints` | + + + + + +# 16.2.0 (2023-08-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [e6b377436](https://github.com/angular/angular-cli/commit/e6b377436a471073657dc35e7c7a28db6688760a) | feat | add `ssr` option in application builder | +| [c05c83be7](https://github.com/angular/angular-cli/commit/c05c83be7c6c8bcdad4be8686a6e0701a55304cc) | feat | add initial application builder implementation | +| [095f5aba6](https://github.com/angular/angular-cli/commit/095f5aba60a4c1267a87b8b3ae38dbfbf70731f1) | feat | add initial support for server bundle generation using esbuild | +| [cb165a75d](https://github.com/angular/angular-cli/commit/cb165a75dc8c21ead537684a092ed50d3736e04a) | feat | add pre-rendering (SSG) and App-shell support generation to application builder | +| [2a3fc6846](https://github.com/angular/angular-cli/commit/2a3fc68460152a48758b9353bff48193641861c5) | feat | add preload hints based on transitive initial files | +| [099cec758](https://github.com/angular/angular-cli/commit/099cec758ad671c7fd0ca2058a271e4fe181a44d) | feat | add support for serving SSR with dev-server when using the application builder | +| [449e21b3a](https://github.com/angular/angular-cli/commit/449e21b3a6da990a5865bb5bdfb8145794f40cf9) | fix | correctly load dev server assets with vite 4.4.0+ | +| [f42f10135](https://github.com/angular/angular-cli/commit/f42f10135c1e2184a9080b726dc5e41669937b13) | fix | ensure preload hints for external stylesheets are marked as styles | +| [7defb3635](https://github.com/angular/angular-cli/commit/7defb3635c89737d151c9537bd7becd463098434) | fix | ensure that server dependencies are loaded also in ssr entrypoint | +| [05f31bd28](https://github.com/angular/angular-cli/commit/05f31bd28f002a232598e0468dc418f99e434ae0) | fix | prevent race condition in setting up sass worker pool | +| [5048f6e82](https://github.com/angular/angular-cli/commit/5048f6e82e299b0733f34cbdcb1e7b1ef9a63210) | fix | Set chunk names explicitly | +| [974748cdf](https://github.com/angular/angular-cli/commit/974748cdf894c5ad0451e3fdf1c186bdad80878b) | perf | filter postcss usage based on content in esbuild builder | +| [61a652d91](https://github.com/angular/angular-cli/commit/61a652d91274f4adce20182e630fe9963b4ceddd) | perf | inject Sass import/use directive importer information when resolving | +| [a0a2c7aef](https://github.com/angular/angular-cli/commit/a0a2c7aef675f8aae294d2119f721c4345d633b0) | perf | only load browserslist in babel preset if needed | +| [6bfd1800e](https://github.com/angular/angular-cli/commit/6bfd1800efa2bf41126696b66938bdf291ad5455) | perf | use in-memory Sass module resolution cache | + + + + + +# 16.1.8 (2023-08-04) + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [7a420d338](https://github.com/angular/angular-cli/commit/7a420d3382b21d24c73b722e849f01b0aacfb860) | fix | build: update critters | + + + + + +# 16.1.7 (2023-08-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [1dab4ed87](https://github.com/angular/angular-cli/commit/1dab4ed8738b42d6b93298889caf1546b011706f) | fix | hot update filename suffix with `.mjs` | + + + + + +# 16.1.6 (2023-07-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [20816b57f](https://github.com/angular/angular-cli/commit/20816b57f16b0bcbd5b81f06f6f790e4485c1daa) | fix | error during critical CSS inlining for external stylesheets | + + + + + +# 16.1.5 (2023-07-20) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [7e91d4709](https://github.com/angular/angular-cli/commit/7e91d4709966c592c271ff8d3456ce569156e2e5) | fix | add `zone.js` to `ng version` output | +| [475506822](https://github.com/angular/angular-cli/commit/475506822b148c8e2597c60653238a40140bacb0) | fix | throw an error when executed in a google3-context | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [07d3d8c6a](https://github.com/angular/angular-cli/commit/07d3d8c6ae01212de866fac769ff2da888d5adea) | fix | correctly wrap CommonJS exported enums when optimizing | + + + + + +# 16.1.4 (2023-07-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [7016cee57](https://github.com/angular/angular-cli/commit/7016cee5783b2762d550db8f2a4f29e7b56f317f) | fix | normalize paths in loader cache with esbuild | + + + + + +# 16.1.3 (2023-06-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [b56ab0798](https://github.com/angular/angular-cli/commit/b56ab07980c5d990606ddb1e298fc1c4ecf8a2a8) | fix | use absolute watch paths for postcss dependency messages | + + + + + +# 15.2.9 (2023-06-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [f36e38a91](https://github.com/angular/angular-cli/commit/f36e38a913b454ec340d6bf2311391c5df1cee24) | fix | update direct semver dependencies to 7.5.3 | + + + + + +# 16.1.2 (2023-06-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [3475e0281](https://github.com/angular/angular-cli/commit/3475e0281da3298f288a5f8836155c0b8c971372) | fix | update direct `semver` dependencies to 7.5.3 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [8108b8c2d](https://github.com/angular/angular-cli/commit/8108b8c2da3ebfdb74f0f9d3554df01f484670bd) | fix | allow linker JIT support with prebundling with esbuild builder | +| [502365037](https://github.com/angular/angular-cli/commit/502365037bf7dbacd0e28d29a074a246971848ea) | fix | use all style language watch files in esbuild builder | + + + + + +# 14.2.12 (2023-06-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [bd396b656](https://github.com/angular/angular-cli/commit/bd396b65623fb0b8e826be13f88709e87b54336e) | fix | update direct semver dependencies to 7.5.3 | + + + + + +# 16.1.1 (2023-06-22) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [f017fee2e](https://github.com/angular/angular-cli/commit/f017fee2e93a4207b7bfd69c838991546b398753) | fix | actually disable Vite prebundling file discovery | +| [2b4beaca2](https://github.com/angular/angular-cli/commit/2b4beaca2c32c11508078e082b3338d1edb414a0) | fix | experimental esbuild pipeline, add `es2015` to main fields for RxJS v6 compatibility | +| [e3c85e00e](https://github.com/angular/angular-cli/commit/e3c85e00e6b3390f239aaeb3db6a38fe4b4d2523) | fix | track postcss provided file dependencies in esbuild builder | +| [1419fff88](https://github.com/angular/angular-cli/commit/1419fff887173e331690fb0a664a081154842554) | fix | unpin and downgrade `browserslist` | +| [950a4b60f](https://github.com/angular/angular-cli/commit/950a4b60f046117867755ccd005f0e04bcc403a7) | fix | watch all bundler provided inputs with esbuild builder | + + + + + +# 16.1.0 (2023-06-13) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [b14b95990](https://github.com/angular/angular-cli/commit/b14b959901d5a670da0df45e082b8fd4c3392d14) | feat | add bootstrap-agnostic utilities for writing ng-add schematics | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [3ede1a2ca](https://github.com/angular/angular-cli/commit/3ede1a2cac5005f4dfbd2a62ef528a34c3793b78) | feat | allow forcing esbuild builder with dev-server | +| [2d141fe3b](https://github.com/angular/angular-cli/commit/2d141fe3bc1efb9e254b15ce91ebc885a43c928a) | feat | show estimated transfer size with esbuild builder | +| [9aa9b5264](https://github.com/angular/angular-cli/commit/9aa9b5264eee1b1dda7abd334b560d4b446c4970) | feat | support autoprefixer/tailwind CSS with Less/Sass in esbuild builder | +| [3d1c09b23](https://github.com/angular/angular-cli/commit/3d1c09b235bf1db0d031c36fdc68ab99359b34b1) | feat | support dev-server package prebundling with esbuild builder | +| [d8930facc](https://github.com/angular/angular-cli/commit/d8930facc075e39d82b3c6cb252c9a8b5fa6a476) | feat | support incremental TypeScript semantic diagnostics in esbuild builder | +| [5cacd34a2](https://github.com/angular/angular-cli/commit/5cacd34a222eea16c18caa63dbe4448b81e106f3) | fix | watch all TypeScript referenced files in esbuild builder | +| [8336ad80d](https://github.com/angular/angular-cli/commit/8336ad80da41cde69343960f7515d9ffd5e5e2e1) | perf | enable in-memory load result caching for stylesheets in esbuild builder | + + + + + +# 16.0.6 (2023-06-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [eebb54cbf](https://github.com/angular/angular-cli/commit/eebb54cbf4683b6113eb56dba17fab038318c918) | fix | correctly handle sass imports | +| [081b62539](https://github.com/angular/angular-cli/commit/081b62539b2562bff130343558bf4baafed7c36d) | fix | support proxy configuration array-form in esbuild builder | + + + + + +# 16.0.5 (2023-06-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [9817b984b](https://github.com/angular/angular-cli/commit/9817b984b15e352caedac6e347cc662117b9e0f8) | fix | ignore .git folder in browser-esbuild watcher | +| [ce95d2545](https://github.com/angular/angular-cli/commit/ce95d254510ffa93a9bd4230f6447530d511ef5f) | fix | ignore folders starting with a dot in browser-esbuild watcher | + + + + + +# 16.0.4 (2023-06-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [5bff97d5b](https://github.com/angular/angular-cli/commit/5bff97d5b965373cd7e4dc0b731c24d80b512faa) | fix | correctly set overridden compiler option | +| [cd0247514](https://github.com/angular/angular-cli/commit/cd0247514db295661d319bec33ad7167229d99f9) | fix | preemptively remove AOT metadata in esbuild builder | + + + + + +# 16.0.3 (2023-05-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [1d83bb656](https://github.com/angular/angular-cli/commit/1d83bb6565550107ab00de52b706cad8f28514b3) | fix | percent encode asset URLs in development server for esbuild | + + + + + +# 16.0.2 (2023-05-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [7a3c895c8](https://github.com/angular/angular-cli/commit/7a3c895c8da534ceff26754ca7ffd49b30c24069) | fix | attempt relative global script read first in esbuild builder | +| [f30be2518](https://github.com/angular/angular-cli/commit/f30be2518b118106f5d6634c92279adcefab0f70) | fix | correctly generate serviceworker hashes for binary assets | +| [117e8d001](https://github.com/angular/angular-cli/commit/117e8d00192d3b764c9c362c2554fa80706946cf) | fix | normalize Vite dev-server Windows asset paths | +| [e5c1d43de](https://github.com/angular/angular-cli/commit/e5c1d43de932daedfaac002ff363ed12243f97bb) | perf | minor sourcemap ignorelist improvements for esbuild builder | + + + + + +# 16.0.1 (2023-05-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [ed82c83fe](https://github.com/angular/angular-cli/commit/ed82c83fef1a67b4168be455b119860217267564) | fix | avoid CommonJS warnings for relative imports with esbuild builders | +| [3083c4eda](https://github.com/angular/angular-cli/commit/3083c4eda87e735a4b1b9e16ff1f61abbccb1c98) | fix | avoid hash filenames for non-injected global styles/scripts | +| [b106bc9d0](https://github.com/angular/angular-cli/commit/b106bc9d07b1e2e38176c484d2fc04251e274aa5) | fix | clean incoming index URL before processing in esbuild builder | +| [2967705ed](https://github.com/angular/angular-cli/commit/2967705ed3f88c35e93866bca659222769664c62) | fix | convert dev-server glob proxy entries for esbuild builder | +| [a9d20015c](https://github.com/angular/angular-cli/commit/a9d20015c943e89b6f29a6e3e295bef6e2072a92) | fix | disable runtime errors from being displayed in overlay | +| [822b552f6](https://github.com/angular/angular-cli/commit/822b552f6f94ac1c39405f7359550e1ab5aa4c17) | fix | fix index option const value for browser-esbuild | +| [131cd23b6](https://github.com/angular/angular-cli/commit/131cd23b65c12ba671088aafcaff4d522f402ba8) | fix | prevent relative import failure with Less in esbuild builder | +| [fedcc5d92](https://github.com/angular/angular-cli/commit/fedcc5d923b7237622b1e7adef053a2ee68f872e) | fix | properly set base dev-server path with esbuild | +| [cb3161045](https://github.com/angular/angular-cli/commit/cb3161045ef39e335460672d016cf0a973de428a) | fix | show error note for CSS url() tilde usage in esbuild builder | +| [54e5000ca](https://github.com/angular/angular-cli/commit/54e5000ca88655bf9d01b87e317dc5810a7ac676) | fix | workaround for esbuild static block AOT generated code | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [5a35970af](https://github.com/angular/angular-cli/commit/5a35970afdf39461592bb0130eb9b959272949fb) | fix | do not generate an UpdateBuffer for created and overridden files | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [70d224ca7](https://github.com/angular/angular-cli/commit/70d224ca7edbfe31fb6360e55cbe06c65dc5e91a) | fix | compress PWA icons | + + + + + +# 16.0.0 (2023-05-03) + +## Breaking Changes + +### @angular/cli + +- The deprecated `defaultCollection` workspace option has been removed. Use `schematicCollections` instead. + + Before + + ```json + "defaultCollection": "@angular/material" + ``` + + After + + ```json + "schematicCollections": ["@angular/material"] + ``` + +- The deprecated `defaultProject` workspace option has been removed. The project to use will be determined from the current working directory. +- Node.js v14 support has been removed + + Node.js v14 is planned to be End-of-Life on 2023-04-30. Angular will stop supporting Node.js v14 in Angular v16. + Angular v16 will continue to officially support Node.js versions v16 and v18. + +### @schematics/angular + +- `ng g resolver` and `ng g guard` now generate a functional resolver or guard by default. It is still possible to generate a (deprecated) class-based resolver or guard by using `ng g resolver --no-functional` or `ng g guard --no-functional`. +- The CLI no longer allows to generate `CanLoad` guards. Use `CanMatch` instead. + +### + +- - TypeScript 4.8 is no longer supported. + +### @angular-devkit/build-angular + +- Deprecated `outputPath` and `outputPaths` from the server and browser builder have been removed from the builder output. Use `outputs` instead. + + Note: this change does not effect application developers. + +### @angular-devkit/core + +- Several changes to the `SchemaRegistry`. + - `compile` method now returns a `Promise`. + - Deprecated `flatten` has been removed without replacement. +- - `ContentHasMutatedException`, `InvalidUpdateRecordException`, `UnimplementedException` and `MergeConflictException` API from `@angular-devkit/core` have been removed in favor of the API from `@angular-devkit/schematics`. + - `UnsupportedPlatformException` - A custom error exception should be created instead. + +### @angular-devkit/schematics + +- The depracated `UpdateBuffer` has been removed and `UpdateBuffer2` + is renamed to `UpdateBuffer`. With this change the related and + deprecated symbols `ContentCannotBeRemovedException` and `Chunk` + have also been removed. + +### @ngtools/webpack + +- NGCC integration has been removed and as a result Angular View Engine libraries will no longer work. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [c2d2da41b](https://github.com/angular/angular-cli/commit/c2d2da41b15143e11f597192eef755c5e3fb4c5d) | feat | add support to add service worker to standalone application | +| [22fdd7da9](https://github.com/angular/angular-cli/commit/22fdd7da97c832048410ca89622712d097490c5d) | feat | generate functional resolvers and guards by default | +| [a832c2028](https://github.com/angular/angular-cli/commit/a832c202828a1caa425e1a0c5ff8d2ebb77c4667) | feat | Implement a standalone flag for new applications | +| [5ceedcb11](https://github.com/angular/angular-cli/commit/5ceedcb11e3ca5bdad4248c7c76ca2562fab43f2) | feat | remove deprecated CanLoad option for guards | +| [c9e84d024](https://github.com/angular/angular-cli/commit/c9e84d0243b4e9191f6cfcd72ebf8288de2b6f2d) | feat | remove generation of `BrowserModule.withServerTransition` | +| [50b9e59a5](https://github.com/angular/angular-cli/commit/50b9e59a50b737e34ee12ee48ab83d17c2b8744a) | feat | update app-shell schematic to support standalone applications | +| [dc5cc893d](https://github.com/angular/angular-cli/commit/dc5cc893d6c3d4e5e6f6c4b19bee632b66a94fc0) | feat | Update universal schematic to support standalone applications | +| [f98c9de80](https://github.com/angular/angular-cli/commit/f98c9de80952593e0294538d96bdac7136629f77) | fix | add experimental message when using standalone application schematic. | +| [a5cb46124](https://github.com/angular/angular-cli/commit/a5cb46124234ec2c47f6288914ad3ed9564f3a72) | fix | add standalone option to library library | +| [b2ed7bd10](https://github.com/angular/angular-cli/commit/b2ed7bd100bfe77dca81c590b827870fd496075f) | fix | provide migration that disables build optimizer on dev server builds | +| [ba4414b2c](https://github.com/angular/angular-cli/commit/ba4414b2cfb7a040393f314d87ab823bcad75f26) | fix | reformat app.config.ts | +| [202e9a50f](https://github.com/angular/angular-cli/commit/202e9a50f62b7927c0900469b21d323b3010762d) | fix | remove compileComponents from component test schematic | +| [0d58f73c5](https://github.com/angular/angular-cli/commit/0d58f73c50ce496dd3a0166533069f450f83a461) | fix | rename `app.server.module.ts` to `app.module.server.ts` | +| [de6d30102](https://github.com/angular/angular-cli/commit/de6d30102978eebda7edbdda43ca50f18c4c8aaf) | fix | replace `provideServerSupport` with `provideServerRendering` | +| [bff634fe0](https://github.com/angular/angular-cli/commit/bff634fe0938ecb4a316064ba3f1b9c2c1f208fe) | fix | update private Components utilities to work with standalone project structure | +| [85fe820b0](https://github.com/angular/angular-cli/commit/85fe820b081b73b229084882e98e65b5c57f9d0f) | fix | use same property order in standalone AppComponent | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [68024234e](https://github.com/angular/angular-cli/commit/68024234edcb942d5a177d6bd7567e77d7e40245) | feat | remove deprecated `defaultCollection` from workspace configuration | +| [d58428d3d](https://github.com/angular/angular-cli/commit/d58428d3dbdb7275e2e4f6d271fcc5fdda5c489e) | feat | remove deprecated `defaultProject` from workspace configuration | +| [7cb5689e0](https://github.com/angular/angular-cli/commit/7cb5689e02c30c0ef53adef92d0e9969e1a1536b) | feat | show optional migrations during update process | +| [c29c8e18d](https://github.com/angular/angular-cli/commit/c29c8e18d84096e2f72af12643c31bde51010548) | refactor | remove Node.js v14 support | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------- | +| [5a171ddff](https://github.com/angular/angular-cli/commit/5a171ddff66ff366089616736baf7545fe44f570) | build | update to TypeScript 5 and drop support for TypeScript 4.8 | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [48871381a](https://github.com/angular/angular-cli/commit/48871381a169888f1d29275ab25915b0d815d1c1) | fix | allow registered builder teardowns to execute | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | +| [ff5ebf9b1](https://github.com/angular/angular-cli/commit/ff5ebf9b1244c5a01961cd3dba6bb345392aa57c) | feat | add CSP support for inline styles | +| [ee8013f66](https://github.com/angular/angular-cli/commit/ee8013f66f7587ba85ed76fb0c662168fd850c47) | feat | display build output table with esbuild | +| [0eac98f61](https://github.com/angular/angular-cli/commit/0eac98f6176bde662d7d7e9532b5a988b8e7ece2) | feat | implement progress option for esbuild builder | +| [f04859d16](https://github.com/angular/angular-cli/commit/f04859d16117a41b6e8ad698a449aca73456b9d7) | feat | initial autoprefixer support for CSS in esbuild builder | +| [8c550302c](https://github.com/angular/angular-cli/commit/8c550302cc046e649f1245007e0e26550a61f931) | feat | initial development server for esbuild-based builder | +| [52969db6b](https://github.com/angular/angular-cli/commit/52969db6bdaf42ec7d7f28274eba518ed1a794b7) | feat | initial tailwindcss support for CSS in esbuild builder | +| [ce46ecae0](https://github.com/angular/angular-cli/commit/ce46ecae011595c86fea265e121ea313bb3cb030) | feat | support module resolution with less stylesheets in esbuild builder | +| [584b51907](https://github.com/angular/angular-cli/commit/584b51907c3b3f60db5478994fff3f800b70c3f2) | feat | support scripts option with esbuild builder | +| [e4883b0ee](https://github.com/angular/angular-cli/commit/e4883b0ee1d1ee7cd57e6cb374944021a100fd3b) | feat | support SSL options with esbuild development server | +| [290802060](https://github.com/angular/angular-cli/commit/2908020601e627b7c76c6fe8d53e19e8858cd325) | feat | support standalone app-shell generation | +| [766c14698](https://github.com/angular/angular-cli/commit/766c14698473fe333168c06e3b88c7303e868acf) | fix | add sourcemap `x_google_ignoreList` support for esbuild builder | +| [cdfa7ca88](https://github.com/angular/angular-cli/commit/cdfa7ca88c2e79564192d4b7fdafb53d97f2607d) | fix | allow multiple polyfills with esbuild-based builder | +| [e690b7cbd](https://github.com/angular/angular-cli/commit/e690b7cbde470b69b3c23fa9af1ecfca4c8e3a7e) | fix | always enable `looseEnums` build optimizer rule | +| [135ab4c36](https://github.com/angular/angular-cli/commit/135ab4c363d5d247342c4bc123a17eb66de17752) | fix | avoid double sourcemap comments with esbuild dev-server | +| [dcf60d2be](https://github.com/angular/angular-cli/commit/dcf60d2be26fdbc1efaec1c506188cb166ffbdf0) | fix | correctly filter lazy global styles in esbuild builder | +| [342a4ea30](https://github.com/angular/angular-cli/commit/342a4ea30e1ab9cbdbe5d6de339c21bdcff1a2c1) | fix | correctly show initial files in stat table with esbuild builder | +| [107851ae4](https://github.com/angular/angular-cli/commit/107851ae45d8399782cbc73d3fa09b3f779e1e02) | fix | display warning when `preserveWhitespaces` is set in the tsconfig provided to the server builder | +| [ff8a89cbf](https://github.com/angular/angular-cli/commit/ff8a89cbfd308a0312d16956d55c30e2425e2d33) | fix | ensure all build resources are served in esbuild dev server | +| [f76a8358e](https://github.com/angular/angular-cli/commit/f76a8358ea07a0d00fb0eb1c62dfaccf056531be) | fix | ensure directories are properly ignored in esbuild builder | +| [005ba4276](https://github.com/angular/angular-cli/commit/005ba427661f0e5907020aea10c432a324b528a8) | fix | ensure empty component styles compile with esbuild | +| [f74151baa](https://github.com/angular/angular-cli/commit/f74151baab740df15a5cc80255d97d0320147b2a) | fix | exclude `@angular/platform-server/init` from unsafe optimizations | +| [f72155bc7](https://github.com/angular/angular-cli/commit/f72155bc7025f4e0b23eb58a92e422bd341720f6) | fix | fully remove third-party sourcemaps when disabled in esbuild builder | +| [26dced95c](https://github.com/angular/angular-cli/commit/26dced95c5612f6386b3179fce50904f178ee569) | fix | JIT support for standalone applications | +| [4822b3ba5](https://github.com/angular/angular-cli/commit/4822b3ba55ec824913e895e76cf83e2b36ec99f9) | fix | keep esbuild server active until builder fully stops | +| [adbf2c8a1](https://github.com/angular/angular-cli/commit/adbf2c8a1ed67f505ea27921c00f957509e9a958) | fix | normalize long-form asset option output to relative path | +| [67670b612](https://github.com/angular/angular-cli/commit/67670b612e2397e26a974cd337cdce1a9c6a0f21) | fix | pass listening port in result for esbuild dev server | +| [1a8833b21](https://github.com/angular/angular-cli/commit/1a8833b211cbf2535d3deed1029591050bc995b8) | fix | provide option to run build-optimizer on server bundles | +| [b8c9667f9](https://github.com/angular/angular-cli/commit/b8c9667f9292d3829bfcac10a98acd859301c3c7) | fix | remove unintended files in esbuild output stats table | +| [04274afc1](https://github.com/angular/angular-cli/commit/04274afc15084ead2916e11055aa8f1d2f61951d) | fix | set public class fields as properties ([#24849](https://github.com/angular/angular-cli/pull/24849)) | +| [a778fe6c2](https://github.com/angular/angular-cli/commit/a778fe6c2e7b9ca0c0995e1350460e97085b39a1) | fix | show lazy files in stat table correctly with esbuild | +| [955b493b1](https://github.com/angular/angular-cli/commit/955b493b13e0a8956706c486d31d9e4338bf41c5) | fix | support CSP on critical CSS link tags. | +| [c272172c8](https://github.com/angular/angular-cli/commit/c272172c84bef35f63038f1fc5fa184b1e2d99bf) | fix | update esbuild builder complete log | +| [0b450578a](https://github.com/angular/angular-cli/commit/0b450578a74e2b46488ae2e97c7f76389baa5271) | fix | update list of known tailwind configuration files | +| [759ae92aa](https://github.com/angular/angular-cli/commit/759ae92aaa595fe3f6000f3aae0e6bb8d025db3a) | fix | update peer dependencies to support version 16 | +| [eca366a84](https://github.com/angular/angular-cli/commit/eca366a843be1fcc8d949bc335cac4cdcbdea41c) | fix | use preserveSymlinks option for tsconfigs in esbuild builder | +| [28c27567c](https://github.com/angular/angular-cli/commit/28c27567cf90712e6c8f4d483bcc0e0fc683ee9b) | perf | asynchronously delete output path in esbuild builder | +| [458400b7b](https://github.com/angular/angular-cli/commit/458400b7b1a435e2febe2c4e1a9fd1ca4eda58d0) | perf | avoid unnessary iterations | +| [a710a262a](https://github.com/angular/angular-cli/commit/a710a262aed8a6c4a6af48e0ad7f479f0a23212e) | perf | cache Sass in memory with esbuild watch mode | +| [e1398d333](https://github.com/angular/angular-cli/commit/e1398d333e86b6caad8b5cfef7048fefd77a9e22) | perf | do not inline sourcemap when using vite dev-server | +| [b2ece91b7](https://github.com/angular/angular-cli/commit/b2ece91b7488a01b6ddfcba1e68f97416c8b05f7) | perf | enhance Sass package resolution in esbuild builder | +| [aae34fc02](https://github.com/angular/angular-cli/commit/aae34fc02dc774d59ecac6483288f47074ee8c2d) | perf | fully lazy load sass in esbuild builder | +| [9ea3e8e34](https://github.com/angular/angular-cli/commit/9ea3e8e349dd1765d5935517999a1879a7a0227d) | perf | only import esbuild watcher when in watch mode | +| [f88ac6fdf](https://github.com/angular/angular-cli/commit/f88ac6fdfee6abf406720c9bc72aa9ddadb112f9) | perf | skip Angular linker in JIT mode with esbuild | +| [a99018cd7](https://github.com/angular/angular-cli/commit/a99018cd7bb66ee53026e06deae6a14455023910) | refactor | remove deprecated `outputPaths` and `outputPath` Builder output | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | +| [f6624b974](https://github.com/angular/angular-cli/commit/f6624b974faf13fa718d304e1a473260c16f0c1d) | feat | update SchemaRegistry `compile` to return `Promise` | +| [0ad81cdbc](https://github.com/angular/angular-cli/commit/0ad81cdbc72e80ca75d9d5cc2bc0c6163267a0bb) | refactor | remove deprecated exceptions | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [d2ef386f4](https://github.com/angular/angular-cli/commit/d2ef386f46131af904ca800cc77388c03239cd9d) | refactor | remove `UpdateBuffer` and rename `UpdateBuffer2` to `UpdateBuffer` | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------- | +| [c8ac660d8](https://github.com/angular/angular-cli/commit/c8ac660d8b13922be7ebcc92dfd5b18392602c40) | refactor | remove NGCC integration | + + + + + +# 15.2.8 (2023-05-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [069dcdf0c](https://github.com/angular/angular-cli/commit/069dcdf0c4e614fea83af61d4496bdd8a96920ca) | docs | improve wording in doc command version description | + + + + + +# 15.2.7 (2023-04-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [f4a6dac87](https://github.com/angular/angular-cli/commit/f4a6dac8782808e564678b4484f3ce87e59f6c8f) | fix | process keeps running when analytics are enabled | +| [f9b2fb1c4](https://github.com/angular/angular-cli/commit/f9b2fb1c4981ff138992a502d3aba4f6a3886df4) | perf | register CLI commands lazily | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [d9aefd6da](https://github.com/angular/angular-cli/commit/d9aefd6da5bd6ea244da3a8d5ea3dcbbadd31f99) | fix | replace vscode launch type from `pwa-chrome` to `chrome` | + + + + + +# 15.2.6 (2023-04-12) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [f0b257ef4](https://github.com/angular/angular-cli/commit/f0b257ef4ae62f92d70bfd2a4e9912d4ceff9c78) | fix | ignore hidden directories when running browserlist migration | + + + + + +# 15.2.5 (2023-04-05) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [db173d7ed](https://github.com/angular/angular-cli/commit/db173d7edf685df67b782d81d1bacb84b8debf9a) | fix | collect tech information | + +## Special Thanks + +Alan Agius + + + + + +# 15.2.4 (2023-03-16) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [f74bfea24](https://github.com/angular/angular-cli/commit/f74bfea241b189f261ec81a8561aea7a56774ae8) | fix | update `webpack` dependency to `5.76.1` | + +## Special Thanks + +Alan Agius + + + + + +# 14.2.11 (2023-03-16) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [ddd33bf38](https://github.com/angular/angular-cli/commit/ddd33bf38d7d76e816ebc0459559917da514477d) | fix | update webpack dependency to `5.76.1` | + +## Special Thanks + +Alan Agius and Joey Perrott + + + + + +# 13.3.11 (2023-03-16) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [50fa9300f](https://github.com/angular/angular-cli/commit/50fa9300f264f68ad35606ae46da820c3798f665) | fix | update `webpack` dependency to `5.76.1` | + +## Special Thanks + +Alan Agius and Joey Perrott + + + + + +# 15.2.3 (2023-03-15) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [a93680585](https://github.com/angular/angular-cli/commit/a9368058517509b277236d6e7db4abc6248817fa) | fix | correct wrap ES2022 classes with static properties | + +## Special Thanks + +Alan Agius and Paul Gschwendtner + + + + + +# 15.2.2 (2023-03-08) + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [dfd03aa7c](https://github.com/angular/angular-cli/commit/dfd03aa7c262f4425fa680e205a46792bd7b8451) | fix | correctly transform numbers from prompts | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [eb22f634f](https://github.com/angular/angular-cli/commit/eb22f634f2ec7a5b0bc2f5300682ed8e718b1424) | fix | build optimizer support for non spec-compliant ES2022 class static properties | + +## Special Thanks + +Alan Agius + + + + + +# 15.2.1 (2023-03-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [9a5609a44](https://github.com/angular/angular-cli/commit/9a5609a440fc49b3f7ddf88efb73618b7eede1ea) | fix | improve parsing of error messages | + +## Special Thanks + +Alan Agius and Paul Gschwendtner + + + + + +# 15.2.0 (2023-02-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [0f58a17c4](https://github.com/angular/angular-cli/commit/0f58a17c4ce92495d96721bc3f2b632a890bbab4) | feat | log number of files update during `ng update` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [ecf43090d](https://github.com/angular/angular-cli/commit/ecf43090d110f996f45a259c279f1b83dcab3fd8) | feat | auto detect package manager ([#24305](https://github.com/angular/angular-cli/pull/24305)) | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [01b3bcf89](https://github.com/angular/angular-cli/commit/01b3bcf898108f9b879da4a791fa2a21c6d9f7c5) | feat | add Less stylesheet support to experimental esbuild-based builder | +| [09af70743](https://github.com/angular/angular-cli/commit/09af70743800aefdefe06e0ca32bcdde18f9eb77) | feat | implement node module license extraction for esbuild builder | +| [bbc1a4f0d](https://github.com/angular/angular-cli/commit/bbc1a4f0dc93437fe97a53a35f68d978cc50bb9e) | feat | support CommonJS dependency checking in esbuild | +| [8cf0d17fb](https://github.com/angular/angular-cli/commit/8cf0d17fb1b39ea7bbd1c751995a56de3df45114) | feat | support JIT compilation with esbuild | +| [3f6769ef9](https://github.com/angular/angular-cli/commit/3f6769ef953b1f880508a9152e669064cbb4dcc9) | fix | allow empty scripts to be optimized | +| [421417a36](https://github.com/angular/angular-cli/commit/421417a36b13a44d39e0818171482871ea8b895f) | fix | avoid CommonJS warning for zone.js in esbuild | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Jason Bedard, Joey Perrott, Marvin and Paul Gschwendtner + + + + + +# 15.1.6 (2023-02-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [3d1f48fa2](https://github.com/angular/angular-cli/commit/3d1f48fa2991ded75da3a1b3a431480710a8ce15) | fix | add set `SessionEngaged` in GA | +| [df07ab113](https://github.com/angular/angular-cli/commit/df07ab11351d6f2d82922ae251ccd17b23d9d0a9) | fix | convert `before` option in `.npmrc` to Date | +| [c787cc780](https://github.com/angular/angular-cli/commit/c787cc7803598eb67260cbd2112d411384d518cc) | fix | replace `os.version` with `os.release`. | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [34a4a1bbf](https://github.com/angular/angular-cli/commit/34a4a1bbf608eb54b0a33b3aa3a6be3e2a576770) | fix | correctly copy `safety-worker.js` contents | +| [88a33155d](https://github.com/angular/angular-cli/commit/88a33155d4bc00077d32bef42588427fb2ed49f4) | fix | update the ECMA output warning message to be more actionable | +| [384ad29c9](https://github.com/angular/angular-cli/commit/384ad29c9a66d78e545ed7e48bf962e4df9d0549) | fix | use babel default export helper in build optimizer | +| [59aa1cdbd](https://github.com/angular/angular-cli/commit/59aa1cdbdf3e2712f988790f68bacc174d070b0c) | perf | reduce rebuilt times when using the `scripts` option | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 15.1.5 (2023-02-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [b8bbe9688](https://github.com/angular/angular-cli/commit/b8bbe9688e0e684245636e7d58d50c51719039c8) | fix | error if Angular compiler is used in a schematic | +| [fabbb8a93](https://github.com/angular/angular-cli/commit/fabbb8a936f3b3b1cee8ea5cbdb7bb7832cb02a7) | fix | only set `DebugView` when `NG_DEBUG` is passed | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [499173b5d](https://github.com/angular/angular-cli/commit/499173b5d197f14377203b92b49ff3cbbf55b260) | fix | remove bootstrapping wrapping in universal schematic | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [e87134fe9](https://github.com/angular/angular-cli/commit/e87134fe94831df76698fe0e90fe556da0011511) | fix | build optimizer support for spec-compliant downlevel class properties | +| [d80adde2f](https://github.com/angular/angular-cli/commit/d80adde2fec53e6513983a89dd194a35c426b8aa) | fix | do not fail compilation when spec pattern does not match | +| [11be502e7](https://github.com/angular/angular-cli/commit/11be502e7cc2544371d55c8b3d32b7bcbbf8066e) | fix | fix support of Safari TP versions | +| [14e317d85](https://github.com/angular/angular-cli/commit/14e317d85429c83e6285c5cec4a1c4483d8a1c8f) | fix | load polyfills and runtime as scripts instead of modules | + +## Special Thanks + +Alan Agius, Charles Lyding, Kristiyan Kostadinov and Ricardo + + + + + +# 15.1.4 (2023-02-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [6c8fdfc69](https://github.com/angular/angular-cli/commit/6c8fdfc6985c5b5017a0b6ab6fa38daf4cb9a775) | fix | load JavaScript bundles as modules in karma | +| [317452e3b](https://github.com/angular/angular-cli/commit/317452e3b7e25080132b7f7a069696d1c5054f69) | fix | print server builder errors and warnings | + +## Special Thanks + +Alan Agius + + + + + +# 15.1.3 (2023-01-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [de15ec576](https://github.com/angular/angular-cli/commit/de15ec5763afe231439c3f1ace35cbacefad2ca7) | fix | handle extended schematics when retrieving aliases | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [2c04f4a8f](https://github.com/angular/angular-cli/commit/2c04f4a8f493781fda65f31e81ad86cdd3e510c0) | fix | update browserslist config to include last 2 Chrome version | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [f31bf300b](https://github.com/angular/angular-cli/commit/f31bf300b9f226d9574060b0e4401c4da88c0ee3) | fix | avoid undefined module path for Sass imports in esbuild | +| [c152a4a13](https://github.com/angular/angular-cli/commit/c152a4a13f482948c6aedbbc99d1423f2cf43aea) | fix | update browserslist config to include last 2 Chrome versions | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [9de99202e](https://github.com/angular/angular-cli/commit/9de99202e9427973c7983940fcdea9e4580a79bd) | fix | handle number like strings in workspace writer | + +## Special Thanks + +Alan Agius, Charles Lyding and Doug Parker + + + + + +# 15.1.2 (2023-01-18) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [387472a95](https://github.com/angular/angular-cli/commit/387472a956b71eaca89e210e64f4d75969abc9d3) | fix | register schematic aliases when providing collection name in `ng generate` | +| [5d9fd788a](https://github.com/angular/angular-cli/commit/5d9fd788a997066dea1b2d69dced865a7c60f5c1) | fix | remove `--to` option from being required when using `--from` in `ng update` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------- | +| [0f5fb7e59](https://github.com/angular/angular-cli/commit/0f5fb7e5944e3a521758c67f403d71928f93f7ac) | fix | replace existing `BrowserModule.withServerTransition` calls when running universal schematic | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [bf4639a6e](https://github.com/angular/angular-cli/commit/bf4639a6e97670972c3d5b137230e2f08467010e) | fix | prevent hanging initial build during exception with esbuild | + +## Special Thanks + +Alan Agius, Charles Lyding and Doug Parker + + + + + +# 15.1.1 (2023-01-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [b94bf60ca](https://github.com/angular/angular-cli/commit/b94bf60ca828a22d548d65b819ea745eafb96deb) | fix | update `esbuild` to `0.16.17` | + +## Special Thanks + +Alan Agius + + + + + +# 15.1.0 (2023-01-11) + +## Deprecations + +### @angular-devkit/schematics + +- The Observable based `SchematicTestRunner.runSchematicAsync` and `SchematicTestRunner.runExternalSchematicAsync` method have been deprecated in favor of the Promise based `SchematicTestRunner.runSchematic` and `SchematicTestRunner.runExternalSchematic`. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [5b18ce154](https://github.com/angular/angular-cli/commit/5b18ce1545d047d49851a64e81a1f8ef59624ef7) | feat | add `guardType` as an alias of `implements` in guard schematic | +| [dd2b65943](https://github.com/angular/angular-cli/commit/dd2b65943d706833f449f76cf8c7278d0a5399ad) | feat | add configuration files generation schematic | +| [8d000d156](https://github.com/angular/angular-cli/commit/8d000d1563684f9a9b6869e549e265f0997187c4) | feat | add environments generation schematic | +| [6c39a162b](https://github.com/angular/angular-cli/commit/6c39a162bec67083bf6c11b54e84612f1d68c384) | feat | Add schematics for generating functional router guards and resolvers | +| [62121f89a](https://github.com/angular/angular-cli/commit/62121f89abce54e0a1c2b816cdd32b57f2b5a5d1) | feat | add sideEffects:false to library package.json | +| [9299dea64](https://github.com/angular/angular-cli/commit/9299dea6492527bcaea24c9c7f3116ee2779405b) | feat | generate functional interceptors | +| [49b313f27](https://github.com/angular/angular-cli/commit/49b313f27adef6300063c9d6817d1454a8657fe2) | fix | add missing import for functional interceptor spec | +| [2f92fe7e5](https://github.com/angular/angular-cli/commit/2f92fe7e589705b282102271897454ea852c4814) | fix | add missing semicolon in functional guard/resolver/interceptor | +| [9b6d190f4](https://github.com/angular/angular-cli/commit/9b6d190f4a082c166d253b0f00162e0286238e45) | fix | remove EnvironmentInjector import in functional guard spec | +| [b11d3f644](https://github.com/angular/angular-cli/commit/b11d3f6442d38f609471ab19c08a1c9a871e0ae3) | fix | use proper variable in functional guard spec | +| [451975f76](https://github.com/angular/angular-cli/commit/451975f7650041a83994e1308f85fe7e33a31e32) | fix | use proper variable in resolver functional spec | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [c29df6954](https://github.com/angular/angular-cli/commit/c29df695467c41feccd3846a55c91c6784af87b2) | feat | add `assets` option to server builder | +| [839d0cb57](https://github.com/angular/angular-cli/commit/839d0cb57ad42896578c235354ffb918ea8bb146) | feat | implement stats-json option for esbuild builder | +| [216991b9d](https://github.com/angular/angular-cli/commit/216991b9d9ca1d8f09992880a5fa92e7c98813fa) | feat | support inline component Sass styles with esbuild builder | +| [7c87ce47c](https://github.com/angular/angular-cli/commit/7c87ce47c66a6426b6b7fbb2edd38d8da729221f) | fix | ensure Sass load paths are resolved from workspace root | +| [7a063238b](https://github.com/angular/angular-cli/commit/7a063238b83eea8b5b3237fed12db5528d1f6912) | fix | explicitly send options to JS transformer workers | +| [22cba7937](https://github.com/angular/angular-cli/commit/22cba79370ed60a27f932acda363ffd87f5d9983) | fix | provide an option to `exclude` specs in Karma builder | +| [20376649c](https://github.com/angular/angular-cli/commit/20376649c5e3003b0aa99b9328e2b61699ccba78) | fix | transform async generator class methods for Zone.js support | +| [0520608f6](https://github.com/angular/angular-cli/commit/0520608f68f1768a13a46fbdb9ecb65310492460) | fix | use relative css resource paths in esbuild JSON stats | +| [0c01532cb](https://github.com/angular/angular-cli/commit/0c01532cb5a3072b96cd65845a38b88ed4543de6) | perf | use worker pool for JavaScript transforms in esbuild builder | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [207358afb](https://github.com/angular/angular-cli/commit/207358afb89e6515cb8d73f5a3a63d9101e80d97) | feat | add `runSchematic` and `runExternalSchematic` methods | + +## Special Thanks + +Alan Agius, Andrew Scott, Charles Lyding, Cédric Exbrayat, Doug Parker, Felix Hamann, Jason Bedard, Joey Perrott and Kristiyan Kostadinov + + + + + +# 15.0.5 (2023-01-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [c2030dec7](https://github.com/angular/angular-cli/commit/c2030dec7d9fecf42cca2de37cc3f7adaaa45e7f) | fix | format esbuild error messages to include more information | + +## Special Thanks + +Alan Agius, Kristiyan Kostadinov, Paul Gschwendtner and aanchal + + + + + +# 15.0.4 (2022-12-14) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [ccc8e0350](https://github.com/angular/angular-cli/commit/ccc8e0350810d123269f55de29acd7964e663f7e) | fix | display actionable error when a style does not exist in Karma builder | +| [507f756c3](https://github.com/angular/angular-cli/commit/507f756c34171db842365398150460e1e29f531a) | fix | downlevel class private methods when targeting Safari <=v15 | +| [a0da91dba](https://github.com/angular/angular-cli/commit/a0da91dba3d9b4c4a86102668f52ab933406e5da) | fix | include sources in generated | +| [9fd356234](https://github.com/angular/angular-cli/commit/9fd356234210734ec5f44ae18f055308b7acc963) | fix | only set ngDevMode when script optimizations are enabled | +| [8e85f4728](https://github.com/angular/angular-cli/commit/8e85f47284472f9df49f2ca6c59057ad28240e9c) | fix | update `css-loader` to `6.7.3` | +| [b2d4415ca](https://github.com/angular/angular-cli/commit/b2d4415caa486bebe55e6147a153f120cf08b070) | fix | update locale setting snippet to use `globalThis`. | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 15.0.3 (2022-12-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [3d9971edb](https://github.com/angular/angular-cli/commit/3d9971edb05e9b8de24bafc1b4381cbf4bad8dbf) | fix | default preserve symlinks to Node.js value for esbuild | +| [24f4b51d2](https://github.com/angular/angular-cli/commit/24f4b51d22a0debc8ff853cf9040a15273654f7a) | fix | downlevel class fields with Safari <= v15 for esbuild | +| [45afc42db](https://github.com/angular/angular-cli/commit/45afc42db86e58357d1618d9984dcf03bffea957) | fix | downlevel class properties when targeting Safari <=v15 | +| [e6461badf](https://github.com/angular/angular-cli/commit/e6461badf7959ff8b8d9a3824a4a081f44e0b237) | fix | prevent optimization adding unsupported ECMASCript features | + +## Special Thanks + +Charles Lyding, Dominic Elm and Paul Gschwendtner + + + + + +# 15.0.2 (2022-11-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [2891d5bc9](https://github.com/angular/angular-cli/commit/2891d5bc9eecf7fa8e3b80906d9c56e6a49f3d15) | fix | correctly set Sass quietDeps and verbose options | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [d9cc4b028](https://github.com/angular/angular-cli/commit/d9cc4b0289eaf382782a994a15497e9526c5a4a2) | fix | elide unused type references | + +## Special Thanks + +Alan Agius and Juuso Valkeejärvi + + + + + +# 15.0.1 (2022-11-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [eda96def4](https://github.com/angular/angular-cli/commit/eda96def48e11533cd0a3353c96b7eac9a881e1e) | fix | use global version of the CLI when running `ng new` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [48426852b](https://github.com/angular/angular-cli/commit/48426852b0c1d5541a3e7369dc2b343e33856968) | fix | show warning when a TS Config is not found during migrations | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [2af32fd3a](https://github.com/angular/angular-cli/commit/2af32fd3a981b1c29e1cf77b442982e1e07aae38) | fix | hide loader paths in webpack warnings | +| [19f5cc746](https://github.com/angular/angular-cli/commit/19f5cc746ec724f15d1b89126c7c1b8a343818fe) | fix | improve package deep import Sass index resolution in esbuild plugin | +| [2220a907d](https://github.com/angular/angular-cli/commit/2220a907daf9ccd9e22dfc8e5ddc259b9d495997) | fix | use url function lexer to rebase Sass URLs | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Joey Perrott and Piotr Wysocki + + + + + +# 15.0.0 (2022-11-16) + +## Breaking Changes + +### @angular/cli + +- The Angular CLI no longer supports `16.10.x`, `16.11.x` and `16.12.x`. Current minimum versions of Node.js are `14.20.0`, `16.13.0` and `18.10.0`. +- Node.js versions older than 14.20 are no longer supported. +- The 'path' option in schematics schema no longer has a special meaning. Use 'workingDirectory' smart default provider should be used instead. + +### @schematics/angular + +- Removed unused `appDir` option from Universal and App-Shell schematic. This option can safely be removed if present since it no longer has effect. + +### + +- `analyticsSharing` option in the global angular configuration has been + removed without replacement. This option was used to configure the Angular CLI to access to your own users' CLI usage data. + + If this option is used, it can be removed using `ng config --global cli.analyticsSharing undefined`. + +- analytics APIs have been removed without replacement from `@angular-devkit/core` and `@angular-devkit/architect`. + +### @angular-devkit/build-angular + +- TypeScript versions older than 4.8.2 are no longer supported. +- The server builder `bundleDependencies` option has been removed. This option was used pre Ivy. Currently, using this option is unlikely to produce working server bundles. + + The `externalDependencies` option can be used instead to exclude specific node_module packages from the final bundle. + +- - Deprecated support for tilde import has been removed. Please update the imports by removing the `~`. + + Before + + ```scss + @import '~font-awesome/scss/font-awesome'; + ``` + + After + + ```scss + @import 'font-awesome/scss/font-awesome'; + ``` + + - By default the CLI will use Sass modern API, While not recommended, users can still opt to use legacy API by setting `NG_BUILD_LEGACY_SASS=1`. + +- Internally the Angular CLI now always set the TypeScript `target` to `ES2022` and `useDefineForClassFields` to `false` unless the target is set to `ES2022` or later in the TypeScript configuration. To control ECMA version and features use the Browerslist configuration. +- `require.context` are no longer parsed. Webpack specific features are not supported nor guaranteed to work in the future. +- Producing ES5 output is no longer possible. This was needed for Internet Explorer which is no longer supported. All browsers that Angular supports work with ES2015+ +- server builder `bundleDependencies` option now only accept a boolean value. +- Deprecated support for Stylus has been removed. The Stylus package has never reached a stable version and its usage in the Angular CLI is minimal. It's recommended to migrate to another CSS preprocessor that the Angular CLI supports. + +### @angular-devkit/core + +- Workspace projects with missing `root` is now an error. + +### @ngtools/webpack + +- TypeScript versions older than 4.8.2 are no longer supported. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [766d4a089](https://github.com/angular/angular-cli/commit/766d4a0895e7895211e93bc73ff131c6e47613a7) | feat | add migration to remove require calls from karma builder main file | +| [d8bff4f1e](https://github.com/angular/angular-cli/commit/d8bff4f1e68a76da1983f9d0774f415e73dfd8c3) | feat | Added --project-root option to the library schematics | +| [597bfea1b](https://github.com/angular/angular-cli/commit/597bfea1b29cc7b25d1f466eb313cbeeb6dffc98) | feat | drop `polyfills.ts` file from new templates | +| [1c21e470c](https://github.com/angular/angular-cli/commit/1c21e470c76d69d08e5096b46b952dbce330f7ef) | feat | enable error on unknown properties and elements in tests | +| [f2a0682dc](https://github.com/angular/angular-cli/commit/f2a0682dc82afa23a3d3481df59e4aaca5e90c78) | feat | generate new projects using TypeScript 4.8.2 | +| [b06421d15](https://github.com/angular/angular-cli/commit/b06421d15e4b5e6daffcb73ee1c2c8703b72cb47) | feat | mark `projectRoot` as non hidden option in application schematic | +| [b6897dbb0](https://github.com/angular/angular-cli/commit/b6897dbb0a1ef287644e117251c1c76cc8afcae0) | feat | remove `karma.conf.js` from newly generated projects | +| [301b5669a](https://github.com/angular/angular-cli/commit/301b5669a724261d53444d5172334966903078c0) | feat | remove `ngOnInit` from component template | +| [9beb878e2](https://github.com/angular/angular-cli/commit/9beb878e2eecd32e499c8af557f22f46548248fc) | feat | remove Browserslist configuration files from projects | +| [283b564d1](https://github.com/angular/angular-cli/commit/283b564d1de985f0af8c2fcb6192801a90baacda) | feat | remove environment files in new applications | +| [56a1e8f9f](https://github.com/angular/angular-cli/commit/56a1e8f9f52658488afb9d36007e96c96d08a03b) | feat | remove test.ts file from new projects | +| [4e69e8050](https://github.com/angular/angular-cli/commit/4e69e80501dd2a9394b7df4518e0d6b0f2ebb7d9) | fix | add `@angular/localize` as type when localize package is installed | +| [57d93fb7d](https://github.com/angular/angular-cli/commit/57d93fb7d979e68c2a4e6f6046ff633f69098afe) | fix | mark project as required option | +| [84e3f7727](https://github.com/angular/angular-cli/commit/84e3f7727dc1de31484704c7c06d51ff5392a34a) | fix | remove empty lines | +| [316a50d75](https://github.com/angular/angular-cli/commit/316a50d75e45962ea3efe4108aa48d9479245dd5) | fix | remove TypeScript target from universal schematic | +| [69b221498](https://github.com/angular/angular-cli/commit/69b2214987c8fad6efd091782cf28b20be62d244) | refactor | remove deprecated appDir option | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | +| [4827d1b23](https://github.com/angular/angular-cli/commit/4827d1b23e564e4e4a8684c5e8ff035d8fa855a2) | feat | add support for Node.js version 18 | +| [4b623461a](https://github.com/angular/angular-cli/commit/4b623461a4a938ba320b5e019f9c715d634a46c4) | feat | drop support for Node.js versions older than 14.20 | +| [3dea1fa71](https://github.com/angular/angular-cli/commit/3dea1fa7173e846aff5b0d15b919d9786bbf7198) | fix | add unique user id as user parameter in GA | +| [af07aa340](https://github.com/angular/angular-cli/commit/af07aa340a1c3c9f3d42446981be59a73effa498) | fix | add workspace information as part of analytics collection | +| [83524f625](https://github.com/angular/angular-cli/commit/83524f62533f9a6bda0c1dbc76c6b16e730a7397) | fix | allow `ng add` to find prerelease versions when CLI is prerelease | +| [22955f245](https://github.com/angular/angular-cli/commit/22955f24592df8044dbdeeb8e635beb1cc770c75) | fix | do not collect analytics when running in non TTY mode | +| [35e5f4278](https://github.com/angular/angular-cli/commit/35e5f4278145b7ef55a75f1692c8e92d6bcd59db) | fix | exclude `@angular/localize@<10.0.0` from ng add pa… ([#24152](https://github.com/angular/angular-cli/pull/24152)) | +| [1a584364e](https://github.com/angular/angular-cli/commit/1a584364e70cafd84770ef45f3da9ad58a46083f) | fix | exclude `@angular/material@7.x` from ng add package discovery | +| [ff0382718](https://github.com/angular/angular-cli/commit/ff0382718af60923fe71f8b224d36a50449484e6) | fix | respect registry in RC when running update through yarn | +| [774d349b7](https://github.com/angular/angular-cli/commit/774d349b73a436a99f2ea932b7509dab7c1d5e45) | refactor | remove deprecated path handler | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------- | +| [639a3071c](https://github.com/angular/angular-cli/commit/639a3071c3630c1ccdf7e3c015e81e9423ab2678) | refactor | migrate analytics collector to use GA4 | +| [c969152de](https://github.com/angular/angular-cli/commit/c969152de630a9afdef44ba2342e728b9353c8e7) | refactor | remove analytics API from core and architect | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------- | +| [4ead45cab](https://github.com/angular/angular-cli/commit/4ead45caba08cb0b67dc7df2f6a9b304c75fff7d) | feat | add `ng-server-context` when using app-shell builder | +| [1c527a9da](https://github.com/angular/angular-cli/commit/1c527a9da5b55a8421ebca787fd322e879f6d29d) | feat | add esbuild-based builder initial support for fileReplacements | +| [67324b3e5](https://github.com/angular/angular-cli/commit/67324b3e5861510b1df9641bb4b10bb67e3a2325) | feat | add initial incremental code rebuilding to esbuild builder | +| [3d94ca21b](https://github.com/angular/angular-cli/commit/3d94ca21bbb7496a2ff588166fd93c5f2339b823) | feat | add initial watch support to esbuild-based builder | +| [c592ec584](https://github.com/angular/angular-cli/commit/c592ec584f1c0b126a2045e5ea1b01cb1569ce4d) | feat | amend `polyfills` option in all builders to support an array of module specifiers | +| [a95d130ef](https://github.com/angular/angular-cli/commit/a95d130ef4249457ed2433d52eb43c94a1169782) | feat | auto include `@angular/localize/init` when found in `types` | +| [979bce45e](https://github.com/angular/angular-cli/commit/979bce45e63eda9ac5402869ef3dc4c63aaca3f1) | feat | auto include `@angular/platform-server/init` during server builds | +| [fd4175357](https://github.com/angular/angular-cli/commit/fd41753579affa78328bfc4b6108db15ff5053f9) | feat | drop support for TypeScript 4.6 and 4.7 | +| [15d3fc6dc](https://github.com/angular/angular-cli/commit/15d3fc6dc3f74462818b3745f6fb4995212a4d22) | feat | export `@angular/platform-server` symbols in server bundle | +| [05a98c029](https://github.com/angular/angular-cli/commit/05a98c02924f656be3257d5f459ae88c1ae29fba) | feat | karma builder `main` option is now optional | +| [2b6029245](https://github.com/angular/angular-cli/commit/2b602924538bf987e92f806c25c2a3d008a3f0a9) | feat | providing a karma config is now optional | +| [9c13fce16](https://github.com/angular/angular-cli/commit/9c13fce162eff8d01d1fa6a7f0e0029da2887c86) | feat | remove `bundleDependencies` from server builder | +| [308e3a017](https://github.com/angular/angular-cli/commit/308e3a017f876bfc727e68803bfbce11e9d3396e) | feat | switch to use Sass modern API | +| [1e5d4a750](https://github.com/angular/angular-cli/commit/1e5d4a75084dfd2aeebb6a0c0b3039417e14bc84) | feat | use Browserslist to determine ECMA output | +| [3ff391738](https://github.com/angular/angular-cli/commit/3ff39173808f2beed97ee5deb91be541205f9a03) | fix | account for package.json exports fields with CSS import statements | +| [001445982](https://github.com/angular/angular-cli/commit/0014459820dc1c127e93993414c154947a7f8da6) | fix | account for package.json exports with Sass in esbuild builder | +| [6280741ce](https://github.com/angular/angular-cli/commit/6280741ce4a89882595c834f48a45cca6f9534e0) | fix | add `@angular/platform-server` as an optional peer dependency | +| [f9a2c3a12](https://github.com/angular/angular-cli/commit/f9a2c3a1216cf9510e122df44a64ddd11d47226b) | fix | allow both script and module sourceTypes to be localized | +| [4cb27b803](https://github.com/angular/angular-cli/commit/4cb27b8031d0f36e687c5116538ebe473acaa149) | fix | avoid attempted resolve of external CSS URLs with esbuild builder | +| [192e0e6d7](https://github.com/angular/angular-cli/commit/192e0e6d77d4f0f20af3f88b653c5196a2c1e052) | fix | correct escaping of target warning text in esbuild builder | +| [4fcb0a82b](https://github.com/angular/angular-cli/commit/4fcb0a82b5fa8a092d8c374cdea448edd80270d4) | fix | correctly resolve Sass partial files in node packages | +| [fb5a66ae6](https://github.com/angular/angular-cli/commit/fb5a66ae66b595602d2a8aea8e938efe5df6d13c) | fix | fix crash when Sass error occurs | +| [b6df9c136](https://github.com/angular/angular-cli/commit/b6df9c1367ae5795a3895628ec9822d432b315bb) | fix | handle conditional exports in `scripts` and `styles` option | +| [0ee7625d6](https://github.com/angular/angular-cli/commit/0ee7625d6b4bd84be6fca0df82f3e74e4b94728c) | fix | ignore cache path when watching with esbuild builder | +| [e34bfe5eb](https://github.com/angular/angular-cli/commit/e34bfe5eb1a559cbf53449ce213503e32fa27ae4) | fix | ignore specs in node_modules when finding specs | +| [f143171fd](https://github.com/angular/angular-cli/commit/f143171fd030fa1cc8df84ed5f0b96f5ad0f9e10) | fix | only add `@angular/platform-server/init` when package is installed. | +| [3a1970b76](https://github.com/angular/angular-cli/commit/3a1970b76e4da7424e2661664a1e9e669bd279b4) | fix | only import karma when running karma builder | +| [8b84c18ed](https://github.com/angular/angular-cli/commit/8b84c18edd01e91c7ebf4327dde8ce60f7f700ca) | fix | provide workaround for V8 object spread performance defect | +| [7dd122ad5](https://github.com/angular/angular-cli/commit/7dd122ad5f34a488f3784326b579b8a93511af7e) | fix | rebase Sass url() values when using esbuild-based builder | +| [2105964af](https://github.com/angular/angular-cli/commit/2105964afc0285cc40c16d32c47d1eb60be5e279) | fix | resolve transitive dependencies in Sass when using Yarn PNP | +| [54e1c01d8](https://github.com/angular/angular-cli/commit/54e1c01d8b608ff240f7559ca176cd50e991952c) | fix | show file replacement in TS missing file error in esbuild builder | +| [6c3f281d9](https://github.com/angular/angular-cli/commit/6c3f281d927c9ae2d4ec76ff9f920752e2cb73d1) | fix | show warning when using TypeScript target older then ES2022 in esbuild builder | +| [8f8e02c32](https://github.com/angular/angular-cli/commit/8f8e02c3221c9477ec931bb6983daf6a2c8dc8be) | fix | support Yarn PNP resolution in modern SASS API | +| [fc82e3bec](https://github.com/angular/angular-cli/commit/fc82e3bec3f188d449e952d9955b845b2efdcd6b) | fix | update browerslist package | +| [0d62157a3](https://github.com/angular/angular-cli/commit/0d62157a30a246c1e00273c2300b9251574e75ae) | fix | update sourcemaps when rebasing Sass url() functions in esbuild builder | +| [1518133db](https://github.com/angular/angular-cli/commit/1518133db3b1c710500786f9f1fcfa05a016862e) | fix | use relative sourcemap source paths for Sass in esbuild builder | +| [fb4ead2ce](https://github.com/angular/angular-cli/commit/fb4ead2ce0de824eef46ce8e27a8f6cc1d08c744) | fix | wait during file watching to improve multi-save rebuilds for esbuild builder | +| [b059fc735](https://github.com/angular/angular-cli/commit/b059fc73597c12330a96fca5f6ab9b1ca226136c) | fix | warn when components styles sourcemaps are not generated when styles optimization is enabled | +| [9d0872fb5](https://github.com/angular/angular-cli/commit/9d0872fb5e369f714633387d9ae39c4242ba1ea1) | perf | add initial global styles incremental rebuilds with esbuild builder | +| [0fe6b3b75](https://github.com/angular/angular-cli/commit/0fe6b3b75b87f6f8050b196615e1c1543b707841) | perf | add vendor chunking to server builder | +| [8c915d414](https://github.com/angular/angular-cli/commit/8c915d41496c99fb42ae3992d9c91de542260bf2) | perf | avoid extra babel file reads in esbuild builder rebuilds | +| [919fe2148](https://github.com/angular/angular-cli/commit/919fe2148885c44655ce36085768b1eab2c8c246) | perf | avoid extra TypeScript emits with esbuild rebuilds | +| [92145c4a7](https://github.com/angular/angular-cli/commit/92145c4a7d2c835b703319676bafd8ea3b4a19f0) | perf | avoid template diagnostics for declaration files in esbuild builder | +| [52db3c000](https://github.com/angular/angular-cli/commit/52db3c00076dfe118cd39d7724229210c30665e0) | perf | minimize Angular diagnostics incremental analysis in esbuild-based builder | +| [feb06753d](https://github.com/angular/angular-cli/commit/feb06753d59f782c6ad8fd59a60537863094f498) | perf | use esbuild-based builder to directly downlevel for await...of | +| [9d83fb91b](https://github.com/angular/angular-cli/commit/9d83fb91b654eed79a5c9c9691d0f1c094f37771) | perf | use Sass worker pool for Sass support in esbuild builder | +| [45a94228f](https://github.com/angular/angular-cli/commit/45a94228fb23acbd0d1a9329448f07b759c8654b) | perf | use Uint8Arrays for incremental caching with esbuild-based builder | +| [f393b0928](https://github.com/angular/angular-cli/commit/f393b09282582da47db683344e037fd1434b32a8) | refactor | disable `requireContext` parsing | +| [12931ba8c](https://github.com/angular/angular-cli/commit/12931ba8c3772b1dd65846cbd6146804b08eab31) | refactor | remove deprecated ES5 support | +| [7f1017e60](https://github.com/angular/angular-cli/commit/7f1017e60f82389568065478d666ae4be6ebfea2) | refactor | remove old `bundleDependencies` enum logic | +| [2ba44a433](https://github.com/angular/angular-cli/commit/2ba44a433c827413a53d12de0ef203f8988ddc2a) | refactor | remove support for Stylus | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [ea4c0aa2e](https://github.com/angular/angular-cli/commit/ea4c0aa2e84d48be37b75e37c99ad381122297c3) | fix | throw error when project has missing root property | +| [de467f46d](https://github.com/angular/angular-cli/commit/de467f46de63059f9c701dfe8695513c742f22b5) | fix | update logger `forEach` `promiseCtor` type | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [9b07b469b](https://github.com/angular/angular-cli/commit/9b07b469b622e083a9915ed3c24e1d53d8abf38f) | refactor | remove `UpdateBuffer` and rename `UpdateBuffer2` to `UpdateBuffer` | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [43bd0abc1](https://github.com/angular/angular-cli/commit/43bd0abc147cf3177e707624bf6163b3dc9e06f8) | feat | drop support for TypeScript 4.6 and 4.7 | +| [1c1f985b9](https://github.com/angular/angular-cli/commit/1c1f985b9c9913f28915f101ee1717c0da540362) | fix | support inline style sourcemaps when using css-loader for component styles | + +## Special Thanks + +Alan Agius, Brent Schmidt, Charles Lyding, Cédric Exbrayat, Dariusz Ostolski, Doug Parker, Günhan Gülsoy, Jason Bedard, Lukas Spirig, Ruslan Lekhman, angular-robot[bot] and minijus + + + + + +# 14.2.10 (2022-11-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------- | +| [9ce386caf](https://github.com/angular/angular-cli/commit/9ce386caf6037f21f422a785fec977634406d208) | fix | exclude `@angular/localize@<10.0.0` from ng add pa… ([#24152](https://github.com/angular/angular-cli/pull/24152)) | +| [6446091a3](https://github.com/angular/angular-cli/commit/6446091a310f327ceeb68ae85f3673f6e3e83286) | fix | exclude `@angular/material@7.x` from ng add package discovery | +| [7541e04f3](https://github.com/angular/angular-cli/commit/7541e04f36ff32118e93588be38dcbb5cc2c92a9) | fix | respect registry in RC when running update through yarn | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [21cea0b42](https://github.com/angular/angular-cli/commit/21cea0b42f08bf56990bdade82e2daa7c33011ed) | fix | update `loader-utils` to `3.2.1` | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 13.3.10 (2022-11-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [f298ebbd5](https://github.com/angular/angular-cli/commit/f298ebbd5f86077985d994662314379df92b6771) | fix | update `loader-utils` to `3.2.1` | + +## Special Thanks + +Alan Agius + + + + + +# 14.2.9 (2022-11-09) + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [e3e787767](https://github.com/angular/angular-cli/commit/e3e78776782da9d933f7b0e4c6bf391a62585bee) | fix | default to failure if no builder result is provided | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [12b2dc5a2](https://github.com/angular/angular-cli/commit/12b2dc5a2374f992df151af32cc80e2c2d7c4dee) | fix | isolate zone.js usage when rendering server bundles | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.2.8 (2022-11-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [4b0ee8ad1](https://github.com/angular/angular-cli/commit/4b0ee8ad15efcb513ab5d9e38bf9b1e08857e798) | fix | guard schematics should include all guards (CanMatch) | + +## Special Thanks + +Andrew Scott + + + + + +# 14.2.7 (2022-10-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [91b5bcbb3](https://github.com/angular/angular-cli/commit/91b5bcbb31715a3c2e183e264ebd5ec1188d5437) | fix | disable version check during auto completion | +| [02a3d7b71](https://github.com/angular/angular-cli/commit/02a3d7b715f4069650389ba26a3601747e67d9c2) | fix | skip node.js compatibility checks when running completion | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [bebed9df8](https://github.com/angular/angular-cli/commit/bebed9df834d01f72753aa0e60dc104f1781bd67) | fix | issue dev-server support warning when using esbuild builder | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.2.6 (2022-10-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [1c9cf594f](https://github.com/angular/angular-cli/commit/1c9cf594f7a855ea4b462fad53acd3bf3a2e7622) | fix | handle missing `which` binary in path | +| [28b2cd18e](https://github.com/angular/angular-cli/commit/28b2cd18e3c490cf2db64d4a6744bbd26c0aeabb) | fix | skip downloading temp CLI when running `ng update` without package names | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [ad6928184](https://github.com/angular/angular-cli/commit/ad692818413a97afe54aee6a39f0447ee9239343) | fix | project extension warning message should identify concerned project | + +## Special Thanks + +AgentEnder and Alan Agius + + + + + +# 14.2.5 (2022-10-05) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [17eb20c77](https://github.com/angular/angular-cli/commit/17eb20c77098841d45f0444f5f047c4d44fc614f) | fix | throw more relevant error when Rule returns invalid null value | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.2.4 (2022-09-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [05b18f4e4](https://github.com/angular/angular-cli/commit/05b18f4e4b39d73c8a3532507c4b7bba8722bf80) | fix | add builders and schematic names as page titles in collected analytics | + +## Special Thanks + +Alan Agius, Jason Bedard and Paul Gschwendtner + + + + + +# 14.2.3 (2022-09-15) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [e7e0cb78f](https://github.com/angular/angular-cli/commit/e7e0cb78f4c6d684fdf25e23a11599b82807cd25) | fix | correctly display error messages that contain "at" text. | +| [4756d7e06](https://github.com/angular/angular-cli/commit/4756d7e0675aa9a8bed11b830b66288141fa6e16) | fix | watch symbolic links | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [1e3ecbdb1](https://github.com/angular/angular-cli/commit/1e3ecbdb138861eff550e05d9662a10d106c0990) | perf | avoid bootstrap conversion AST traversal where possible | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Joey Perrott + + + + + +# 14.2.2 (2022-09-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [5405a9b3b](https://github.com/angular/angular-cli/commit/5405a9b3b56675dc671e1ef27410e632f3f6f536) | fix | favor non deprecated packages during update | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [6bfd6a7fb](https://github.com/angular/angular-cli/commit/6bfd6a7fbcaf433bd2c380087803044df4c6d8ee) | fix | update minimum Angular version to 14.2 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [2b00bca61](https://github.com/angular/angular-cli/commit/2b00bca615a2c79b0a0311c83cb9f1450b6f1745) | fix | allow esbuild-based builder to use SVG Angular templates | +| [45c95e1bf](https://github.com/angular/angular-cli/commit/45c95e1bf1327532ceeb1277fa6f4ce7c3a45581) | fix | change service worker errors to compilation errors | +| [ecc014d66](https://github.com/angular/angular-cli/commit/ecc014d669efe9609177354c465f24a1c94279cd) | fix | handle service-worker serving with localize in dev-server | +| [39ea128c1](https://github.com/angular/angular-cli/commit/39ea128c1294046525a8c098ed6a776407990365) | fix | handling of `@media` queries inside css layers | +| [17b7e1bdf](https://github.com/angular/angular-cli/commit/17b7e1bdfce5823718d1fa915d25858f4b0d7110) | fix | issue warning when using deprecated tilde imports | +| [3afd784f1](https://github.com/angular/angular-cli/commit/3afd784f1f00ee07f68ba112bea7786ccb2d4f35) | fix | watch index file when running build in watch mode | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Joey Perrott + + + + + +# 14.2.1 (2022-08-26) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [e4ca46866](https://github.com/angular/angular-cli/commit/e4ca4686627bd31604cf68bc1d2473337e26864c) | fix | update ng-packagr version to `^14.2.0` | + +## Special Thanks + +Alan Agius + + + + + +# 14.2.0 (2022-08-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [596037010](https://github.com/angular/angular-cli/commit/596037010a8113809657cebc9385d040922e6d86) | fix | add missing space after period in warning text | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [44c25511e](https://github.com/angular/angular-cli/commit/44c25511ea2adbd4fbe82a6122fc00af612be8e8) | feat | add ability to serve service worker when using dev-server | +| [3fb569b5c](https://github.com/angular/angular-cli/commit/3fb569b5c82f22afca4dc59313356f198755827e) | feat | switch to Sass modern API in esbuild builder | +| [5bd03353a](https://github.com/angular/angular-cli/commit/5bd03353ac6bb19c983efb7ff015e7aec3ff61d1) | fix | correct esbuild builder global stylesheet sourcemap URL | +| [c4402b1bd](https://github.com/angular/angular-cli/commit/c4402b1bd32cdb0cdd7aeab14239b57ee700d361) | fix | correctly handle parenthesis in url | +| [50c783307](https://github.com/angular/angular-cli/commit/50c783307eb1253f4f2a87502bd7a19f6a409aeb) | fix | use valid CSS comment for sourcemaps with Sass in esbuild builder | +| [4c251853f](https://github.com/angular/angular-cli/commit/4c251853fbc66c6c9aae171dc75612db31afe2fb) | perf | avoid extra string creation with no sourcemaps for esbuild sass | +| [d97640534](https://github.com/angular/angular-cli/commit/d9764053478620a5f4a3349c377c74415435bcbb) | perf | with esbuild builder only load Sass compiler when needed | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Jason Bedard, Joey Perrott, Kristiyan Kostadinov and angular-robot[bot] + + + + + +# 14.1.3 (2022-08-17) + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [365035cb3](https://github.com/angular/angular-cli/commit/365035cb37c57e07cb96e45a38f266b16b4e2fbf) | fix | update workspace extension warning to use correct phrasing | + +## Special Thanks + +AgentEnder, Alan Agius, Charles Lyding and Jason Bedard + + + + + +# 14.1.2 (2022-08-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [3e19c842c](https://github.com/angular/angular-cli/commit/3e19c842cc2a7f2dc62904f5f88025a4687d378a) | fix | avoid collect stats from chunks with no files | +| [d0a0c597c](https://github.com/angular/angular-cli/commit/d0a0c597cd09b1ce4d7134d3e330982b522f28a9) | fix | correctly handle data URIs with escaped quotes in stylesheets | +| [67b3a086f](https://github.com/angular/angular-cli/commit/67b3a086fe90d1b7e5443e8a9f29b12367dd07e7) | fix | process stylesheet resources from url tokens with esbuild browser builder | +| [e6c45c316](https://github.com/angular/angular-cli/commit/e6c45c316ebcd1b5a16b410a3743088e9e9f789c) | perf | reduce babel transformation in esbuild builder | +| [38b71bcc0](https://github.com/angular/angular-cli/commit/38b71bcc0ddca1a34a5a4480ecd0b170bd1e9620) | perf | use esbuild in esbuild builder to downlevel native async/await | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [dd47a5e8c](https://github.com/angular/angular-cli/commit/dd47a5e8c543cbd3bb37afe5040a72531b028347) | fix | elide type only named imports when using `emitDecoratorMetadata` | + +## Special Thanks + +Alan Agius, Charles Lyding and Jason Bedard + + + + + +# 14.1.1 (2022-08-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [4ee825bac](https://github.com/angular/angular-cli/commit/4ee825baca21c21db844bdf718b6ec29dc6c3d42) | fix | catch clause variable is not an Error instance | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [83dcfb32f](https://github.com/angular/angular-cli/commit/83dcfb32f8ef3334f83bb36a2c3097fe9f8a4e4b) | fix | prevent numbers from class names | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [ef6da4aad](https://github.com/angular/angular-cli/commit/ef6da4aad76ff534d4edb9e73c2d56c53b649b15) | fix | allow the esbuild-based builder to fully resolve global stylesheet packages | +| [eed54b359](https://github.com/angular/angular-cli/commit/eed54b359d2b514156242529ee8a25b51c50dae0) | fix | catch clause variable is not an Error instance | +| [c98471094](https://github.com/angular/angular-cli/commit/c9847109438d33d38a31ded20a1cab2721fc1fbd) | fix | correctly respond to preflight requests | +| [94b444e4c](https://github.com/angular/angular-cli/commit/94b444e4caff4c3092e0291d9109e2abed966656) | fix | correctly set `ngDevMode` in esbuilder | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [44c18082a](https://github.com/angular/angular-cli/commit/44c18082a5963b7f9d0f1577a0975b2f35abe6a2) | fix | `classify` string util should concat string without using a `.` | + +### @angular/create + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [cb0d3fb33](https://github.com/angular/angular-cli/commit/cb0d3fb33f196393761924731c3c3786a3a3493b) | fix | use appropriate package manager to install dependencies | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Paul Gschwendtner + + + + + +# 14.1.0 (2022-07-20) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [3884b8652](https://github.com/angular/angular-cli/commit/3884b865262c1ffa5652ac0f4d67bbf59087f453) | fix | add esbuild browser builder to workspace schema | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [707911d42](https://github.com/angular/angular-cli/commit/707911d423873623d4201d2fbce4a294ab73a135) | feat | support controlling `addDependency` utility rule install behavior | +| [a8fe4fcc3](https://github.com/angular/angular-cli/commit/a8fe4fcc315fd408b5b530a44a02c1655b5450a8) | fix | Allow skipping existing dependencies in E2E schematic | +| [b8bf3b480](https://github.com/angular/angular-cli/commit/b8bf3b480bef752641370e542ebb5aee649a8ac6) | fix | only issue a warning for addDependency existing specifier | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [a7709b718](https://github.com/angular/angular-cli/commit/a7709b718c953d83f3bde00fa3bf896501359946) | feat | add `externalDependencies` to the esbuild browser builder | +| [248860ad6](https://github.com/angular/angular-cli/commit/248860ad674b54f750bb5c197588bb6d031be208) | feat | add Sass file support to experimental esbuild-based builder | +| [b06ae5514](https://github.com/angular/angular-cli/commit/b06ae55140c01f8b5107527fd0af1da3b04a721f) | feat | add service worker support to experimental esbuild builder | +| [b5f6d862b](https://github.com/angular/angular-cli/commit/b5f6d862b95afd0ec42d9b3968e963f59b1b1658) | feat | Identify third-party sources in sourcemaps | +| [b3a14d056](https://github.com/angular/angular-cli/commit/b3a14d05629ba6e3b23c09b1bfdbc4b35d534813) | fix | allow third-party sourcemaps to be ignored in esbuild builder | +| [53dd929e5](https://github.com/angular/angular-cli/commit/53dd929e59f98a7088d150e861d18e97e6de4114) | fix | ensure esbuild builder sourcemap sources are relative to workspace | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [526cdb263](https://github.com/angular/angular-cli/commit/526cdb263a8c74ad228f584f70dc029aa69351d7) | feat | allow `chain` rule to accept iterables of rules | + +### @angular/create + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [cfe93fbc8](https://github.com/angular/angular-cli/commit/cfe93fbc89fad2f58826f0118ce7ff421cd0e4f2) | feat | add support for `yarn create` and `npm init` | + +## Special Thanks + +Alan Agius, Charles Lyding, Derek Cormier, Doug Parker, Jason Bedard, Joey Perrott, Paul Gschwendtner, Victor Porof and renovate[bot] + + + + + +# 14.0.7 (2022-07-20) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [f653bf4fb](https://github.com/angular/angular-cli/commit/f653bf4fbb69b9e0fa0e6440a88a30f17566d9a3) | fix | incorrect logo for Angular Material | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [5810c2cc2](https://github.com/angular/angular-cli/commit/5810c2cc2dd21e5922a5eaa330e854e4327a0500) | fix | fallback to use projectRoot when sourceRoot is missing during coverage | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [2ba4678b6](https://github.com/angular/angular-cli/commit/2ba4678b6ba2164e80cb661758565c133e08afaa) | fix | add i18n as valid project extension | +| [c2201c835](https://github.com/angular/angular-cli/commit/c2201c835801ef9c1cc6cacec2748c8ca341519d) | fix | log name of invalid extension too | + +## Special Thanks + +Alan Agius, Fortunato Ventre, Katerina Skroumpelou and Kristiyan Kostadinov + + + + + +# 13.3.9 (2022-07-20) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [0d62716ae](https://github.com/angular/angular-cli/commit/0d62716ae3753bb463de6b176ae07520ebb24fc9) | fix | update terser to address CVE-2022-25858 | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.0.6 (2022-07-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [178550529](https://github.com/angular/angular-cli/commit/1785505290940dad2ef9a62d4725e0d1b4b486d4) | fix | handle cases when completion is enabled and running in an older CLI workspace | +| [10f24498e](https://github.com/angular/angular-cli/commit/10f24498ec2938487ae80d6ecea584e20b01dcbe) | fix | remove deprecation warning of `no` prefixed schema options | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [dfa6d73c5](https://github.com/angular/angular-cli/commit/dfa6d73c5c45d3c3276fb1fecfb6535362d180c5) | fix | remove browserslist configuration | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| [4d848c4e6](https://github.com/angular/angular-cli/commit/4d848c4e6f6944f32b9ecb2cf2db5c544b3894fe) | fix | generate different content hashes for scripts which are changed during the optimization phase | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [2500f34a4](https://github.com/angular/angular-cli/commit/2500f34a401c2ffb03b1dfa41299d91ddebe787e) | fix | provide actionable warning when a workspace project has missing `root` property | + +## Special Thanks + +Alan Agius and martinfrancois + + + + + +# 14.0.5 (2022-07-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [98a6aad60](https://github.com/angular/angular-cli/commit/98a6aad60276960bd6bcecda73172480e4bdec48) | fix | during an update only use package manager force option with npm 7+ | +| [094aa16aa](https://github.com/angular/angular-cli/commit/094aa16aaf5b148f2ca94cae45e18dbdeaacad9d) | fix | improve error message for project-specific ng commands when run outside of a project | +| [e5e07fff1](https://github.com/angular/angular-cli/commit/e5e07fff1919c46c15d6ce61355e0c63007b7d55) | fix | show deprecated workspace config options in IDE | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [f9f970cab](https://github.com/angular/angular-cli/commit/f9f970cab515a8a1b1fbb56830b03250dd5cccce) | fix | prevent importing `RouterModule` parallel to `RoutingModule` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [aa8ed532f](https://github.com/angular/angular-cli/commit/aa8ed532f816f2fa23b1fe443a216c5d75507432) | fix | disable glob mounting for patterns that start with a forward slash | +| [c76edb8a7](https://github.com/angular/angular-cli/commit/c76edb8a79d1a12376c2a163287251c06e1f0222) | fix | don't override base-href in HTML when it's not set in builder | +| [f64903528](https://github.com/angular/angular-cli/commit/f649035286d640660c3bc808b7297fb60d0888bc) | fix | improve detection of CommonJS dependencies | +| [74dbd5fc2](https://github.com/angular/angular-cli/commit/74dbd5fc273aece097b2b3ee0b28607d24479d8c) | fix | support hidden component stylesheet sourcemaps with esbuild builder | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [7aed97561](https://github.com/angular/angular-cli/commit/7aed97561c2320f92f8af584cc9852d4c8d818b9) | fix | do not run ngcc when `node_modules` does not exist | + +## Special Thanks + +Alan Agius, Charles Lyding, JoostK and Paul Gschwendtner + + + + + +# 14.0.4 (2022-06-29) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [fc72c625b](https://github.com/angular/angular-cli/commit/fc72c625bb7db7b9c8d865086bcff05e2db426ee) | fix | correctly handle `--collection` option in `ng new` | +| [f5badf221](https://github.com/angular/angular-cli/commit/f5badf221d2a2f5357f93bf0e32146669f8bbede) | fix | improve global schema validation | +| [ed302ea4c](https://github.com/angular/angular-cli/commit/ed302ea4c80b4f6fe8a73c5a0d25055a7dca1db2) | fix | remove color from help epilogue | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [c58c66c0d](https://github.com/angular/angular-cli/commit/c58c66c0d5c76630453151b65b1a1c3707c82e9f) | fix | use `sourceRoot` instead of `src` in universal schematic | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [88acec1fd](https://github.com/angular/angular-cli/commit/88acec1fd302d7d8a053e37ed0334ec6a30c952c) | fix | complete builders on the next event loop iteration | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [694b73dfa](https://github.com/angular/angular-cli/commit/694b73dfa12e5aefff8fc5fdecf220833ac40b42) | fix | exit dev-server when CTRL+C is pressed | +| [6d4782199](https://github.com/angular/angular-cli/commit/6d4782199c4a4e92a9c0b189d6a7857ca631dd3f) | fix | exit localized builds when CTRL+C is pressed | +| [282baffed](https://github.com/angular/angular-cli/commit/282baffed507926e806db673b6804b9299c383af) | fix | hide stacktraces from webpack errors | +| [c4b0abf5b](https://github.com/angular/angular-cli/commit/c4b0abf5b8c1e392ead84c8810e8d6e615fd0024) | fix | set base-href in service worker manifest when using i18n and app-shell | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [33f1cc192](https://github.com/angular/angular-cli/commit/33f1cc192d963b4a4348bb41b8fb0969ffd5c342) | fix | restore process title after NGCC is executed | +| [6796998bf](https://github.com/angular/angular-cli/commit/6796998bf4dd829f9ac085a52ce7e9d2cda73fd1) | fix | show a compilation error on invalid TypeScript version | + +## Special Thanks + +Alan Agius, Charles Lyding and Tim Bowersox + + + + + +# 14.0.3 (2022-06-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [b3db91baf](https://github.com/angular/angular-cli/commit/b3db91baf50c92589549a66ffef437f7890d3de7) | fix | disable version check when running `ng completion` commands | +| [cdab9fa74](https://github.com/angular/angular-cli/commit/cdab9fa7431db7e2a75e04e776555b8e5e15fc94) | fix | provide an actionable error when using `--configuration` with `ng run` | +| [5521648e3](https://github.com/angular/angular-cli/commit/5521648e33af634285f6352b43a324a1ee023e27) | fix | temporarily handle boolean options in schema prefixed with `no` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [5e960ce24](https://github.com/angular/angular-cli/commit/5e960ce246e7090f57ce22723911a743aa8fcb0c) | fix | fix incorrect glob cwd in karma when using `--include` option | +| [1b5e92075](https://github.com/angular/angular-cli/commit/1b5e92075e64563459942d4de785f1a8bef46ec7) | fix | handle `codeCoverageExclude` correctly in Windows | +| [ff6d81a45](https://github.com/angular/angular-cli/commit/ff6d81a4539657446c8f5770cefe688d2d578450) | fix | ignore supported browsers during i18n extraction | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [170c16f2e](https://github.com/angular/angular-cli/commit/170c16f2ea769e76a48f1ac215ee88ba47ff511d) | fix | workspace writer skip creating empty projects property | + +## Special Thanks + +Alan Agius, Charles Lyding and Paul Gschwendtner + + + + + +# 14.0.2 (2022-06-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [23095e9c3](https://github.com/angular/angular-cli/commit/23095e9c3fc514c7e9a892833d8a18270da5bd95) | fix | show more actionable error when command is ran in wrong scope | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [5a486cb64](https://github.com/angular/angular-cli/commit/5a486cb64253ba2829160a6f1fa3bf0e381d45ea) | fix | remove vscode testing configurations for `minimal` workspaces | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [9d88c96d8](https://github.com/angular/angular-cli/commit/9d88c96d898c5c46575a910a7230d239f4fe7a77) | fix | replace fallback locale for `en-US` | + +## Special Thanks + +Alan Agius and Julien Marcou + + + + + +# 13.3.8 (2022-06-15) + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [c7f994f88](https://github.com/angular/angular-cli/commit/c7f994f88a396be96c01da1017a15083d5f544fb) | fix | add peer dependency on Angular CLI | + +## Special Thanks + +Alan Agius + + + + + +# 14.0.1 (2022-06-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------- | +| [e4fb96657](https://github.com/angular/angular-cli/commit/e4fb96657f044d97562008b5b3c6f3a55ac8ba3a) | fix | add text to help output to indicate that additional commands are available when ran in different context | +| [7952e5790](https://github.com/angular/angular-cli/commit/7952e579066f7191f4b82a10816c6a41a4ea5644) | fix | avoid creating unnecessary global configuration | +| [66a1d6b9d](https://github.com/angular/angular-cli/commit/66a1d6b9d2e1fba3d5ee88a6c5d81206f530ce3a) | fix | correct scope cache command | +| [e2d964289](https://github.com/angular/angular-cli/commit/e2d964289fe2a418e5f4e421249e2f8da64185cc) | fix | correctly print package manager name when an install is needed | +| [75fd3330d](https://github.com/angular/angular-cli/commit/75fd3330d4c27263522ea931eb1545ce0a34ab6a) | fix | during an update only use package manager force option with npm 7+ | +| [e223890c1](https://github.com/angular/angular-cli/commit/e223890c1235b4564ec15eb99d71256791a21c3c) | fix | ensure full process exit with older local CLI versions | +| [0cca3638a](https://github.com/angular/angular-cli/commit/0cca3638adb46cd5d0c18b823c83d4b604d7c798) | fix | handle project being passed as a flag | +| [b1451cb5e](https://github.com/angular/angular-cli/commit/b1451cb5e90f43df365202a6fdfcfbc9e0853ca4) | fix | improve resilience of logging during process exit | +| [17fec1357](https://github.com/angular/angular-cli/commit/17fec13577ac333fc66c3752c75be58146c9ebac) | fix | provide actionable error when project cannot be determined | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [73dcf39c6](https://github.com/angular/angular-cli/commit/73dcf39c6e7678a3915a113fd72829549ccc3b8e) | fix | remove strict setting under application project | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [c788d5b56](https://github.com/angular/angular-cli/commit/c788d5b56a1a191e7ca53c3b63245e3979a1cf44) | fix | log modified and removed files when using the `verbose` option | +| [6e8fe0ed5](https://github.com/angular/angular-cli/commit/6e8fe0ed54d88132da0238fdb3a6e97330c85ff7) | fix | replace dev-server socket path from `/ws` to `/ng-cli-ws` | +| [651adadf4](https://github.com/angular/angular-cli/commit/651adadf4df8b66c60771f27737cb2a67957b46a) | fix | update Angular peer dependencies to 14.0 stable | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [cfd264d06](https://github.com/angular/angular-cli/commit/cfd264d061109c7989933e51a14b6bf83b289b07) | fix | add peer dependency on Angular CLI | + +## Special Thanks + +Alan Agius, Charles Lyding and Doug Parker + + + + + +# 14.0.0 (2022-06-02) + +## Breaking Changes + +### @angular/cli + +- Several changes to the `ng analytics` command syntax. + - `ng analytics project ` has been replaced with `ng analytics ` + - `ng analytics ` has been replaced with `ng analytics --global` + +- Support for Node.js v12 has been removed as it will become EOL on 2022-04-30. Please use Node.js v14.15 or later. +- Support for TypeScript 4.4 and 4.5 has been removed. Please update to TypeScript 4.6. +- `--all` option from `ng update` has been removed without replacement. To update packages which don’t provide `ng update` capabilities in your workspace `package.json` use `npm update`, `yarn upgrade-interactive` or `yarn upgrade` instead. +- Deprecated option `--prod` has been removed from all builders. `--configuration production`/`-c production` should be used instead if the default configuration of the builder is not configured to `production`. +- `--configuration` cannot be used with `ng run`. Provide the configuration as part of the target. Ex: `ng run project:builder:configuration`. +- Deprecated `ng x18n` and `ng i18n-extract` commands have been removed in favor of `ng extract-i18n`. +- Several changes in the Angular CLI commands and arguments handling. + - `ng help` has been removed in favour of the `—-help` option. + - `ng —-version` has been removed in favour of `ng version` and `ng v`. + - Deprecated camel cased arguments are no longer supported. Ex. using `—-sourceMap` instead of `—-source-map` will result in an error. + - `ng update`, `—-migrate-only` option no longer accepts a string of migration name, instead use `—-migrate-only -—name `. + - `—-help json` help has been removed. + +### @angular-devkit/architect-cli + +- camel case arguments are no longer allowed. + +### @angular-devkit/schematics-cli + +- camel case arguments are no longer allowed. + +### @angular-devkit/build-angular + +- `browser` and `karma` builders `script` and `styles` options input files extensions are now validated. + + Valid extensions for `scripts` are: + - `.js` + - `.cjs` + - `.mjs` + - `.jsx` + - `.cjsx` + - `.mjsx` + + Valid extensions for `styles` are: + - `.css` + - `.less` + - `.sass` + - `.scss` + - `.styl` + +- We now issue a build time error since importing a CSS file as an ECMA module is non standard Webpack specific feature, which is not supported by the Angular CLI. + + This feature was never truly supported by the Angular CLI, but has as such for visibility. + +- Reflect metadata polyfill is no longer automatically provided in JIT mode + Reflect metadata support is not required by Angular in JIT applications compiled by the CLI. + Applications built in AOT mode did not and will continue to not provide the polyfill. + For the majority of applications, the reflect metadata polyfill removal should have no effect. + However, if an application uses JIT mode and also uses the previously polyfilled reflect metadata JavaScript APIs, the polyfill will need to be manually added to the application after updating. + To replicate the previous behavior, the `core-js` package should be manually installed and the `import 'core-js/proposals/reflect-metadata';` statement should be added to the application's `polyfills.ts` file. +- `NG_BUILD_CACHE` environment variable has been removed. `cli.cache` in the workspace configuration should be used instead. +- The deprecated `showCircularDependencies` browser and server builder option has been removed. The recommended method to detect circular dependencies in project code is to use either a lint rule or other external tools. + +### @angular-devkit/core + +- `parseJson` and `ParseJsonOptions` APIs have been removed in favor of 3rd party JSON parsers such as `jsonc-parser`. +- The below APIs have been removed without replacement. Users should leverage other Node.js or other APIs. + - `fs` namespace + - `clean` + - `mapObject` + +### @angular-devkit/schematics + +- Schematics `NodePackageInstallTask` will not execute package scripts by default + The `NodePackageInstallTask` will now use the package manager's `--ignore-scripts` option by default. + The `--ignore-scripts` option will prevent package scripts from executing automatically during an install. + If a schematic installs packages that need their `install`/`postinstall` scripts to be executed, the + `NodePackageInstallTask` now contains an `allowScripts` boolean option which can be enabled to provide the + previous behavior for that individual task. As with previous behavior, the `allowScripts` option will + prevent the individual task's usage of the `--ignore-scripts` option but will not override the package + manager's existing configuration. +- Deprecated `analytics` property has been removed from `TypedSchematicContext` interface + +### @ngtools/webpack + +- `ivy` namespace has been removed from the public API. + - `ivy.AngularWebpackPlugin` -> `AngularWebpackPlugin` + - `ivy.AngularPluginOptions` -> `AngularPluginOptions` + +## Deprecations + +### @angular/cli + +- The `defaultCollection` workspace option has been deprecated in favor of `schematicCollections`. + + Before + + ```json + "defaultCollection": "@angular/material" + ``` + + After + + ```json + "schematicCollections": ["@angular/material"] + ``` + +- The `defaultProject` workspace option has been deprecated. The project to use will be determined from the current working directory. + +### @angular-devkit/core + +- - `ContentHasMutatedException`, `InvalidUpdateRecordException`, `UnimplementedException` and `MergeConflictException` symbol from `@angular-devkit/core` have been deprecated in favor of the symbol from `@angular-devkit/schematics`. + - `UnsupportedPlatformException` - A custom error exception should be created instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------- | +| [afafa5788](https://github.com/angular/angular-cli/commit/afafa5788f11b8727c39bb0a390300a706aba5bc) | feat | add `--global` option to `ng analytics` command | +| [bb550436a](https://github.com/angular/angular-cli/commit/bb550436a476d74705742a8c36f38971b346b903) | feat | add `ng analytics info` command | +| [e5bf35ea3](https://github.com/angular/angular-cli/commit/e5bf35ea3061a3e532aa85df44551107e62e24c5) | feat | add `ng cache` command | +| [7ab22ed40](https://github.com/angular/angular-cli/commit/7ab22ed40d521e3cec29ab2d66d0289c3cdb4106) | feat | add disable/enable aliases for off/on `ng analytics` command | +| [4212fb8de](https://github.com/angular/angular-cli/commit/4212fb8de2f4f3e80831a0803acc5fc6e54db1e1) | feat | add prompt to set up CLI autocompletion | +| [0316dea67](https://github.com/angular/angular-cli/commit/0316dea676be522b04d654054880cc5794e3c8b3) | feat | add prompts on missing builder targets | +| [607a723f7](https://github.com/angular/angular-cli/commit/607a723f7d623ec8a15054722b2afd13042f66a1) | feat | add support for auto completion | +| [366cabc66](https://github.com/angular/angular-cli/commit/366cabc66c3dd836e2fdfea8dad6c4c7c2096b1d) | feat | add support for multiple schematics collections | +| [036327e9c](https://github.com/angular/angular-cli/commit/036327e9ca838f9ef3f117fbd18949d9d357e68d) | feat | deprecated `defaultProject` option | +| [fb0622893](https://github.com/angular/angular-cli/commit/fb06228932299870774a7b254f022573f5d8175f) | feat | don't prompt to set up autocompletion for `ng update` and `ng completion` commands | +| [4ebfe0341](https://github.com/angular/angular-cli/commit/4ebfe03415ebe4e8f1625286d1be8bd1b54d3862) | feat | drop support for Node.js 12 | +| [022d8c7bb](https://github.com/angular/angular-cli/commit/022d8c7bb142e8b83f9805a39bc1ae312da465eb) | feat | make `ng completion` set up CLI autocompletion by modifying `.bashrc` files | +| [2e15df941](https://github.com/angular/angular-cli/commit/2e15df9417dcc47b12785a8c4c9074bf05d0450c) | feat | remember after prompting users to set up autocompletion and don't prompt again | +| [7fa3e6587](https://github.com/angular/angular-cli/commit/7fa3e6587955d0638929758d3c257392c242c796) | feat | support TypeScript 4.6.2 | +| [9e69331fa](https://github.com/angular/angular-cli/commit/9e69331fa61265c77d6281232bb64a2c63509290) | feat | use PNPM as package manager when `pnpm-lock.yaml` exists | +| [6f6b453fb](https://github.com/angular/angular-cli/commit/6f6b453fbf90adad16eba7ea8929a11235c1061b) | fix | `ng doc` doesn't open browser in Windows | +| [8e66c9188](https://github.com/angular/angular-cli/commit/8e66c9188be827380e5acda93c7e21fae718b9ce) | fix | `ng g` show description from `collection.json` if not present in `schema.json` | +| [9edeb8614](https://github.com/angular/angular-cli/commit/9edeb86146131878c5e8b21b6adaa24a26f12453) | fix | add long description to `ng update` | +| [160cb0718](https://github.com/angular/angular-cli/commit/160cb071870602d9e7fece2ce381facb71e7d762) | fix | correctly handle `--search` option in `ng doc` | +| [d46cf6744](https://github.com/angular/angular-cli/commit/d46cf6744eadb70008df1ef25e24fb1db58bb997) | fix | display option descriptions during auto completion | +| [09f8659ce](https://github.com/angular/angular-cli/commit/09f8659cedcba70903140d0c3eb5d0e10ebb506c) | fix | display package manager during `ng update` | +| [a49cdfbfe](https://github.com/angular/angular-cli/commit/a49cdfbfefbdd756882be96fb61dc8a0d374b6e0) | fix | don't prompt for analytics when running `ng analytics` | +| [4b22593c4](https://github.com/angular/angular-cli/commit/4b22593c4a269ea4bd63cef39009aad69f159fa1) | fix | ensure all available package migrations are executed | +| [054ae02c2](https://github.com/angular/angular-cli/commit/054ae02c2fb8eed52af76cf39a432a3770d301e4) | fix | favor project in cwd when running architect commands | +| [ff4eba3d4](https://github.com/angular/angular-cli/commit/ff4eba3d4a9417d2baef70aaa953bdef4bb426a6) | fix | handle duplicate arguments | +| [5a8bdeb43](https://github.com/angular/angular-cli/commit/5a8bdeb434c7561334bfc8865ed279110a44bd93) | fix | hide private schematics from `ng g` help output | +| [644f86d55](https://github.com/angular/angular-cli/commit/644f86d55b75a289e641ba280e8456be82383b06) | fix | improve error message for Windows autocompletion use cases | +| [3012036e8](https://github.com/angular/angular-cli/commit/3012036e81fc6e5fc6c0f1df7ec626f91285673e) | fix | populate path with working directory in nested schematics | +| [8a396de6a](https://github.com/angular/angular-cli/commit/8a396de6a8a58347d2201a43d7f5101f94f20e89) | fix | print entire config when no positional args are provided to `ng config` | +| [bdf2b9bfa](https://github.com/angular/angular-cli/commit/bdf2b9bfa9893a940ba254073d024172e0dc1abc) | fix | print schematic errors correctly | +| [efc3c3225](https://github.com/angular/angular-cli/commit/efc3c32257a65caf36999dc34cadc41eedcbf323) | fix | remove analytics prompt postinstall script | +| [bf15b202b](https://github.com/angular/angular-cli/commit/bf15b202bb1cd073fe01cf387dce2c033b5bb14c) | fix | remove cache path from global valid paths | +| [142da460b](https://github.com/angular/angular-cli/commit/142da460b22e07a5a37b6140b50663446c3a2dbf) | fix | remove incorrect warning during `ng update` | +| [96a0d92da](https://github.com/angular/angular-cli/commit/96a0d92da2903edfb3835ce86b3700629d6e43ad) | fix | remove JSON serialized description from help output | +| [78460e995](https://github.com/angular/angular-cli/commit/78460e995a192336db3c4be9d0592b4e7a2ff2c8) | fix | remove type casting and add optional chaining for current in optionTransforms | +| [e5bdadac4](https://github.com/angular/angular-cli/commit/e5bdadac44ac023363bc0a2473892fc17430b81f) | fix | skip prompt or warn when setting up autocompletion without a global CLI install | +| [ca401255f](https://github.com/angular/angular-cli/commit/ca401255f49568cfe5f9ec6a35ea5b91c91afa70) | fix | sort commands in help output | +| [b97772dfc](https://github.com/angular/angular-cli/commit/b97772dfc03401fe1faa79e77742905341bd5d46) | fix | support silent package installs with Yarn 2+ | +| [87cd5cd43](https://github.com/angular/angular-cli/commit/87cd5cd4311e71a15ea1ecb82dde7480036cb815) | fix | workaround npm 7+ peer dependency resolve errors during updates | +| [d94a67353](https://github.com/angular/angular-cli/commit/d94a67353dcdaa30cf5487744a7ef151a6268f2d) | refactor | remove deprecated `--all` option from `ng update` | +| [2fc7c73d7](https://github.com/angular/angular-cli/commit/2fc7c73d7e40dbb0a593df61eeba17c8a8f618a9) | refactor | remove deprecated `--prod` flag | +| [b69ca3a7d](https://github.com/angular/angular-cli/commit/b69ca3a7d22b54fc06fbc1cfb559b2fd915f5609) | refactor | remove deprecated command aliases for `extract-i18n`. | +| [2e0493130](https://github.com/angular/angular-cli/commit/2e0493130acfe7244f7ee3ef28c961b1b04d7722) | refactor | replace command line arguments parser | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [7b78b7840](https://github.com/angular/angular-cli/commit/7b78b7840e95b0f4dca2fcb9218b67dd7500ff2c) | feat | add --standalone to ng generate | +| [e49220fba](https://github.com/angular/angular-cli/commit/e49220fba0d158be0971989e26eb199ec02fa113) | feat | add migratiom to remove `defaultProject` in workspace config | +| [3fa38b08b](https://github.com/angular/angular-cli/commit/3fa38b08ba8ef57a6079873223a7d6088d5ea64e) | feat | introduce `addDependency` rule to utilities | +| [b07ccfbb1](https://github.com/angular/angular-cli/commit/b07ccfbb1b2045d285c23dd4b654e1380892fcb2) | feat | introduce a utility subpath export for Angular rules and utilities | +| [7e7de6858](https://github.com/angular/angular-cli/commit/7e7de6858dd71bd461ceb0f89e29e2c57099bbcc) | feat | update Angular dependencies to use `^` as version prefix | +| [69ecddaa7](https://github.com/angular/angular-cli/commit/69ecddaa7d8b01aa7a9e61c403a4b9a8669e34c4) | feat | update new and existing projects compilation target to `ES2020` | +| [7e8e42063](https://github.com/angular/angular-cli/commit/7e8e42063f354c402d758f10c8ba9bee7e0c8aff) | fix | add migration to remove `package.json` in libraries secondary entrypoints | +| [b928d973e](https://github.com/angular/angular-cli/commit/b928d973e97f33220afe16549b41c4031feb5c5e) | fix | alphabetically order imports during component generation | +| [09a71bab6](https://github.com/angular/angular-cli/commit/09a71bab6044e517319f061dbd4555ce57fe6485) | fix | Consolidated setup with a single `beforeEach()` | +| [1921b07ee](https://github.com/angular/angular-cli/commit/1921b07eeb710875825dc6f7a4452bd5462e6ba7) | fix | don't add path mapping to old entrypoint definition file | +| [c927c038b](https://github.com/angular/angular-cli/commit/c927c038ba356732327a026fe9a4c36ed23c9dec) | fix | remove `@types/node` from new projects | +| [27cb29438](https://github.com/angular/angular-cli/commit/27cb29438aa01b185b2dca3617100d87f45f14e8) | fix | remove extra space in standalone imports | + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | +| [c7556b62b](https://github.com/angular/angular-cli/commit/c7556b62b7b0eab5717ed6eeab3fa7f0f1f2a873) | refactor | replace parser with yargs-parser | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | +| [5330d52ae](https://github.com/angular/angular-cli/commit/5330d52aee32daca27fa1a2fa15712f4a408602a) | refactor | replace parser with yargs-parser | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------ | +| [00186fb93](https://github.com/angular/angular-cli/commit/00186fb93f66d8da51886de37cfa4599f3e89af9) | feat | add initial experimental esbuild-based application browser builder | +| [d23a168b8](https://github.com/angular/angular-cli/commit/d23a168b8d558ae9d73c8c9eed4ff199fc4d74b9) | feat | validate file extensions for `scripts` and `styles` options | +| [2adf252dc](https://github.com/angular/angular-cli/commit/2adf252dc8a7eb0ce504de771facca56730e5272) | fix | add es2015 exports package condition to browser-esbuild | +| [72e820e7b](https://github.com/angular/angular-cli/commit/72e820e7b2bc6904b030f1092bbb610334a4036f) | fix | better handle Windows paths in esbuild experimental builder | +| [587082fb0](https://github.com/angular/angular-cli/commit/587082fb0fa7bdb6cddb36327f791889d76e3e7b) | fix | close compiler on Karma exit | +| [c52d10d1f](https://github.com/angular/angular-cli/commit/c52d10d1fc4b70483a2043edfa73dc0f323f6bf1) | fix | close dev-server on error | +| [48630ccfd](https://github.com/angular/angular-cli/commit/48630ccfd7a672fc5174ef484b3bd5c549d32fef) | fix | detect `tailwind.config.cjs` as valid tailwindcss configuration | +| [4d5f6c659](https://github.com/angular/angular-cli/commit/4d5f6c65918c1a8a4bde0a0af01089242d1cdc4a) | fix | downlevel libraries based on the browserslist configurations | +| [1a160dac0](https://github.com/angular/angular-cli/commit/1a160dac00f34aab089053281c640dba3efd597f) | fix | ensure karma sourcemap support on Windows | +| [07e776ea3](https://github.com/angular/angular-cli/commit/07e776ea379a50a98a50cf590156c2dc1b272e78) | fix | fail build when importing CSS files as an ECMA modules | +| [ac1383f9e](https://github.com/angular/angular-cli/commit/ac1383f9e5d491181812c090bd4323f46110f3d8) | fix | properly handle locally-built APF v14 libraries | +| [966d25b55](https://github.com/angular/angular-cli/commit/966d25b55eeb6cb84eaca183b30e7d3b0d0a2188) | fix | remove unneeded JIT reflect metadata polyfill | +| [b8564a638](https://github.com/angular/angular-cli/commit/b8564a638df3b6971ef2ac8fb838e6a7c910ac3b) | refactor | remove deprecated `NG_BUILD_CACHE` environment variable | +| [0a1cd584d](https://github.com/angular/angular-cli/commit/0a1cd584d8ed00889b177f4284baec7e5427caf2) | refactor | remove deprecated `showCircularDependencies` browser and server builder option | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- | +| [c5b3e9299](https://github.com/angular/angular-cli/commit/c5b3e9299130132aecfa19219405e1964d0c5443) | refactor | deprecate unused exception classes | +| [67144b9e5](https://github.com/angular/angular-cli/commit/67144b9e54b5a9bfbc963e386b01275be5eaccf5) | refactor | remove deprecated `parseJson` and `ParseJsonOptions` APIs | +| [a0c02af7e](https://github.com/angular/angular-cli/commit/a0c02af7e340bb16f4e6f523c2d835c9b18926b3) | refactor | remove deprecated fs, object and array APIs | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------- | +| [c9c781c7d](https://github.com/angular/angular-cli/commit/c9c781c7d5f3c6de780912fd7c624a457e6da14c) | feat | add parameter to `listSchematicNames` to allow returning hidden schematics. | +| [0e6425fd8](https://github.com/angular/angular-cli/commit/0e6425fd88ea32679516251efdca6ff07cc4b56a) | feat | disable package script execution by default in `NodePackageInstallTask` | +| [25498ad5b](https://github.com/angular/angular-cli/commit/25498ad5b2ba6fa5a88c9802ddeb0ed85c5d9b60) | feat | re-export core string helpers from schematics package | +| [464cf330a](https://github.com/angular/angular-cli/commit/464cf330a14397470e1e57450a77f421a45a927e) | feat | support null for options parameter from OptionTransform type | +| [33f9f3de8](https://github.com/angular/angular-cli/commit/33f9f3de869bba2ecd855a01cc9a0a36651bd281) | feat | support reading JSON content directly from a Tree | +| [01297f450](https://github.com/angular/angular-cli/commit/01297f450387dea02eafd6f5701c417ab5c5d844) | feat | support reading text content directly from a Tree | +| [48f9b79bc](https://github.com/angular/angular-cli/commit/48f9b79bc4d43d0180bab5af5726621a68204a15) | fix | support ignore scripts package installs with Yarn 2+ | +| [3471cd6d8](https://github.com/angular/angular-cli/commit/3471cd6d8696ae9c28dba901d3e0f6868d69efc8) | fix | support quiet package installs with Yarn 2+ | +| [44c1e6d0d](https://github.com/angular/angular-cli/commit/44c1e6d0d2db5f2dc212d63a34ade045cb7854d5) | refactor | remove deprecated `analytics` property | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [243cb4062](https://github.com/angular/angular-cli/commit/243cb40622fef4107b0162bc7b6a374471cebc14) | fix | remove `@schematics/angular` utility deep import usage | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------ | +| [0c344259d](https://github.com/angular/angular-cli/commit/0c344259dcdc10a35840151bfe3ae1b27f9b53ff) | fix | update peer dependency to reflect TS 4.6 support | +| [044101554](https://github.com/angular/angular-cli/commit/044101554dfbca07d74f2a4391f94875df7928d2) | perf | use Webpack's built-in xxhash64 support | +| [9277eed1d](https://github.com/angular/angular-cli/commit/9277eed1d9603d5e258eb7ae27de527eba919482) | refactor | remove deprecated ivy namespace | + +## Special Thanks + +Adrien Crivelli, Alan Agius, Charles Lyding, Cédric Exbrayat, Daniil Dubrava, Doug Parker, Elton Coelho, George Kalpakas, Jason Bedard, Joey Perrott, Kristiyan Kostadinov, Paul Gschwendtner, Pawel Kozlowski, Tobias Speicher and alkavats1 + + + + + +# 13.3.7 (2022-05-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [a54018d8f](https://github.com/angular/angular-cli/commit/a54018d8f5f976034bf0a33f826245b7a6b74bbe) | fix | add debugging and timing information in JavaScript and CSS optimization plugins | + +## Special Thanks + +Alan Agius and Joey Perrott + + + + + +# 13.3.6 (2022-05-18) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [e20964c43](https://github.com/angular/angular-cli/commit/e20964c43c52125b6d2bfa9bbea444fb2eea1e15) | fix | resolve relative schematic from `angular.json` instead of current working directory | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [16fec8d58](https://github.com/angular/angular-cli/commit/16fec8d58b6ec421df5e7809c45838baf232b4a9) | fix | update `babel-loader` to 8.2.5 | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Paul Gschwendtner + + + + + +# 13.3.5 (2022-05-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [6da0910d3](https://github.com/angular/angular-cli/commit/6da0910d345eb84084e32a462432a508d518f402) | fix | update `@ampproject/remapping` to `2.2.0` | + +## Special Thanks + +Alan Agius, Charles Lyding and Paul Gschwendtner + + + + + +# 13.3.4 (2022-04-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [f4da75656](https://github.com/angular/angular-cli/commit/f4da756560358273098df2a5cae7848201206c77) | fix | change wrapping of schematic code | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [5d0141bfb](https://github.com/angular/angular-cli/commit/5d0141bfb4ae80b1a7543eab64e9c381c932eaef) | fix | correctly resolve custom service worker configuration file | + +## Special Thanks + +Charles Lyding and Wagner Maciel + + + + + +# 13.3.3 (2022-04-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [d38b247cf](https://github.com/angular/angular-cli/commit/d38b247cf19edf5ecf7792343fa2bc8c05a3a8b8) | fix | display debug logs when using the `--verbose` option | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------- | +| [5682baee4](https://github.com/angular/angular-cli/commit/5682baee4b562b314dad781403dcc0c46e0a8abb) | fix | emit devserver setup errors | + +## Special Thanks + +Alan Agius + + + + + +# 13.3.2 (2022-04-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [49dc63d09](https://github.com/angular/angular-cli/commit/49dc63d09a7a7f2b7759b47e79fac934b867e9b4) | fix | ensure lint command auto-add exits after completion | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [bbe74b87e](https://github.com/angular/angular-cli/commit/bbe74b87e52579c06b911db6173f33c67b8010a6) | fix | provide actionable error message when routing declaration cannot be found | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [c97c8e7c9](https://github.com/angular/angular-cli/commit/c97c8e7c9bbcad66ba80967681cac46042c3aca7) | fix | update `minimatch` dependency to `3.0.5` | + +## Special Thanks + +Alan Agius, Charles Lyding and Morga Cezary + + + + + +# 13.3.1 (2022-03-30) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------- | +| [cf3cb2ecf](https://github.com/angular/angular-cli/commit/cf3cb2ecf9ca47a984c4272f0094f2a1c68c7dfe) | fix | fix extra comma added when use --change-detection=onPush and --style=none to generate a component | + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [9f8d4dea0](https://github.com/angular/angular-cli/commit/9f8d4dea0449e236de7b928c5cc97e597a6f5844) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [ba3486de9](https://github.com/angular/angular-cli/commit/ba3486de94e733addf0ac17706b806dd813c9046) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/benchmark + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [1f7fa6970](https://github.com/angular/angular-cli/commit/1f7fa6970e8cddb2ba0c42df0e048a57292b7fe8) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [293526c31](https://github.com/angular/angular-cli/commit/293526c31db9f0becc0ffc2d60999c80afa8a308) | fix | add `node_modules` prefix to excludes RegExp | +| [58ed97410](https://github.com/angular/angular-cli/commit/58ed97410b760909d523b05c3b4a06364e3c9a0f) | fix | allow Workers in Stackblitz | +| [4cd2331d3](https://github.com/angular/angular-cli/commit/4cd2331d34e2a9ab2ed78edf0284dbfefef511a5) | fix | don't override asset info when updating assets | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [c7c75820f](https://github.com/angular/angular-cli/commit/c7c75820f1d4ef827336626b78c8c3e5c0bd1f00) | fix | add Angular CLI major version as analytics dimension | + +## Special Thanks + +Alan Agius and gauravsoni119 + + + + + +# 12.2.17 (2022-03-31) + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [ccb0f95f3](https://github.com/angular/angular-cli/commit/ccb0f95f33ff0d23a0ff9b237d0d78fc4c864787) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [abcdf4df2](https://github.com/angular/angular-cli/commit/abcdf4df20c29907ee28a38842942464addcf259) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/benchmark + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [2656a330e](https://github.com/angular/angular-cli/commit/2656a330eb365f37c3b6f8894436b4449d157e63) | fix | update `minimist` to `1.2.6` | + +## Special Thanks + +Alan Agius + + + + + +# 11.2.19 (2022-03-30) + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [75caa1143](https://github.com/angular/angular-cli/commit/75caa1143f4007c9550ab0dabb62ae4df91e3827) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [80d479e9f](https://github.com/angular/angular-cli/commit/80d479e9fdfcf6863ebbe0986ea6cd29309f398d) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/benchmark + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [f61cd1a79](https://github.com/angular/angular-cli/commit/f61cd1a79b6960711d4aa5b16d04308bbdc67beb) | fix | update `minimist` to `1.2.6` | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 13.3.0 (2022-03-16) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [c995ed5e8](https://github.com/angular/angular-cli/commit/c995ed5e8a8e1b20cf376f4c48c5141fd5f4548a) | feat | support TypeScript 4.6 | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 13.2.6 (2022-03-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [90a5531b1](https://github.com/angular/angular-cli/commit/90a5531b1fbe4043ab47f921ad6b858d34e7c7d0) | fix | ignore css only chunks during naming | + +## Special Thanks + +Alan Agius, Charles Lyding and Daniele Maltese + + + + + +# 13.2.5 (2022-02-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [acf1e5e4a](https://github.com/angular/angular-cli/commit/acf1e5e4a5b359be125272f7e4055208116a13d8) | fix | don't rename blocks which have a name | +| [7a493979c](https://github.com/angular/angular-cli/commit/7a493979ccb71e974d668fca67d75e1b194f8608) | fix | update `terser` to `5.11.0` | + +## Special Thanks + +Alan Agius and Paul Gschwendtner + + + + + +# 13.2.4 (2022-02-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [48c655ac9](https://github.com/angular/angular-cli/commit/48c655ac98e1d69622dd832c6a915c48e703cd8f) | fix | update `esbuild` to `0.14.22` | +| [c0736ea0b](https://github.com/angular/angular-cli/commit/c0736ea0b173861bb5ceb9315d27038eb28535e1) | fix | update license-webpack-plugin to 4.0.2 | + +## Special Thanks + +Alan Agius, Anner Visser and Charles Lyding + + + + + +# 13.2.3 (2022-02-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [8c8377fee](https://github.com/angular/angular-cli/commit/8c8377fee4999266f4e58bf3c3091100d4393df7) | fix | block Karma from starting until build is complete | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [1317e470e](https://github.com/angular/angular-cli/commit/1317e470ec74d1dd9dced2d0ec0022abfe921995) | fix | support locating PNPM lock file during NGCC processing | + +## Special Thanks + +Alan Agius, Derek Cormier and Joey Perrott + + + + + +# 13.2.2 (2022-02-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [cc5505cfc](https://github.com/angular/angular-cli/commit/cc5505cfcf12732fad4f85e6e76c8e4f0584c13a) | fix | add `whatwg-url` to downlevel exclusion list | +| [ff54b49e7](https://github.com/angular/angular-cli/commit/ff54b49e7097cda2eb835bc8c9674f71fcc91c3c) | fix | ensure to use content hash as filenames hashing mechanism | +| [b0e2bb289](https://github.com/angular/angular-cli/commit/b0e2bb289050efc77478a0f50778abbec9c5a318) | perf | update `license-webpack-plugin` to `4.0.1` | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [c8826a973](https://github.com/angular/angular-cli/commit/c8826a9738f860e374bd65a058c6be1b02545133) | fix | correctly resolve schema references defaults | + +## Special Thanks + +Alan Agius, Derek Cormier and Joey Perrott + + + + + +# 13.2.1 (2022-01-31) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [acd752773](https://github.com/angular/angular-cli/commit/acd752773d85e4debbc2b415c7ea369bc3d7018a) | fix | invalid browsers version ranges | + +## Special Thanks + +Alan Agius + + + + + +# 13.2.0 (2022-01-26) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [41a828e20](https://github.com/angular/angular-cli/commit/41a828e2068b881f744846c3f0edbff8c62cb9ce) | fix | updated Angular new project version to v13.2.0-next.0 | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [f2c6b2b7e](https://github.com/angular/angular-cli/commit/f2c6b2b7ec88a1b7e45884b38faa0978af1b4b74) | fix | correctly handle ESM builders | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [cbe028e37](https://github.com/angular/angular-cli/commit/cbe028e37c8af6f2e17cbbeddc968c9410151bbb) | feat | expose i18nDuplicateTranslation option of browser and server builders | +| [509322b62](https://github.com/angular/angular-cli/commit/509322b6214b3425bd209087ac99ee9b14edeaba) | fix | Don't use TAILWIND_MODE=watch | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [820ff2a3e](https://github.com/angular/angular-cli/commit/820ff2a3e84c5a55e23359e3a45714db83362a2a) | fix | correctly handle ESM webpack configurations | + +## Special Thanks + +Alan Agius, Cédric Exbrayat, Derek Cormier, Doug Parker, Joey Perrott, Jordan Pittman, grant-wilson and minijus + + + + + +# 13.1.4 (2022-01-19) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [2f2069dba](https://github.com/angular/angular-cli/commit/2f2069dbaa70c3d4725923f1c3ccbf56b1f57576) | fix | disable parsing `new URL` syntax | +| [bddd0fb9f](https://github.com/angular/angular-cli/commit/bddd0fb9f34a8706dd1646952eed08970b9cddbe) | fix | support ESNext as target for JavaScript optimizations | + +## Special Thanks + +Alan Agius, Derek Cormier and Doug Parker + + + + + +# 13.1.3 (2022-01-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [4c9d72c65](https://github.com/angular/angular-cli/commit/4c9d72c659d912bd9ef4590a2e88340932a96868) | fix | remove extra space in `Unable to find compatible package` during `ng add` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [9b07191b1](https://github.com/angular/angular-cli/commit/9b07191b1ccdcd2a6bb17686471acddd5862dcf5) | fix | set `skipTest` flag for resolvers when using ng new --skip-tests | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [5b39e0eca](https://github.com/angular/angular-cli/commit/5b39e0eca6e8a3825f66ad6cd1818e551bf98f08) | fix | automatically purge stale build cache entries | +| [6046e06b9](https://github.com/angular/angular-cli/commit/6046e06b926af29f89c605504f5356ec553c6390) | fix | correctly resolve `core-js/proposals/reflect-metadata` | +| [de68daa55](https://github.com/angular/angular-cli/commit/de68daa5581dd1f257382da16704d442b540ec41) | fix | enable `:where` CSS pseudo-class | +| [6a617ff4a](https://github.com/angular/angular-cli/commit/6a617ff4a2fe75968965dc5dcf0f3ba7bae92935) | fix | ensure `$localize` calls are replaced in watch mode | +| [92b4e067b](https://github.com/angular/angular-cli/commit/92b4e067b24bdcd1bb7e40612b5355ce61e040ce) | fix | load translations fresh start | +| [d674dcd1a](https://github.com/angular/angular-cli/commit/d674dcd1af409910dd4f41ac676349aee363ebdb) | fix | localized bundle generation fails in watch mode | +| [6876ad36e](https://github.com/angular/angular-cli/commit/6876ad36efaadac5c4d371cff96c9a4cfa0e3d2b) | fix | use `contenthash` instead of `chunkhash` for chunks | +| [11fd02105](https://github.com/angular/angular-cli/commit/11fd02105908e155c4a9c7f87e9641127cc2f378) | fix | websocket client only injected if required | +| [6ca0e41a9](https://github.com/angular/angular-cli/commit/6ca0e41a9b54aef0a8ea626be73e06d19370f3a7) | perf | update `esbuild` to `0.14.11` | + +## Special Thanks + +Alan Agius, Bill Barry, Derek Cormier, Elio Goettelmann, Joey Perrott, Kasper Christensen, Lukas Spirig and Zoltan Lehoczky + + + + + +# 12.2.15 (2022-01-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [526115fdb](https://github.com/angular/angular-cli/commit/526115fdb7d35ff01f5dbdb6027d9f5e925e4056) | fix | updated webpack-dev-server to latest security patch | + +## Special Thanks + +Doug Parker and iRealNirmal + + + +# 11.2.18 (2022-01-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [534678450](https://github.com/angular/angular-cli/commit/534678450196a45610e88a85ee01317aa43dc788) | fix | updated webpack-dev-server to latest security patch | + +## Special Thanks + +Doug Parker and iRealNirmal + + + +# 13.2.0-next.1 (2021-12-15) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [41a828e20](https://github.com/angular/angular-cli/commit/41a828e2068b881f744846c3f0edbff8c62cb9ce) | fix | updated Angular new project version to v13.2.0-next.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [0323a35b4](https://github.com/angular/angular-cli/commit/0323a35b47a4a2fd3870b09d46e3655714e50abd) | fix | add `tailwindcss` support for version 3 | +| [471930007](https://github.com/angular/angular-cli/commit/471930007cb9cd26264eab483fdfd1f5b4db6641) | fix | display FS cache information when `verbose` option is used | +| [f1d2873ca](https://github.com/angular/angular-cli/commit/f1d2873ca7ee337748366d04878514c2c27a72a2) | fix | only extract CSS styles when are specified in `styles` option | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [b03b9eefe](https://github.com/angular/angular-cli/commit/b03b9eefeac77b93931803de208118e3a6c5a928) | perf | reduce redundant module rebuilds when cache is restored | + +## Special Thanks + +Alan Agius, Cédric Exbrayat, Derek Cormier and Doug Parker + + + + + +# 13.1.2 (2021-12-15) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [1ddbd75ae](https://github.com/angular/angular-cli/commit/1ddbd75ae200c14b5f33556bd6d5ae6b7722d14e) | fix | add `tailwindcss` support for version 3 | +| [adf925c07](https://github.com/angular/angular-cli/commit/adf925c0755b6e78a57932becdb7b7a764afb9e6) | fix | display FS cache information when `verbose` option is used | +| [09c3826c9](https://github.com/angular/angular-cli/commit/09c3826c9d9128a6b520d0fe8da3cb466d18cddc) | fix | only extract CSS styles when are specified in `styles` option | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [f31d7f79d](https://github.com/angular/angular-cli/commit/f31d7f79dfa8f997fecdcfec1ebc6cfbe657f3fb) | perf | reduce redundant module rebuilds when cache is restored | + +## Special Thanks + +Alan Agius, Derek Cormier and Doug Parker + + + + + +# 11.2.17 (2021-12-16) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [1efff8f82](https://github.com/angular/angular-cli/commit/1efff8f82df38b7485f8a8dcdd5bfea5a457c6a1) | fix | exclude deprecated packages with removal migration from update | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 11.2.16 (2021-12-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [f456b0962](https://github.com/angular/angular-cli/commit/f456b0962b9f339759bc86c092256f68d68d9ecf) | fix | error when updating Angular packages across multi-major migrations | +| [886d2511e](https://github.com/angular/angular-cli/commit/886d2511e292b687acce1ac4c6924f992494d14f) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | +| [776d1210a](https://github.com/angular/angular-cli/commit/776d1210a9e62bf2531d977138f49f93820a8b87) | fix | update `ng update` output for Angular packages | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 10.2.4 (2021-12-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [745d77728](https://github.com/angular/angular-cli/commit/745d777288a5ae0e79b4ecdf7b8483f242ba8e66) | fix | error when updating Angular packages across multi-major migrations | +| [460ea21b5](https://github.com/angular/angular-cli/commit/460ea21b5d4b8759a3f7457b885110022dd21dfc) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | +| [03da12899](https://github.com/angular/angular-cli/commit/03da1289996790ae574a49bb46123c74417a97c2) | fix | update `ng update` output for Angular packages | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [d6582d489](https://github.com/angular/angular-cli/commit/d6582d48944f7bf169f3902e4c19186a6751f473) | fix | change `karma-jasmine-html-reporter` dependency to use tilde | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker and Joey Perrott + + + + + +# 13.1.1 (2021-12-10) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [a315b968a](https://github.com/angular/angular-cli/commit/a315b968a36e6aae990e52d9a18673fef9b5fda6) | fix | updated Angular new project version to v13.1.0 | + +## Special Thanks + +Alan Agius, Cédric Exbrayat and Derek Cormier + + + + + +# 13.1.0 (2021-12-09) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [56f802b7d](https://github.com/angular/angular-cli/commit/56f802b7dd26bfc774b6b00982a1dbbe0bafddd0) | feat | ask to install angular-eslint when running ng lint in new projects | +| [ecd9fb5c7](https://github.com/angular/angular-cli/commit/ecd9fb5c774b6301348c4514da04d58ae8903d06) | feat | provide more detailed error for not found builder | +| [0b6071af3](https://github.com/angular/angular-cli/commit/0b6071af3a51e7d3f38a661bd4e0a3c3e81aff2f) | fix | `ng doc` does open browser on Windows | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [d5d9f042f](https://github.com/angular/angular-cli/commit/d5d9f042f2ea42573b7ff4fab90cab85d0c5ec0b) | feat | add VS Code configurations when generating a new workspace | +| [f95cc8281](https://github.com/angular/angular-cli/commit/f95cc8281a64bd9ac19e0fa5d92cb0a6ee8c32ec) | feat | generate new projects using TypeScript 4.5 | +| [21809e14c](https://github.com/angular/angular-cli/commit/21809e14cd5c666c82fdaebc9e601341dfb76d0a) | feat | loosen project name validation | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [339bab06c](https://github.com/angular/angular-cli/commit/339bab06cc25863571acb09cb3e877fed14ca2f9) | feat | generate new projects using TypeScript 4.5 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [bc8563760](https://github.com/angular/angular-cli/commit/bc856376039287cf5fb6135ca5da65a9000f5664) | feat | add estimated transfer size to build output report | +| [bc17cf0cd](https://github.com/angular/angular-cli/commit/bc17cf0cdd02bf50758e510756a26e6e6ca32d14) | feat | colorize file raw sizes based on failing budgets | +| [3c681b68d](https://github.com/angular/angular-cli/commit/3c681b68d7a32f1cfaf3feee6b2e02cc6e0f0568) | feat | set `dir` attribute when using localization | +| [6d0f99a2d](https://github.com/angular/angular-cli/commit/6d0f99a2deef957c15836c172b9f68f716f836a4) | feat | support JSON comments in dev-server proxy configuration file | +| [9300545e6](https://github.com/angular/angular-cli/commit/9300545e6148b4548cc02bb6a311a2f0e2bb79c5) | feat | watch i18n translation files with dev server | +| [9bacba342](https://github.com/angular/angular-cli/commit/9bacba3420cda7897091522415a8d55cf1b75106) | fix | differentiate components and global styles using file query instead of filename | +| [7408511da](https://github.com/angular/angular-cli/commit/7408511da555f37560ca7e3b536e15dfc8f6a1e5) | fix | display cleaner errors | +| [d55fc62ef](https://github.com/angular/angular-cli/commit/d55fc62ef2f8bc7a6f1190f56f8e8b64c9195263) | fix | fallback to use language ID to set the `dir` attribute | +| [4c288b8bd](https://github.com/angular/angular-cli/commit/4c288b8bd28e7215887aa52025c4fa41fcf7bc01) | fix | lazy modules bundle budgets | +| [562dc6a89](https://github.com/angular/angular-cli/commit/562dc6a8924826509d9012b2c0fe61c089077399) | fix | prefer ES2015 entrypoints when application targets ES2019 or lower | +| [ac66e400c](https://github.com/angular/angular-cli/commit/ac66e400cddc81bde46949d1abe4560185dfbedb) | fix | Sass compilation in StackBlitz webcontainers | +| [e1bac5bbb](https://github.com/angular/angular-cli/commit/e1bac5bbb36f391b89445ba61abe561c75746f30) | fix | update Angular peer dependencies to v13.1 prerelease | +| [789ddfaeb](https://github.com/angular/angular-cli/commit/789ddfaeb0fcbc9aab1581384b88c3618e606c4b) | perf | disable webpack backwards compatible APIs | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [5402f99f8](https://github.com/angular/angular-cli/commit/5402f99f8ad20e0a57456a416a992415fc6332bd) | fix | add `cjs` and `mjs` to passthrough files | +| [10d4ede2d](https://github.com/angular/angular-cli/commit/10d4ede2de42dfc302dcb4c5790274290170568d) | fix | handle promise rejection during Angular program analyzes | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Ferdinand Malcher, Joey Perrott and Ruslan Lekhman + + + + + +# 12.2.14 (2021-12-07) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [30295b33e](https://github.com/angular/angular-cli/commit/30295b33ed74667f31e9d3a4a0017910a85fd734) | fix | error when updating Angular packages across multi-major migrations | +| [e07bd059e](https://github.com/angular/angular-cli/commit/e07bd059e3d6bc6b40191c036c467595ed119da7) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | +| [ce1ec0420](https://github.com/angular/angular-cli/commit/ce1ec0420770a8e28c1c1301df9e5eb4548d4c53) | fix | update `ng update` output for Angular packages | +| [dd9f8df52](https://github.com/angular/angular-cli/commit/dd9f8df5204d639272f183795ebd48d7994df427) | fix | update `pacote` to `12.0.2` | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 13.0.4 (2021-12-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [ded7b5c06](https://github.com/angular/angular-cli/commit/ded7b5c069a145d1b3e264538d7c4302919ad030) | fix | exit with a non-zero error code when migration fails during `ng update` | +| [250a58b48](https://github.com/angular/angular-cli/commit/250a58b4820a738aba7609627fa7fce0a24f10db) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [372e2e633](https://github.com/angular/angular-cli/commit/372e2e633f4bd9bf29c35d02890e1c6a70da3169) | fix | address eslint linting failures in `test.ts` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------- | +| [b835389c8](https://github.com/angular/angular-cli/commit/b835389c8a60749151039ed0baf0be025ce0932b) | fix | correctly extract messages when using cached build ([#22266](https://github.com/angular/angular-cli/pull/22266)) | +| [647a5f0b1](https://github.com/angular/angular-cli/commit/647a5f0b18e49b2ece3f43c0a06bfb75d7caef49) | fix | don't watch nested `node_modules` when polling is enabled | +| [4d01d4f72](https://github.com/angular/angular-cli/commit/4d01d4f72344c42f650f5495b21e6bd94069969a) | fix | transform remapped sourcemap into a plain object | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [4d918ef99](https://github.com/angular/angular-cli/commit/4d918ef9912d53a09d73fb19fa41b121dceed37c) | fix | JIT mode CommonJS accessing inexistent `default` property | + +## Special Thanks + +Alan Agius, Billy Lando, David-Emmanuel DIVERNOIS and Derek Cormier + + + + + +# 13.0.3 (2021-11-17) + +## Special Thanks + +Alan Agius, Joey Perrott and Krzysztof Platis + + + + + +# 13.0.2 (2021-11-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [34047b1ad](https://github.com/angular/angular-cli/commit/34047b1adccd7eb852c1900c872e9ca71c8d4cd9) | fix | avoid redirecting @angular/core in Angular migrations | +| [ff4538e98](https://github.com/angular/angular-cli/commit/ff4538e981cfff49b6e8433ffcb5ac2d2ea5d07e) | fix | favor ng-update `packageGroupName` in ng update output | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [1bc00b6fe](https://github.com/angular/angular-cli/commit/1bc00b6feb9033fd611dec965c82f03e4135a9f4) | fix | migrate ng-packagr configurations in package.json | +| [9ea74a13d](https://github.com/angular/angular-cli/commit/9ea74a13d07208373490c7cdb3ff7c452c698322) | fix | show warning when migrating ng-packagr JS configurations | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [35164bf92](https://github.com/angular/angular-cli/commit/35164bf92b986a67215580622aaddc4148a7c822) | fix | don't restore `input` of type `file` during HMR | +| [facb5d8ff](https://github.com/angular/angular-cli/commit/facb5d8ffd4f6a81d3132515b8bae64278cf8316) | fix | don't show `[NG HMR] Unknown input type` when restoring file type input | +| [ef8815d04](https://github.com/angular/angular-cli/commit/ef8815d0434836f2d8119e91a7bc09742ff77d37) | fix | improve sourcemap fidelity during code-coverage | +| [966a1334a](https://github.com/angular/angular-cli/commit/966a1334a6502f5d4a18710ae22e739e62770101) | fix | suppress "@charset" must be the first rule in the file warning | +| [1cdc24da0](https://github.com/angular/angular-cli/commit/1cdc24da0105fad75221e3c145de12dafc601059) | fix | update Angular peer dependencies to 13.0 stable | + +## Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott and Paul Gschwendtner + + + + + +# 13.0.1 (2021-11-03) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [40f599241](https://github.com/angular/angular-cli/commit/40f599241e278478c694580c9dec4f5cc34db011) | fix | updated Angular new project version to v13.0.0 | + +## Special Thanks + +Charles Lyding and Joey Perrott + + + + + +# 12.2.13 (2021-11-03) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [a2bd940e4](https://github.com/angular/angular-cli/commit/a2bd940e4ab44db57b0fc69d5346d2862a19c879) | fix | add verbose logging for differential loading and i18n | + +## Special Thanks + +Charles Lyding and Doug Parker + + + + + +# 13.0.0 (2021-11-03) + +## Breaking Changes + +### @angular/cli + +- We drop support for Node.js versions prior to `12.20`. + +### @schematics/angular + +- `classlist.js` and `web-animations-js` are removed from application polyfills and uninstalled from the package. These were only needed for compatibility with Internet Explorer, which is no longer needed now that Angular only supports evergreen browsers. See: https://angular.dev/reference/versions#browser-support. + +Add the following to the polyfills file for an app to re-add these packages: + +```typescript +import 'classlist.js'; +import 'web-animations-js'; +``` + +And then run: + +```sh +npm install classlist.js web-animations-js --save +``` + +- We removed several deprecated `@schematics/angular` deprecated options. +- `lintFix` have been removed from all schematics. `ng lint --fix` should be used instead. +- `legacyBrowsers` have been removed from the `application` schematics since IE 11 is no longer supported. +- `configuration` has been removed from the `web-worker` as it was unused. +- `target` has been removed from the `service-worker` as it was unused. + +### @angular-devkit/build-angular + +- Support for `karma-coverage-instanbul-reporter` has been dropped in favor of the official karma coverage plugin `karma-coverage`. + +- Support for `node-sass` has been removed. `sass` will be used by default to compile SASS and SCSS files. + +- `NG_PERSISTENT_BUILD_CACHE` environment variable option no longer have effect. Configure `cli.cache` in the workspace configuration instead. + +```json +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "cache": { + "enabled": true, + "path": ".custom-cache-path", + "environment": "all" + } + } + ... +} +``` + +- Calling `BuilderContext.scheduleBuilder()` with a builder from `@angular-devkit/build-angular` now requires passing the `target` property in the 3rd argument, like in the following example: + + ```typescript + context.scheduleBuilder('@angular-devkit/build-angular:ng-packagr', options, { + target: context.target, + }); + ``` + +- The automatic inclusion of Angular-required ES2015 polyfills to support ES5 browsers has been removed. Previously when targeting ES5 within the application's TypeScript configuration or listing an ES5 requiring browser in the browserslist file, Angular-required polyfills were included in the built application. However, with Angular no longer supporting IE11, there are now no browsers officially supported by Angular that would require these polyfills. As a result, the automatic inclusion of these ES2015 polyfills has been removed. Any polyfills manually added to an application's code are not affected by this change. + +- With this change a number of deprecated dev-server builder options which proxied to the browser builder have been removed. These options should be configured in the browser builder instead. + +The removed options are: + +- `aot` +- `sourceMap` +- `deployUrl` +- `baseHref` +- `vendorChunk` +- `commonChunk` +- `optimization` +- `progress` + +- With this change we removed several deprecated builder options +- `extractCss` has been removed from the browser builder. CSS is now always extracted. +- `servePathDefaultWarning` and `hmrWarning` have been removed from the dev-server builder. These options had no effect. + +- Deprecated `@angular-devkit/build-angular:tslint` builder has been removed. Use https://github.com/angular-eslint/angular-eslint instead. + +- Differential loading support has been removed. With Angular no longer supporting IE11, there are now no browsers officially supported by Angular that require ES5 code. As a result, differential loading's functionality for creating and conditionally loading ES5 and ES2015+ variants of an application is no longer required. + +- TypeScript versions prior to 4.4 are no longer supported. + +- The dev-server now uses WebSockets to communicate changes to the browser during HMR and live-reloaded. If during your development you are using a proxy you will need to enable proxying of WebSockets. + +- We remove inlining of Google fonts in WOFF format since IE 11 is no longer supported. Other supported browsers use WOFF2. + +### @angular-devkit/build-webpack + +- Support for `webpack-dev-server` version 3 has been removed. For more information about the migration please see: https://github.com/webpack/webpack-dev-server/blob/master/migration-v4.md + +Note: this change only affects users depending on `@angular-devkit/build-webpack` directly. + +### @angular-devkit/core + +- With this change we drop support for the deprecated behaviour to transform `id` in schemas. Use `$id` instead. + +Note: this only effects schematics and builders authors. + +- The deprecated JSON parser has been removed from public API. [jsonc-parser](https://www.npmjs.com/package/jsonc-parser) should be used instead. + +### @angular-devkit/schematics + +- `isAction` has been removed without replacement as it was unused. + +- With this change we remove the following deprecated APIs +- `TslintFixTask` +- `TslintFixTaskOptions` + +**Note:** this only effects schematics developers. + +### @ngtools/webpack + +- Deprecated `inlineStyleMimeType` option has been removed from `AngularWebpackPluginOptions`. Use `inlineStyleFileExtension` instead. + +- Applications directly using the `webpack-cli` and not the Angular CLI to build must set the environment variable `DISABLE_V8_COMPILE_CACHE=1`. The `@ngtools/webpack` package now uses dynamic imports to provide support for the ESM `@angular/compiler-cli` package. The `v8-compile-cache` package used by the `webpack-cli` does not currently support dynamic import expressions and will cause builds to fail if the environment variable is not specified. Applications using the Angular CLI are not affected by this limitation. + +## Deprecations + +### + +- `@angular-devkit/build-optimizer` + +It's functionality has been included in `@angular-devkit/build-angular` so this package is no longer needed by the CLI and we will stop publishing the package soon. It has been an experimental (never hit `1.0.0`) and internal (only used by Angular itself) package and should be not be used directly by others. + +### @angular-devkit/build-angular + +- `NG_BUILD_CACHE` environment variable option will be removed in the next major version. Configure `cli.cache` in the workspace configuration instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [9fe55752d](https://github.com/angular/angular-cli/commit/9fe55752db8bb50cad5a1ddfe670dce06528e23e) | feat | officially support Node.js v16 | +| [5ad145722](https://github.com/angular/angular-cli/commit/5ad145722f66af526a36983b259c6d625c93f307) | fix | error when updating Angular packages across multi-major migrations | +| [e4bc35e33](https://github.com/angular/angular-cli/commit/e4bc35e332e378f8d238f4069dc56f422fe205d6) | fix | exclude packages from ng add that contain invalid peer dependencies | +| [e1b954d70](https://github.com/angular/angular-cli/commit/e1b954d707f90622d8a75fc45840cefeb224c286) | fix | keep relative migration paths during update analysis | +| [c3acf3cc2](https://github.com/angular/angular-cli/commit/c3acf3cc26b9e37a3b8f4c369f42731f46b522ee) | fix | remove unused cli project options. | +| [77fe6c4e6](https://github.com/angular/angular-cli/commit/77fe6c4e67147ff42fa6350edaf4ef7dc184a3a6) | fix | update `engines` to require `node` `12.20.0` | +| [8795536a3](https://github.com/angular/angular-cli/commit/8795536a31efbed6373787188cb21c5d1e0accbd) | fix | update `ng update` output for Angular packages | +| [d8c9f6eaf](https://github.com/angular/angular-cli/commit/d8c9f6eaf4513639741d20c6af97a751b33b968e) | fix | update the update command to fully support Node.js v16 | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| [7ff8c5350](https://github.com/angular/angular-cli/commit/7ff8c5350ea2e49574dd659adae02215957d2685) | feat | add `/.angular/cache` to `.gitignore` | +| [3ba13f467](https://github.com/angular/angular-cli/commit/3ba13f467c12f4ad0c314cc92a2d94fb63f640ec) | feat | add `noImplicitOverride` and `noPropertyAccessFromIndexSignature` to workspace tsconfig | +| [268a03b63](https://github.com/angular/angular-cli/commit/268a03b63094d9c680401bc0977edafb22826ce3) | feat | add migration to update the workspace config | +| [7bdcd7da1](https://github.com/angular/angular-cli/commit/7bdcd7da1ff3a31f4958d90d856beb297e99b187) | feat | create new projects with rxjs 7 | +| [eac18aed7](https://github.com/angular/angular-cli/commit/eac18aed78da55efb840a3ef6f5e90718946504c) | feat | drop polyfills required only for Internet Explorer now that support has been dropped for it | +| [4f91816b2](https://github.com/angular/angular-cli/commit/4f91816b2951c0e2b0109ad1938eb0ae632c0c76) | feat | migrate libraries to be published from ViewEngine to Ivy Partial compilation | +| [5986befcd](https://github.com/angular/angular-cli/commit/5986befcdc953c0e8c90c756ac1c89b8c4b66614) | feat | remove deprecated options | +| [9fbd16655](https://github.com/angular/angular-cli/commit/9fbd16655e86ec6fc598a47436e3e80a48beb649) | feat | remove IE 11 specific polyfills | +| [a7b2e6f51](https://github.com/angular/angular-cli/commit/a7b2e6f512d2a1124f0d2c68caacfe6552a10cd5) | feat | update ngsw-config resources extensions | +| [732ef7985](https://github.com/angular/angular-cli/commit/732ef798523f74994ed3d482a65b191058674d19) | fix | add browserslist configuration in library projects | +| [585adacd0](https://github.com/angular/angular-cli/commit/585adacd0624ddf32c5c69a755d8e542f3463861) | fix | don't add `destroyAfterEach` in newly generated spec files | +| [e58226ee9](https://github.com/angular/angular-cli/commit/e58226ee948ea88f27a81d50d71945b5c9c39ee3) | fix | don't export `renderModuleFactory` from server file | +| [0ec0ad8a4](https://github.com/angular/angular-cli/commit/0ec0ad8a4dba4a778b368c5cd76ef13fb370b310) | fix | remove `target` and `lib` options for library tsconfig | +| [f227e145d](https://github.com/angular/angular-cli/commit/f227e145dfbec2954cb96c92ab3c4cb97cbe0f32) | fix | updated Angular new project version to v13.0 prerelease | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [5e435ff37](https://github.com/angular/angular-cli/commit/5e435ff37703f9ffea7fa92fbd5cd42d9a3db07e) | docs | mark `@angular-devkit/build-optimizer` as deprecated. | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [09e039500](https://github.com/angular/angular-cli/commit/09e039500f34b0d6a16e62128409ac5821e8b9c2) | feat | include workspace extensions in project metadata | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------- | +| [f53bf9dc2](https://github.com/angular/angular-cli/commit/f53bf9dc21ee9aa8a682b8a82ee8a9870fa859e1) | feat | add `type=module` to all scripts tags | +| [e95ecb8ab](https://github.com/angular/angular-cli/commit/e95ecb8ab0382eb803741619c446d6cc7b215ba0) | feat | deprecate deployUrl | +| [7dcfffaff](https://github.com/angular/angular-cli/commit/7dcfffafff6f3d29bbe679a90cdf77b1292fec0b) | feat | drop support for `karma-coverage-instanbul-reporter` | +| [ac3fc2752](https://github.com/angular/angular-cli/commit/ac3fc2752f28761e1cd42157b59dcf2364ae5567) | feat | drop support for `node-sass` | +| [5904afd1d](https://github.com/angular/angular-cli/commit/5904afd1de3ffa0bb6cd1757795ba9abfce9e523) | feat | enable disk cache by default and provide configurable options | +| [22cd9edfa](https://github.com/angular/angular-cli/commit/22cd9edfafd357bb9d62a93dd56f033b3f34bbe8) | feat | favor es2020 main fields | +| [7576136b2](https://github.com/angular/angular-cli/commit/7576136b2fc8a9173b0a92e2ab14c9bc2559081e) | feat | remove automatic inclusion of ES5 browser polyfills | +| [000b0e51c](https://github.com/angular/angular-cli/commit/000b0e51c166ecd26b6f24d6a133ea5076df9849) | feat | remove deprecated dev-server options | +| [20e48a33c](https://github.com/angular/angular-cli/commit/20e48a33c14a1b0b959ba0a45018df53a3e129c8) | feat | remove deprecated options | +| [e78f6ab5d](https://github.com/angular/angular-cli/commit/e78f6ab5d8f00338d826c8407ce5c8fca40cf097) | feat | remove deprecated tslint builder | +| [701214d17](https://github.com/angular/angular-cli/commit/701214d174586fe7373b6155024c9b6e97b26377) | feat | remove differential loading support | +| [fb1ad7c5b](https://github.com/angular/angular-cli/commit/fb1ad7c5b3fa3df85f1d3dff3850e1ad0003ef9d) | feat | support ESM proxy configuration files for the dev server | +| [505438cc4](https://github.com/angular/angular-cli/commit/505438cc4146b1950038531ce30e1f62f7c41d00) | feat | support TypeScript 4.4 | +| [32dbf659a](https://github.com/angular/angular-cli/commit/32dbf659acb632fac1d76d99d8191ea9c5e6350b) | feat | update `webpack-dev-server` to version 4 | +| [c1efaa17f](https://github.com/angular/angular-cli/commit/c1efaa17feb1d2911dcdea12688d75086d410bf1) | fix | calculate valid Angular versions from peerDependencies | +| [d7af4a7b5](https://github.com/angular/angular-cli/commit/d7af4a7b536a7c43704f808ea208bc9f230d2403) | fix | enable custom `es2020` and `es2015` conditional exports | +| [f383f3201](https://github.com/angular/angular-cli/commit/f383f3201b69d28f8755c0bd63134619f9da408d) | fix | ESM-interop loaded plugin creators of `@angular/localize/tools` not respected | +| [7934becb5](https://github.com/angular/angular-cli/commit/7934becb581d07c8e1f74898ddd4c20f050be659) | fix | generate unique webpack runtimes | +| [b14e0a547](https://github.com/angular/angular-cli/commit/b14e0a54727352a6939c7a0ff13dffe2deaa67d2) | fix | improve sourcemaps fidelity when code coverage is enabled | +| [e19287453](https://github.com/angular/angular-cli/commit/e19287453c10740ea21b31a6c8a3cd5f3714955d) | fix | move `@angular/localize` detection prior to webpack initialization | +| [76d6d8826](https://github.com/angular/angular-cli/commit/76d6d8826f9968f84edf219f67b84673d70bbe95) | fix | set browserslist defaults | +| [167eed465](https://github.com/angular/angular-cli/commit/167eed4654be4480c45d7fdfe7a0b9f160170289) | fix | update Angular peer dependencies to v13.0 prerelease | +| [1d8cdf853](https://github.com/angular/angular-cli/commit/1d8cdf853dc8fdea78b067a715b3342ed9427caa) | fix | update esbuild to 0.13.12 | +| [884111ac0](https://github.com/angular/angular-cli/commit/884111ac0b8a73dca06d844b2ed795a3e3ed3289) | fix | update IE unsupported and deprecation messages | +| [4be6537dd](https://github.com/angular/angular-cli/commit/4be6537ddf4b32e8d204dbaa75f1a53712fe9d44) | fix | update TS/JS regexp checks to latest extensions | +| [427a9ee97](https://github.com/angular/angular-cli/commit/427a9ee9738c0911caeaba5fb4b59d183ffe6244) | fix | update workspace tsconfig lib es2020 | +| [ea926db25](https://github.com/angular/angular-cli/commit/ea926db257ad3b042af86178e472b5763a695146) | fix | use es2015 when generating server bundles | +| [13cceab8e](https://github.com/angular/angular-cli/commit/13cceab8e737a12d0809f184f852ceb5620d81fb) | fix | use URLs for absolute import paths with ESM | +| [4e0743c8a](https://github.com/angular/angular-cli/commit/4e0743c8ad5879f212f2ea232ac9492848a8df2c) | perf | change webpack hashing function to `xxhash64` | +| [cb7d156c2](https://github.com/angular/angular-cli/commit/cb7d156c23a7ef2f1c2f338db1487b85f8b98690) | perf | use esbuild as a CSS optimizer for global styles | +| [8e82263c5](https://github.com/angular/angular-cli/commit/8e82263c5e7da6ca25bdd4e2ce9ad2c775d623b7) | perf | use esbuild/terser combination to optimize global scripts | +| [e82eef924](https://github.com/angular/angular-cli/commit/e82eef924eb172a98fa157a958bde2cfcaa52ce6) | refactor | remove WOFF handling from inline-fonts processor | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [a0b5897d5](https://github.com/angular/angular-cli/commit/a0b5897d50a00ee4668029c2cbc47cacd2ab925f) | feat | update `webpack-dev-server` to version 4 | +| [9efcb32e3](https://github.com/angular/angular-cli/commit/9efcb32e378442714eae4caec43281123c5e30f6) | fix | better handle concurrent dev-servers | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- | +| [0c92ea5ca](https://github.com/angular/angular-cli/commit/0c92ea5ca34d82849862d55c4210cf62c819d514) | feat | remove deprecated schema id handling | +| [9874aff71](https://github.com/angular/angular-cli/commit/9874aff71ecb5f3baf6c1dcc489581d1dcb58491) | fix | add missing option peer dependency on `chokidar` | +| [a54e5e065](https://github.com/angular/angular-cli/commit/a54e5e06551c828eb5cf08695674e04fd8a78bf3) | fix | support Node.js v16 with `NodeJsSyncHost`/`NodeJsAsyncHost` delete operation | +| [d722fdf1f](https://github.com/angular/angular-cli/commit/d722fdf1f67c394762906794605bc1ad657670d1) | refactor | remove deprecated JSON parser | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [0565ed62e](https://github.com/angular/angular-cli/commit/0565ed62eb08c1e82cffb2533e6afde216c37eb7) | feat | add UpdateBuffer2 based on magic-string | +| [8954d1152](https://github.com/angular/angular-cli/commit/8954d1152b6c1a33dd7d4b63d2fa430d91e7b370) | feat | remove deprecated `isAction` | +| [053b7d66c](https://github.com/angular/angular-cli/commit/053b7d66c269423804891e4d43d61f8605838e24) | feat | remove deprecated tslint APIs | +| [bdd89ae84](https://github.com/angular/angular-cli/commit/bdd89ae84ad6919b670dde862de72f562c86d0c5) | fix | handle zero or negative length removals in update buffer | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------- | +| [d2a97f919](https://github.com/angular/angular-cli/commit/d2a97f9193fcf7e454fe8eb48c0ed732d3b2f24f) | fix | update Angular peer dependencies to v13.0 prerelease | +| [7928b18ed](https://github.com/angular/angular-cli/commit/7928b18edf34243a404b5a4f40a5d6e40247d797) | perf | reduce repeat path mapping analysis during resolution | +| [8ce8e4edc](https://github.com/angular/angular-cli/commit/8ce8e4edc5ca2984d6a36fe4c7d308fa7f089102) | refactor | remove deprecated `inlineStyleMimeType` option | +| [7d98ab3df](https://github.com/angular/angular-cli/commit/7d98ab3df9f7c15612c69cedca5a01a535301508) | refactor | support an ESM-only `@angular/compiler-cli` package | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Douglas Parker, Joey Perrott, Kristiyan Kostadinov, Lukas Spirig and Paul Gschwendtner + + + + + +# 12.2.9 (2021-10-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [9d45b7752](https://github.com/angular/angular-cli/commit/9d45b77522a9693c4876fdfd741e8869e89e0268) | fix | add web-streams-polyfill to downlevel exclusion list | +| [ccedf53a8](https://github.com/angular/angular-cli/commit/ccedf53a820a748b56c84528294b36c7af30dbaf) | fix | update `esbuild` to `0.13.4` | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 12.2.8 (2021-10-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [821a1b5a9](https://github.com/angular/angular-cli/commit/821a1b5a949d53f2e82f734062b711a166d42e24) | fix | babel adjust enum plugin incorrectly transforming loose enums | + +## Special Thanks + +Paul Gschwendtner + + + + + +# 12.2.7 (2021-09-22) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [d856b4d23](https://github.com/angular/angular-cli/commit/d856b4d2369bea76ce65fc5f6d1585145ad41618) | fix | support WASM-based esbuild optimizer fallback | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 12.2.6 (2021-09-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [8b21effad](https://github.com/angular/angular-cli/commit/8b21effad673877cf1a82ef7d0601393a65517fb) | fix | handle `FORCE_COLOR` when stdout is not instance of `WriteStream` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [ea60f0f52](https://github.com/angular/angular-cli/commit/ea60f0f527f2ab8fc5acc967138c4ae993946923) | fix | handle `FORCE_COLOR` when stdout is not instance of `WriteStream` | + +## Special Thanks + +Alan Agius + + + + + +# 12.2.5 (2021-09-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [0498768c5](https://github.com/angular/angular-cli/commit/0498768c54de225a40c28fdf27bb1fc43959ba20) | fix | disable dev-server response compression | +| [367fce2e9](https://github.com/angular/angular-cli/commit/367fce2e9f9389c41f2ed5361ef6749198c49785) | fix | improve Safari browserslist to esbuild target conversion | + +## Special Thanks: + +Alan Agius and Charles Lyding + + + + + +# 12.2.4 (2021-09-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [aaadef026](https://github.com/angular/angular-cli/commit/aaadef02698ba729ca04ccd4159bda5b6582babb) | fix | update `esbuild` to `0.12.24` | +| [f8a9f4a01](https://github.com/angular/angular-cli/commit/f8a9f4a0100286b7cf656ffbe486c3424cad5172) | fix | update `mini-css-extract-plugin` to `2.2.1` | + +## Special Thanks + +Alan Agius + + + + + +# 12.2.3 (2021-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [3e3321857](https://github.com/angular/angular-cli/commit/3e33218578007f93a131dc8be569e9985179098f) | fix | RGBA converted to hex notation in component styles breaks IE11 | + +## Special Thanks: + +Alan Agius and Trevor Karjanis + + + + + +# 12.2.2 (2021-08-18) + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| [a55118a75](https://github.com/angular/angular-cli/commit/a55118a753555c0082cfd434379559df7e3eb7f9) | fix: provide supported browsers to esbuild | +| [81baa4f95](https://github.com/angular/angular-cli/commit/81baa4f956443fcc718f9021fd23ab7064d04607) | fix: update Angular peer dependencies to 12.2 stable | +| [297410ae8](https://github.com/angular/angular-cli/commit/297410ae860860d71905639cf38b49ff05813845) | fix: handle undefined entrypoints when marking async chunks | + +### @ngtools/webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| [b7199f366](https://github.com/angular/angular-cli/commit/b7199f366841d976b502ad5f1923e24ea2f6b302) | fix: update Angular peer dependencies to 12.2 stable | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott and Simon Primetzhofer + + + + + +# 12.2.1 (2021-08-11) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| [8dc3c895a](https://github.com/angular/angular-cli/commit/8dc3c895a6531316e672031c8d0815781f0c089a) | fix(@angular/cli): show error when using non-TTY terminal without passing `--skip-confirmation` during `ng add` | + +### @angular-devkit/schematics-cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| [eded01270](https://github.com/angular/angular-cli/commit/eded01270f9aa70f6ba4806a068de8d1c0a52454) | fix(@angular-devkit/schematics-cli): log when in debug and/or dry run modes | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| [22e0208a9](https://github.com/angular/angular-cli/commit/22e0208a9ee6257213b3bf93ac61a2c3d4ac9504) | fix(@angular-devkit/build-angular): ensure native async is downlevelled in third-party libraries | +| [9b4b86fb0](https://github.com/angular/angular-cli/commit/9b4b86fb0d9c88a3c714f5eabf925859bb7b71bb) | fix(@angular-devkit/build-angular): support both pure annotation forms for static properties | +| [cea028090](https://github.com/angular/angular-cli/commit/cea0280908db39308ac5fa37374b138ceb79ecea) | fix(@angular-devkit/build-angular): do not consume inline sourcemaps when vendor sourcemaps is disabled. | +| [e7ec0346e](https://github.com/angular/angular-cli/commit/e7ec0346e69c090ded7d9ec6d3574deb79926db0) | fix(@angular-devkit/build-angular): avoid attempting to optimize copied JavaScript assets | +| [4f757c2bc](https://github.com/angular/angular-cli/commit/4f757c2bcf1356d33eaa86bc3b715c0a6b7c2ed8) | fix(@angular-devkit/build-angular): handle null maps in JavaScript optimizer worker | + +## Special Thanks: + +Alan Agius and Charles Lyding + + + + + +# 12.2.0 (2021-08-04) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| [259e26979](https://github.com/angular/angular-cli/commit/259e26979ebc712ee08fd36fb68a9576c1e02447) | fix(@angular/cli): merge npmrc files values | +| [c1eddbdc9](https://github.com/angular/angular-cli/commit/c1eddbdc98631fdfff287ce566d79ed43b601e0f) | fix(@angular/cli): handle `YARN_` environment variables during `ng update` and `ng add` | +| [6b00d1270](https://github.com/angular/angular-cli/commit/6b00d1270acaf33f32ee68c4254ce06951ddcb8c) | fix(@angular/cli): handle NPM_CONFIG environment variables during ng update and ng add | +| [88ee85c41](https://github.com/angular/angular-cli/commit/88ee85c4178e37b72001e8946b70a46ba739a0b7) | fix(@angular/cli): disable update notifier when retrieving package manager version during `ng version` | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| [d750c686f](https://github.com/angular/angular-cli/commit/d750c686fd26f3ccfccb039027bd816a91279497) | fix(@angular-devkit/build-angular): add priority to copy-webpack-plugin patterns | +| [4bcd1dc9e](https://github.com/angular/angular-cli/commit/4bcd1dc9ee744343a465d73d51d4a062964a3714) | fix(@angular-devkit/build-angular): allow classes with pure annotated static properties to be optimized | +| [ceade0c27](https://github.com/angular/angular-cli/commit/ceade0c27e4b8b0e731e6ca5128fd86cf071d029) | fix(@angular-devkit/build-angular): dasherize disable-host-check suggestion | +| [8383c6b42](https://github.com/angular/angular-cli/commit/8383c6b421f7005a25a3bff0826048f3a24f3030) | fix(@angular-devkit/build-angular): silence Sass compiler warnings from 3rd party stylesheets | +| [07763702f](https://github.com/angular/angular-cli/commit/07763702fd244ba44aebb714a295dbf5ba72b91d) | fix(@angular-devkit/build-angular): force linker `sourceMapping` option to false. | +| [a5c69722f](https://github.com/angular/angular-cli/commit/a5c69722ffeceb72dcd46901c2bb983e5dc8bf32) | fix(@angular-devkit/build-angular): ensure `NG_PERSISTENT_BUILD_CACHE` always creates a cache in the specified cache directory | +| [c65b04999](https://github.com/angular/angular-cli/commit/c65b049996a8de9d9fcc66631872424cbe5f13f9) | fix(@angular-devkit/build-angular): fail browser build when index generation fails | +| [3d71c63b3](https://github.com/angular/angular-cli/commit/3d71c63b3a11946ebfca3f0d97d4fbf8dca16255) | fix(@angular-devkit/build-angular): fix issue were `@media all` causing critical CSS inling to fail | +| [9a04975a2](https://github.com/angular/angular-cli/commit/9a04975a2170c3ecc2c09c32bd15a89c613e198f) | fix(@angular-devkit/build-angular): `extractLicenses` didn't have an effect when using server builder | +| [2ac8e9c0e](https://github.com/angular/angular-cli/commit/2ac8e9c0e131bf7fcb2c6e92500eeaa112efcefb) | fix(@angular-devkit/build-angular): display incompatibility errors | +| [2c2b49919](https://github.com/angular/angular-cli/commit/2c2b499193fb319e1c9cb92318610353b7720e2b) | fix(@angular-devkit/build-angular): limit advanced terser passes to two | +| [1be3b0783](https://github.com/angular/angular-cli/commit/1be3b07836659487e4aa9b8c71c673635e268a60) | fix(@angular-devkit/build-angular): exclude `outputPath` from persistent build cache key | +| [fefd6d042](https://github.com/angular/angular-cli/commit/fefd6d04213e61d3f48c0484d8c6a8dcff1ecd34) | perf(@angular-devkit/build-angular): use `esbuild` as a CSS optimizer for component styles | +| [18cfa0431](https://github.com/angular/angular-cli/commit/18cfa04317230f934ccba798c080543bb389725f) | feat(@angular-devkit/build-angular): add support to inline Adobe Fonts | +| [9a751f0f8](https://github.com/angular/angular-cli/commit/9a751f0f81919d67f5eeeaecbe807d5c216f6a7a) | fix(@angular-devkit/build-angular): handle `ENOENT` and `ENOTDIR` errors when deleting outputs | +| [41e645792](https://github.com/angular/angular-cli/commit/41e64579213b9d4a7c976ea45daa6b32d980df10) | fix(@angular-devkit/build-angular): downlevel `for await...of` when targeting ES2018+ | +| [070a13364](https://github.com/angular/angular-cli/commit/070a1336478d721bbbb474622f50fab455cda26c) | fix(@angular-devkit/build-angular): configure webpack target in common configuration | +| [da32daa75](https://github.com/angular/angular-cli/commit/da32daa75d08d4be177af5fa16088398d7fb427b) | perf(@angular-devkit/build-angular): use combination of `esbuild` and `terser` as a JavaScript optimizer | +| [6a2b11906](https://github.com/angular/angular-cli/commit/6a2b11906e4173562a82b3654ff662dd05513049) | perf(@angular-devkit/build-angular): cache JavaScriptOptimizerPlugin results | +| [ab17b1721](https://github.com/angular/angular-cli/commit/ab17b1721c05366e592cf805ad6d25e672b314bf) | fix(@angular-devkit/build-angular): handle ng-packagr errors more gracefully. | +| [d4c5f8518](https://github.com/angular/angular-cli/commit/d4c5f8518d4801b9fd76de289a015dcbb8d8f69b) | fix(@angular-devkit/build-angular): control linker template sourcemapping via builder sourcemap options | +| [06181c2fb](https://github.com/angular/angular-cli/commit/06181c2fbf5a20396b2d0e2b3925ceb1276947fb) | fix(@angular-devkit/build-angular): parse web-workers in tests when webWorkerTsConfig is defined | + +### @angular-devkit/build-webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [615353022](https://github.com/angular/angular-cli/commit/61535302204a2a767f85053b7efaa6ac5ac64098) | fix(@angular-devkit/build-webpack): emit result when webpack is closed | + +### @ngtools/webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [dbbcf5c8c](https://github.com/angular/angular-cli/commit/dbbcf5c8c4ec4427609942f4ef7053c1b51773c9) | fix(@ngtools/webpack): only track file dependencies | +| [7536338e0](https://github.com/angular/angular-cli/commit/7536338e0becc7f9cde62becbde58e18a270cb31) | fix(@ngtools/webpack): allow generated assets of Angular component resources | +| [720feee34](https://github.com/angular/angular-cli/commit/720feee34f910fc11c40e2f68d919d61b7d6cbec) | fix(@ngtools/webpack): avoid non-actionable template type-checker syntax diagnostics | +| [6a7bcf330](https://github.com/angular/angular-cli/commit/6a7bcf3300b459aef80fcf98f2475c977f6244dc) | fix(@ngtools/webpack): encode component style data | +| [12c14b565](https://github.com/angular/angular-cli/commit/12c14b56537d65d6986e245ab1ae4dd9aa8dd378) | fix(@ngtools/webpack): remove no longer needed component styles workaround | + +### @schematics/angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [20fd33f6d](https://github.com/angular/angular-cli/commit/20fd33f6d4ce6cef1feb508a0221222e83a85630) | feat(@schematics/angular): destroy test module after every test | +| [5b10d4f54](https://github.com/angular/angular-cli/commit/5b10d4f549ebc12645ad08cba8ab7b91eaa87d28) | fix(@schematics/angular): remove unsafe any usage in application spec file | +| [1b5e18e7b](https://github.com/angular/angular-cli/commit/1b5e18e7b401efb7ec73d99c4d77d9b29e956724) | fix(@schematics/angular): replace interactive `div` with `button` in application component template | +| [0907b6941](https://github.com/angular/angular-cli/commit/0907b694174d6d684d965baf6cd37b87f49742e8) | fix(@schematics/angular): use stricter semver for `karma-jasmine-html-reporter` | +| [8ad1539c5](https://github.com/angular/angular-cli/commit/8ad1539c5e73bad30eb6eb340379d64db208098c) | fix(@schematics/angular): add 'none' value for the 'style' option of the component schematic | +| [e5ba29c7d](https://github.com/angular/angular-cli/commit/e5ba29c7d54cbd83057cf23a21119ea5a3146993) | fix(@schematics/angular): display warning during migrations when using third-party builders | +| [a44dc02fe](https://github.com/angular/angular-cli/commit/a44dc02feecaf8735f2dc6128a5b6cc5666b4434) | fix(@schematics/angular): add devtools to ng new | + +## Special Thanks: + +Alan Agius, Charles Lyding, David Scourfield, Doug Parker, hien-pham, Joey Perrott, LeonEck, Mike +Jancar, twerske, Vaibhav Singh and originalfrostig + + + + + +# 12.2.0-rc.0 (2021-07-28) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| [259e26979](https://github.com/angular/angular-cli/commit/259e26979ebc712ee08fd36fb68a9576c1e02447) | fix(@angular/cli): merge npmrc files values | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [d750c686f](https://github.com/angular/angular-cli/commit/d750c686fd26f3ccfccb039027bd816a91279497) | fix(@angular-devkit/build-angular): add priority to copy-webpack-plugin patterns | + +### @angular-devkit/build-webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [615353022](https://github.com/angular/angular-cli/commit/61535302204a2a767f85053b7efaa6ac5ac64098) | fix(@angular-devkit/build-webpack): emit result when webpack is closed | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott and originalfrostig + + + + + +# 12.1.4 (2021-07-28) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| [e02c97dd0](https://github.com/angular/angular-cli/commit/e02c97dd09399443438b32cf1ad47fa0f7011df3) | fix(@angular/cli): merge npmrc files values | + +### @schematics/angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [cfc267426](https://github.com/angular/angular-cli/commit/cfc267426716e9ecf0c9833720cb35298284f699) | fix(@schematics/angular): ensure valid SemVer range for new project Angular packages | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [55c0bddc8](https://github.com/angular/angular-cli/commit/55c0bddc8b2425309f00733eca96c06f60f867d5) | fix(@angular-devkit/build-angular): add priority to copy-webpack-plugin patterns | + +### @angular-devkit/build-webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [b3736a3c0](https://github.com/angular/angular-cli/commit/b3736a3c09f39f5ee5dc12d98535fe4b6803ea3b) | fix(@angular-devkit/build-webpack): emit result when webpack is closed | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott and originalfrostig + + + + + +# 12.2.0-next.3 (2021-07-21) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [c1eddbdc9](https://github.com/angular/angular-cli/commit/c1eddbdc98631fdfff287ce566d79ed43b601e0f) | fix(@angular/cli): handle `YARN_` environment variables during `ng update` and `ng add` | +| [6b00d1270](https://github.com/angular/angular-cli/commit/6b00d1270acaf33f32ee68c4254ce06951ddcb8c) | fix(@angular/cli): handle NPM_CONFIG environment variables during ng update and ng add | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [4bcd1dc9e](https://github.com/angular/angular-cli/commit/4bcd1dc9ee744343a465d73d51d4a062964a3714) | fix(@angular-devkit/build-angular): allow classes with pure annotated static properties to be optimized | +| [ceade0c27](https://github.com/angular/angular-cli/commit/ceade0c27e4b8b0e731e6ca5128fd86cf071d029) | fix(@angular-devkit/build-angular): dasherize disable-host-check suggestion | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott, LeonEck and Mike Jancar + + + + + +# 12.1.3 (2021-07-21) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [eaa2378b6](https://github.com/angular/angular-cli/commit/eaa2378b6bc69a2485cce742ef95b0b94ae994c6) | fix(@angular/cli): handle `YARN_` environment variables during `ng update` and `ng add` | +| [4b9a41bde](https://github.com/angular/angular-cli/commit/4b9a41bdedcdc4e115e8956d31126c5bf6f442ca) | fix(@angular/cli): handle NPM_CONFIG environment variables during ng update and ng add | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [04e9ffe4f](https://github.com/angular/angular-cli/commit/04e9ffe4f6b262ce5ef630310bed318e1466d238) | fix(@angular-devkit/build-angular): allow classes with pure annotated static properties to be optimized | +| [6ae17e265](https://github.com/angular/angular-cli/commit/6ae17e26547a0174f7a8910c514016db60fe4c7a) | fix(@angular-devkit/build-angular): dasherize disable-host-check suggestion | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott, LeonEck and Mike Jancar + + + + + +# v12.2.0-next.2 (2021-07-14) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.2.0-next.2)

Commit + Description + Notes +
+ + + silence Sass compiler warnings from 3rd party stylesheets + + [Closes #21235]
+
+ +
+ + + force linker `sourceMapping` option to false. + + [Closes #21271]
+
+ +
+ + + ensure `NG_PERSISTENT_BUILD_CACHE` always creates a cache in the specified cache directory +
+ + + fail browser build when index generation fails +
+ + + fix issue were `@media all` causing critical CSS inling to fail + + [Closes #20804]
+
+ +
+ + + `extractLicenses` didn't have an effect when using server builder +
+ + + display incompatibility errors + + [Closes #21322]
+
+ +
+ + + limit advanced terser passes to two +
+ + + exclude `outputPath` from persistent build cache key + + [Closes #21275]
+
+ +
+ + + use `esbuild` as a CSS optimizer for component styles +

@ngtools/webpack (12.2.0-next.2)

Commit + Description + Notes +
+ + + only track file dependencies + + [Closes #21228]
+
+ +
+ + + allow generated assets of Angular component resources +
+ + + avoid non-actionable template type-checker syntax diagnostics +

@schematics/angular (12.2.0-next.2)

Commit + Description + Notes +
+ + + destroy test module after every test + + [Closes #21280]
+
+ +
+ + + remove unsafe any usage in application spec file +
+ + + replace interactive `div` with `button` in application component template +
+ + + use stricter semver for `karma-jasmine-html-reporter` +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott + + + + + +# v12.1.2 (2021-07-14) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.2)

Commit + Description + Notes +
+ + + silence Sass compiler warnings from 3rd party stylesheets + + [Closes #21235]
+
+ +
+ + + ensure `NG_PERSISTENT_BUILD_CACHE` always creates a cache in the specified cache directory +
+ + + force linker `sourceMapping` option to false. + + [Closes #21271]
+
+ +
+ + + fail browser build when index generation fails +
+ + + `extractLicenses` didn't have an effect when using server builder +
+ + + fix issue were `@media all` causing critical CSS inling to fail + + [Closes #20804]
+
+ +
+ + + display incompatibility errors + + [Closes #21322]
+
+ +
+ + + exclude `outputPath` from persistent build cache key + + [Closes #21275]
+
+ +

@ngtools/webpack (12.1.2)

Commit + Description + Notes +
+ + + only track file dependencies + + [Closes #21228]
+
+ +
+ + + allow generated assets of Angular component resources +
+ + + avoid non-actionable template type-checker syntax diagnostics +

@schematics/angular (12.1.2)

Commit + Description + Notes +
+ + + remove unsafe any usage in application spec file +
+ + + replace interactive `div` with `button` in application component template +
+ + + use stricter semver for `karma-jasmine-html-reporter` +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott, Terence D. Honles + + + + + +# v12.1.1 (2021-07-01) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.1)

Commit + Description + Notes +
+ + + handle `ENOENT` and `ENOTDIR` errors when deleting outputs + + [Closes #21202]
+
+ +
+ + + downlevel `for await...of` when targeting ES2018+ + + [Closes #21196]
+
+ +
+ + + configure webpack target in common configuration + + [Closes #21239]
+
+ +
+ + + update `mini-css-extract-plugin` to `1.6.2` +
+ + + update `webpack` to `5.41.1` +

@angular/cli (12.1.1)

Commit + Description + Notes +
+ + + disable update notifier when retrieving package manager version during `ng version` + + [Closes #21172]
+
+ +

@ngtools/webpack (12.1.1)

Commit + Description + Notes +
+ + + encode component style data + + [Closes #21236]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Doug Parker + + + + + +# v12.2.0-next.1 (2021-07-01) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.2.0-next.1)

Commit + Description + Notes +
+ + + add support to inline Adobe Fonts + + [Closes #21186]
+
+ +
+ + + handle `ENOENT` and `ENOTDIR` errors when deleting outputs + + [Closes #21202]
+
+ +
+ + + downlevel `for await...of` when targeting ES2018+ + + [Closes #21196]
+
+ +
+ + + configure webpack target in common configuration + + [Closes #21239]
+
+ +
+ + + use combination of `esbuild` and `terser` as a JavaScript optimizer +
+ + + cache JavaScriptOptimizerPlugin results +

@angular/cli (12.2.0-next.1)

Commit + Description + Notes +
+ + + disable update notifier when retrieving package manager version during `ng version` + + [Closes #21172]
+
+ +

@ngtools/webpack (12.2.0-next.1)

Commit + Description + Notes +
+ + + encode component style data + + [Closes #21236]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Doug Parker + + + + + +# v12.2.0-next.0 (2021-06-24) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0)

Commit + Description + Notes +
+ + + handle ng-packagr errors more gracefully. +
+ + + control linker template sourcemapping via builder sourcemap options +
+ + + parse web-workers in tests when webWorkerTsConfig is defined +

@ngtools/webpack (12.1.0)

Commit + Description + Notes +
+ + + remove no longer needed component styles workaround +

@schematics/angular (12.1.0)

Commit + Description + Notes +
+ + + add 'none' value for the 'style' option of the component schematic +
+ + + display warning during migrations when using third-party builders +
+ + + add devtools to ng new +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Vaibhav Singh, Joey Perrott, twerske, David Scourfield, hien-pham + + + + + +# v12.1.0 (2021-06-24) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0)

Commit + Description + Notes +
+ + + enable webpack Trusted Types support +
+ + + deprecate protractor builder +
+ + + support using TypeScript 4.3 +
+ + + revert open to 8.0.2 + + [Closes #20807]
+
+ +
+ + + correctly ignore inline styles during i18n extraction +
+ + + use the name as chunk filename instead of id +
+ + + handle ng-packagr errors more gracefully. +
+ + + control linker template sourcemapping via builder sourcemap options +
+ + + parse web-workers in tests when webWorkerTsConfig is defined +
+ + + use CSS optimization plugin that leverages workers +
+ + + enable opt-in usage of file system cache +

@angular/cli (12.1.0)

Commit + Description + Notes +
+ + + show Node.js version support status in version command + + [Closes #20879]
+
+ +
+ + + handle unscoped authentication details in `.npmrc` files +
+ + + don't resolve `.npmrc` from parent directories +

@ngtools/webpack (12.1.0)

Commit + Description + Notes +
+ + + support using TypeScript 4.3 +
+ + + remove redundant inline style cache +
+ + + ensure plugin provided Webpack instance is used +
+ + + disable caching for ngcc synchronous Webpack resolver +
+ + + remove no longer needed component styles workaround +

@schematics/angular (12.1.0)

Commit + Description + Notes +
+ + + create new projects with TypeScript 4.3 +
+ + + add migration to replace deprecated `--prod` + + [Closes #21036]
+
+ +
+ + + add 'none' value for the 'style' option of the component schematic +
+ + + display warning during migrations when using third-party builders +
+ + + add devtools to ng new +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Joey Perrott, Bjarki, Vaibhav Singh, twerske, David Scourfield, hien-pham, Alberto Calvo, Paul Gschwendtner, Keen Yee Liau + + + + + +# v12.1.0-next.6 (2021-06-17) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0-next.6)

Commit + Description + Notes +
+ + + don't parse `new Worker` syntax when `webWorkerTsConfig` is not defined in karma builder + + [Closes #21108]
+
+ +
+ + + explicitly set compilation target in test configuration + + [Closes #21111]
+
+ +
+ + + use the name as chunk filename instead of id +
+ + + enable opt-in usage of file system cache +

@angular/cli (12.1.0-next.6)

Commit + Description + Notes +
+ + + handle unscoped authentication details in `.npmrc` files +
+ + + don't resolve `.npmrc` from parent directories +

@schematics/angular (12.1.0-next.6)

Commit + Description + Notes +
+ + + add migration to replace deprecated `--prod` + + [Closes #21036]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Joey Perrott, Alberto Calvo, Charles Lyding + + + + + +# v12.0.5 (2021-06-17) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.5)

Commit + Description + Notes +
+ + + don't parse `new Worker` syntax when `webWorkerTsConfig` is not defined in karma builder + + [Closes #21108]
+
+ +
+ + + explicitly set compilation target in test configuration + + [Closes #21111]
+
+ +

@angular/cli (12.0.5)

Commit + Description + Notes +
+ + + handle unscoped authentication details in .npmrc files +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Joey Perrott + + + + + +# v12.1.0-next.5 (2021-06-10) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0-next.5)

Commit + Description + Notes +
+ + + support using TypeScript 4.3 +
+ + + ensure all Webpack Stats assets are present on rebuilds + + [Closes #21038]
+
+ +
+ + + dispose Sass worker resources on Webpack shutdown + + [Closes #20985]
+
+ +
+ + + show progress during re-builds +
+ + + correctly mark async chunks as non initial in dev-server +
+ + + add web-workers in lazy chunks in stats output + + [Closes #21059]
+
+ +
+ + + styles CSS files not available in unit tests + + [Closes #21054]
+
+ +
+ + + reduce memory usage by cleaning output directory before emitting +

@angular-devkit/schematics (12.1.0-next.5)

Commit + Description + Notes +
+ + + handle updating renamed files + + [Closes #14255]
+
+ + + [Closes #21083]
+
+ +

@angular/cli (12.1.0-next.5)

Commit + Description + Notes +
+ + + avoid shell exec when bootstrapping update command +
+ + + correctly redirect nested Angular schematic dependency requests + + [Closes #21075]
+
+ +

@ngtools/webpack (12.1.0-next.5)

Commit + Description + Notes +
+ + + support using TypeScript 4.3 +
+ + + ensure plugin provided Webpack instance is used +
+ + + disable caching for ngcc synchronous Webpack resolver +

@schematics/angular (12.1.0-next.5)

Commit + Description + Notes +
+ + + create new projects with TypeScript 4.3 +
+ + + added webWorkerTsConfig into test option +
+ + + working with formatting +
+ +--- + +--- + +# Special Thanks + +Charles Lyding, Alan Agius, Doug Parker, Santosh Mahto, Joey Perrott + + + + + +# v12.0.4 (2021-06-09) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.4)

Commit + Description + Notes +
+ + + ensure all Webpack Stats assets are present on rebuilds + + [Closes #21038]
+
+ +
+ + + dispose Sass worker resources on Webpack shutdown + + [Closes #20985]
+
+ +
+ + + show progress during re-builds +
+ + + correctly mark async chunks as non initial in dev-server +
+ + + add web-workers in lazy chunks in stats output + + [Closes #21059]
+
+ +
+ + + styles CSS files not available in unit tests + + [Closes #21054]
+
+ +
+ + + reduce memory usage by cleaning output directory before emitting +

@angular-devkit/schematics (12.0.4)

Commit + Description + Notes +
+ + + handle updating renamed files + + [Closes #14255]
+
+ + + [Closes #21083]
+
+ +

@angular/cli (12.0.4)

Commit + Description + Notes +
+ + + avoid shell exec when bootstrapping update command +
+ + + correctly redirect nested Angular schematic dependency requests + + [Closes #21075]
+
+ +

@ngtools/webpack (12.0.4)

Commit + Description + Notes +
+ + + ensure plugin provided Webpack instance is used +

@schematics/angular (12.0.4)

Commit + Description + Notes +
+ + + added webWorkerTsConfig into test option +
+ + + working with formatting +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Santosh Mahto, Joey Perrott, Doug Parker + + + + + +# v12.0.3 (2021-06-02) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.3)

Commit + Description + Notes +
+ + + do not resolve web-workers in server builds + + [Closes #20877]
+
+ +
+ + + provided earlier build feedback in console + + [Closes #20957]
+
+ +
+ + + correctly ignore inline styles during i18n extraction + + [Closes #20968]
+
+ +
+ + + update `license-webpack-plugin` to `2.3.19` +

@angular-devkit/build-webpack (0.1200.3)

Commit + Description + Notes +
+ + + include only required stats in webpackStats +

@angular-devkit/core (12.0.3)

Commit + Description + Notes +
+ + + show allowed enum values when validation on enum fails +
+ + + handle complex smart defaults in schemas +
+ + + handle async schema validations +
+ + + transform path using getSystemPath for NodeJsAsyncHost's `exists` method +

@angular/cli (12.0.3)

Commit + Description + Notes +
+ + + update supported range of node versions to be less restrictive + + [Closes #20796]
+
+ +

@ngtools/webpack (12.0.3)

Commit + Description + Notes +
+ + + normalize paths when adding file dependencies + + [Closes #20891]
+
+ +
+ + + remove redundant inline style cache +

@schematics/angular (12.0.3)

Commit + Description + Notes +
+ + + make version 12 workspace config migration idempotent + + [Closes #20979]
+
+ +
+ + + show better error when non existing project is passed to the component schematic +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Doug Parker, Charles Lyding, why520crazy + + + + + +# v12.1.0-next.4 (2021-06-02) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0-next.4)

Commit + Description + Notes +
+ + + do not resolve web-workers in server builds + + [Closes #20877]
+
+ +
+ + + provided earlier build feedback in console + + [Closes #20957]
+
+ +
+ + + correctly ignore inline styles during i18n extraction +

@angular-devkit/build-webpack (0.1201.0-next.4)

Commit + Description + Notes +
+ + + include only required stats in webpackStats +

@angular-devkit/core (12.1.0-next.4)

Commit + Description + Notes +
+ + + show allowed enum values when validation on enum fails +
+ + + handle complex smart defaults in schemas +
+ + + handle async schema validations +
+ + + transform path using getSystemPath for NodeJsAsyncHost's `exists` method +

@angular/cli (12.1.0-next.4)

Commit + Description + Notes +
+ + + update supported range of node versions to be less restrictive + + [Closes #20796]
+
+ +

@ngtools/webpack (12.1.0-next.4)

Commit + Description + Notes +
+ + + normalize paths when adding file dependencies + + [Closes #20891]
+
+ +
+ + + remove redundant inline style cache +

@schematics/angular (12.1.0-next.4)

Commit + Description + Notes +
+ + + make version 12 workspace config migration idempotent + + [Closes #20979]
+
+ +
+ + + show better error when non existing project is passed to the component schematic +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Doug Parker, Charles Lyding, why520crazy + + + + + +# v12.1.0-next.3 (2021-05-26) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0-next.3)

Commit + Description + Notes +
+ + + enable webpack Trusted Types support +
+ + + deprecate protractor builder +
+ + + ensure Sass worker implementation supports Node.js 12.14 +
+ + + don't add `.hot-update.js` script tags + + [Closes #20855]
+
+ +
+ + + correctly generate ServiceWorker config on Windows + + [Closes #20894]
+
+ +
+ + + ensure latest inline stylesheet data is used during rebuilds +
+ + + allow i18n extraction on application that uses web-workers + + [Closes #20930]
+
+ +
+ + + hide stacktraces from dart-sass errors +
+ + + resolve absolute outputPath properly + + [Closes #20935]
+
+ +
+ + + show `--disable-host-check` warning only when not using `disableHostCheck` + + [Closes #20951]
+
+ +
+ + + disable CSS optimization parallelism for components styles + + [Closes #20883]
+
+ +
+ + + load postcss-preset-env configuration once +

@angular/cli (12.1.0-next.3)

Commit + Description + Notes +
+ + + show Node.js version support status in version command + + [Closes #20879]
+
+ +
+ + + ng update on windows to allow path +

@ngtools/webpack (12.1.0-next.3)

Commit + Description + Notes +
+ + + re-emit component stylesheet assets + + [Closes #20882]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Bjarki, Hassan Sani, JoostK, George Kalpakas, Joey Perrott + + + + + +# v12.0.2 (2021-05-26) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.2)

Commit + Description + Notes +
+ + + ensure Sass worker implementation supports Node.js 12.14 +
+ + + don't add `.hot-update.js` script tags + + [Closes #20855]
+
+ +
+ + + correctly generate ServiceWorker config on Windows + + [Closes #20894]
+
+ +
+ + + ensure latest inline stylesheet data is used during rebuilds +
+ + + allow i18n extraction on application that uses web-workers + + [Closes #20930]
+
+ +
+ + + hide stacktraces from dart-sass errors +
+ + + resolve absolute outputPath properly + + [Closes #20935]
+
+ +
+ + + show `--disable-host-check` warning only when not using `disableHostCheck` + + [Closes #20951]
+
+ +
+ + + update PostCSS to 8.3 +
+ + + disable CSS optimization parallelism for components styles + + [Closes #20883]
+
+ +
+ + + load postcss-preset-env configuration once +

@angular/cli (12.0.2)

Commit + Description + Notes +
+ + + ng update on windows to allow path +

@ngtools/webpack (12.0.2)

Commit + Description + Notes +
+ + + re-emit component stylesheet assets + + [Closes #20882]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Hassan Sani, JoostK, George Kalpakas, Joey Perrott + + + + + +# v12.0.1 (2021-05-19) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.1)

Commit + Description + Notes +
+ + + add experimental web-assembly + + [Closes #20762]
+
+ +
+ + + fix error with inline styles when running extract-i18n +
+ + + add `NG_BUILD_MAX_WORKERS` settimgs to control maximum number of workers +
+ + + non injected styles should not count as initial + + [Closes #20781]
+
+ +
+ + + revert open to 8.0.2 + + [Closes #20807]
+
+ +
+ + + correctly resolve babel runtime helpers + + [Closes #20800]
+
+ +
+ + + compile schema in synchronously + + [Closes #20847]
+
+ +
+ + + execute dart-sass in a worker +
+ + + reduce JSON stats +
+ + + use CSS optimization plugin that leverages workers +
+ + + render Sass using a pool of workers +
+ + + clean no-longer used assets during builds +

@angular/cli (12.0.1)

Commit + Description + Notes +
+ + + cannot locate bin for temporary package +
+ + + clean node modules directory prior to updating +
+ + + improve `--prod` deprecation warning + + [Closes #20806]
+
+ +

@ngtools/webpack (12.0.1)

Commit + Description + Notes +
+ + + reduce non-watch mode TypeScript diagnostic analysis overhead +

@schematics/angular (12.0.1)

Commit + Description + Notes +
+ + + remove --prod option from README template +
+ + + don't add `skipTest` option to module schematic options + + [Closes #20811]
+
+ +
+ + + add migration to remove `skipTests` from `@schematics/angular:module` + + [Closes #20848]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott, Keen Yee Liau, Luca Vazzano, Pankaj Patil, Ryan Lester, Terence D. Honles, Alan Cohen + + + + + +# v12.1.0-next.2 (2021-05-19) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.1.0-next.2)

Commit + Description + Notes +
+ + + add experimental web-assembly + + [Closes #20762]
+
+ +
+ + + add `NG_BUILD_MAX_WORKERS` settimgs to control maximum number of workers +
+ + + non injected styles should not count as initial + + [Closes #20781]
+
+ +
+ + + revert open to 8.0.2 + + [Closes #20807]
+
+ +
+ + + correctly resolve babel runtime helpers + + [Closes #20800]
+
+ +
+ + + compile schema in synchronously + + [Closes #20847]
+
+ +
+ + + execute dart-sass in a worker +
+ + + reduce JSON stats +
+ + + use CSS optimization plugin that leverages workers +
+ + + render Sass using a pool of workers +
+ + + clean no-longer used assets during builds +

@angular/cli (12.1.0-next.2)

Commit + Description + Notes +
+ + + cannot locate bin for temporary package +
+ + + clean node modules directory prior to updating +
+ + + improve `--prod` deprecation warning + + [Closes #20806]
+
+ +

@ngtools/webpack (12.1.0-next.2)

Commit + Description + Notes +
+ + + reduce non-watch mode TypeScript diagnostic analysis overhead +

@schematics/angular (12.1.0-next.2)

Commit + Description + Notes +
+ + + remove --prod option from README template +
+ + + don't add `skipTest` option to module schematic options + + [Closes #20811]
+
+ +
+ + + add migration to remove `skipTests` from `@schematics/angular:module` + + [Closes #20848]
+
+ +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott, Keen Yee Liau, Luca Vazzano, Pankaj Patil, Ryan Lester, Alan Cohen, Paul Gschwendtner + + + + + +# v12.0.0 (2021-05-12) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/architect (0.1200.0)

Commit + Description + Notes +
+ + + add implementation for defaultConfiguration +

@angular-devkit/build-angular (12.0.0)

Commit + Description + Notes +
+ + + add `postcss-preset-env` with stage 3 features +
+ + + drop support for karma version 5.2 +
+ + + drop support for ng-packagr version 11 +
+ + + enable inlineCritical by default +
+ + + show warning during build when project requires IE 11 support +
+ + + expose legacy-migrate message format +
+ + + integrate JIT mode linker + + [Closes #20281]
+
+ +
+ + + upgrade to Webpack 5 throughout the build system +
+ + + support processing component inline CSS styles +
+ + + support specifying stylesheet language for inline component styles +
+ + + remove left-over `experimentalRollupPass` option +
+ + + support writing large Webpack stat outputs +
+ + + ensure output directory is present before writing stats JSON +
+ + + remove deprecated View Engine support for i18n extraction +
+ + + remove usage of deprecated View Engine compiler +
+ + + remove deprecated i18nLocale and i18nFormat options from i18n-extract +
+ + + update karma builder to use non-deprecated API +
+ + + disable webpack cache when using `NG_BUILD_CACHE` +
+ + + remove duplicate application bundle generation complete message +
+ + + mark programmatic builder execution functions as experimental +
+ + + avoid double build optimizer processing +
+ + + replace Webpack 4 `hashForChunk` hook usage +
+ + + use new Webpack watch API in karma webpack plugin +
+ + + recover from CSS optimization errors +
+ + + disable Webpack 5 automatic public path support +
+ + + always inject live reload client when using live reload +
+ + + change several builder options defaults +
+ + + show warning when using stylus +
+ + + avoid triggering file change after file build +
+ + + remove left-over `forkTypeChecker` option +
+ + + disable CSS declaration sorting optimizations + + [Closes #20693]
+
+ +
+ + + disable `showCircularDependencies` by default +
+ + + use Webpack's GC memory caching in watch mode +
+ + + improve incremental time during Karma tests +
+ + + avoid async downlevel for known ES2015 code +

@angular-devkit/build-optimizer (0.1200.0)

Commit + Description + Notes +
+ + + support Webpack 5 +

@angular-devkit/build-webpack (0.1200.0)

Commit + Description + Notes +
+ + + provide output path in builder results +
+ + + support Webpack 5 +

@angular-devkit/core (12.0.0)

Commit + Description + Notes +
+ + + add handling for `defaultConfiguration` target definition property +
+ + + update schema validator +
+ + + ensure job input values are processed in order +
+ + + improve handling of set schema values + + [Closes #20594]
+
+ +

@angular/cli (12.0.0)

Commit + Description + Notes +
+ + + add `defaultConfiguration` property to architect schema +
+ + + deprecate `--prod` command line argument +
+ + + confirm ng add action before installation +
+ + + support TypeScript 4.2 +
+ + + ensure odd number Node.js version message is a warning +
+ + + remove npm 7 incompatibility notification +
+ + + avoid exceptions for expected errors in architect commands +
+ + + ensure update migrations are fully executed +
+ + + exclude deprecated packages with removal migrations from update +
+ + + add message update updating from non LTS versions of the CLI +
+ + + ignore `tsickle` during updates +
+ + + run all migrations when updating from or between prereleases +
+ + + add package manager name and version in `ng version` output +
+ + + Support XDG Base Directory Specification +
+ + + don't display options multiple times in schematics help output +
+ + + change package installation to async +
+ + + infer schematic defaults correctly when using `--project` + + [Closes #20666]
+
+ +
+ + + propagate update's force option to package managers +
+ + + allow unsetting config when value is `undefined` +
+ + + allow config object to be of JSON. +
+ + + disallow additional properties in builders sections +

@ngtools/webpack (12.0.0)

Commit + Description + Notes +
+ + + support Webpack 5 +
+ + + drop support for string based lazy loading +
+ + + support multiple plugin instances per compilation +
+ + + support generating data URIs for inline component styles in JIT +
+ + + support processing inline component styles in AOT +
+ + + remove Webpack 5 deprecation warning in resource loader +
+ + + use correct Webpack asset stage in resource loader +
+ + + remove Webpack plugin for deprecated ViewEngine compiler +
+ + + only track actual resource file dependencies +
+ + + avoid adding transitive dependencies to Webpack's dependency graph +
+ + + use precalculated dependencies in unused file check +
+ + + only check affected files for Angular semantic diagnostics +
+ + + cache results of processed inline resources +
+ + + rebuild Angular required files asynchronously +
+ + + reduce source file and Webpack module iteration +

@schematics/angular (12.0.0)

Commit + Description + Notes +
+ + + add migration to remove deprecated options from 'angular.json' +
+ + + strict mode by default +
+ + + use new zone.js entry-points +
+ + + add migration to use new zone.js entry-points +
+ + + add migration to remove emitDecoratorMetadata +
+ + + augment `universal` schematics to import `platform-server` shims + + [Closes #40559]
+
+ +
+ + + update new project dependencies version + + [Closes #20106]
+
+ +
+ + + production builds by default +
+ + + deprecate `legacyBrowsers` application and ng-new option +
+ + + add migration to remove `lazyModules` configuration option +
+ + + add migration to update lazy loading string syntax to use dynamic imports +
+ + + update several TypeScript compilation target (Syntax) +
+ + + remove tslint and codelyzer from new projects + + [Closes #20105]
+
+ + + [Closes #18465]
+
+ +
+ + + add production by default optional migration +
+ + + update new workspaces to use Karma 6.3 +
+ + + remove `entryComponent` from `component` schematic +
+ + + configure new libraries to be published in Ivy partial mode +
+ + + update `jasmine-spec-reporter` to version 7 +
+ + + migrate web workers to support Webpack 5 +
+ + + only update removed v12 options in migration +
+ + + add `additionalProperties` to all schemas +
+ + + remove references to the prod flag +
+ + + only show legacy browsers deprecation warning when option is used +
+ + + remove leftover workspace tslint config +
+ + + correctly handle adding multi-line strings to `@NgModule` metadata +
+ + + run update-i18n migration for server builder +
+ + + update web-worker to support Webpack 5 +
+ + + set `inlineStyleLanguage` when application `style` option is used +
+ + + set `inlineStyleLanguage` for universal if present in build options +
+ + + remove jasmine-spec-reporter and ts-node from default workspace +
+ + + remove Protractor from home page +
+ + + remove lint command from package.json + + [Closes #20618]
+
+ +
+ + + fix migration for namedChunks and option +
+ + + add "type" option in enum schematic +
+ + + only run `emitDecoratorMetadata` removal migration in safe workspaces +
+ + + replace `clientProject` with `project` +
+ +--- + +  + +# Breaking Changes + +

+ @schematics/angular: remove `stylus` from `style` options (fd729ac) +

+`styl` (Stylus) is no longer a supported value as `style` in `application`, `component`, `ng-new` schematics. Stylus is not actively maintained and only 0.3% of the Angular CLI users use it. + +(cherry picked from commit 0272fc55b67d1a3f986b996c8eb21aea31eedf51) + +

+ @angular-devkit/build-angular: change several builder options defaults (656f8d7) +

+A number of browser and server builder options have had their default values changed. The aim of these changes is to reduce the configuration complexity and support the new "production builds by default" initiative. + +**Browser builder** + +| Option | Previous default value | New default value | +| --------------- | ---------------------- | ----------------- | +| optimization | false | true | +| aot | false | true | +| buildOptimizer | false | true | +| sourceMap | true | false | +| extractLicenses | false | true | +| namedChunks | true | false | +| vendorChunk | true | false | + +**Server builder** + +| Option | Previous default value | New default value | +| ------------ | ---------------------- | ----------------- | +| optimization | false | true | +| sourceMap | true | false | + +(cherry picked from commit 0a74d0d28daf68510459ed73ef048c91bfcabbbc) + +

+ @angular-devkit/core: update schema validator (0875313) +

+support for JSON Schema draft-04 and draft-06 is removed. If you have schemas using the `id` keyword replace them with `$id`. For an interim period we will auto rename any top level `id` keyword to `$id`. + +**NB**: This change only effects schematics and builders authors. + +

+ @angular-devkit/build-angular: upgrade to Webpack 5 throughout the build system (d883ce5) +

+Webpack 5 lazy loaded file name changes +Webpack 5 generates similar but differently named files for lazy loaded JavaScript files in development configurations (when the `namedChunks` option is enabled). +For the majority of users this change should have no effect on the application and/or build process. Production builds should also not be affected as the `namedChunks` option is disabled by default in production configurations. +However, if a project's post-build process makes assumptions as to the file names then adjustments may need to be made to account for the new naming paradigm. +Such post-build processes could include custom file transformations after the build, integration into service-side frameworks, or deployment procedures. +Example development file name change: `lazy-lazy-module.js` --> `src_app_lazy_lazy_module_ts.js` + +Webpack 5 now includes web worker support. However, the structure of the URL within the `Worker` constructor must be in a specific format that differs from the current requirement. +Web worker usage should be updated as shown below (where `./app.worker` should be replaced with the actual worker name): +Before: `new Worker('./app.worker', ...)` +After: `new Worker(new URL('./app.worker', import.meta.url), ...)` + +

+ @ngtools/webpack: remove Webpack plugin for deprecated ViewEngine compiler (160102a) +

+Removal of View Engine support from application builds +With the removal of the deprecated View Engine compiler in Angular version 12 for applications, the View Engine Webpack plugin has been removed. +The Ivy-based Webpack plugin is the default used within the Angular CLI. +If using a custom standalone Webpack configuration, the removed `AngularCompilerPlugin` should be replaced with the Ivy-based `AngularWebpackPlugin`. + +

+ @angular-devkit/build-angular: remove deprecated i18n options from server and browser builder (5cf9a08) +

+Removal of deprecated browser and server command options. +- `i18nFile`, use `locales` object in the project metadata instead. +- `i18nFormat`, No longer needed as the format will be determined automatically. +- `i18nLocale`, use `localize` option instead. + +

+ @angular-devkit/build-angular: remove deprecated i18nLocale and i18nFormat options from i18n-extract (eca5a01) +

+Removal of deprecated `extract-i18n` command options +The deprecated `i18nLocale` option has been removed and the `i18n.sourceLocale` within a project's configuration should be used instead. +The deprecated `i18nFormat` option has been removed and the `format` option should be used instead. + +

+ @angular-devkit/build-angular: remove usage of deprecated View Engine compiler (677913f) +

+Removal of View Engine support from application builds +With the removal of the deprecated View Engine compiler in Angular version 12 for applications, Ivy-based compilation will always be used when building an application. +The default behavior for applications is to use the Ivy compiler when building and no changes are required for these applications. +For applications that have opted-out of Ivy, a warning will be shown and an Ivy-based build will be attempted. If the build fails, +the application may need to be updated to become Ivy compatible. + +

+ @schematics/angular: remove `entryComponent` from `component` schematic (8582ddc) +

+`entryComponent` option has been removed from the `component` schematic as this was intended to be used with the now no longer supported ViewEngine rendering engine. + +

+ @angular-devkit/build-angular: remove view engine app-shell generation (1c2aeeb) +

+App-shell builder now only supports generation using Ivy + +

+ @angular-devkit/build-angular: remove deprecated View Engine support for i18n extraction (012700a) +

+Removal of View Engine support from i18n extraction +With the removal of the deprecated View Engine compiler in Angular version 12 for applications, the `ng extract-i18n` command will now always use the Ivy compiler. +The `--ivy` option has also been removed as Ivy-based extraction is always enabled. +The default behavior for applications is to use the Ivy compiler for building/extraction and no changes are required for these applications. +For applications that have opted-out of Ivy, a warning will be shown and Ivy-based extraction will be attempted. If the extraction fails, +the application may need to be updated to become Ivy compatible. + +

+ @angular/cli: confirm ng add action before installation (985dc1a) +

+The `ng add` command will now ask the user to confirm the package and version prior to installing and executing an uninstalled package. +This new behavior allows a user to abort the action if the version selected is not appropriate or if a typo occurred on the command line and an incorrect package would be installed. +A `--skip-confirmation` option has been added to skip the prompt and directly install and execute the package. This option is useful in CI and non-TTY scenarios such as automated scripts. + +

+ @angular-devkit/build-angular: remove deprecated `lazyModules` option (8d66912) +

+Server and Browser builder `lazyModules` option has been removed without replacement. + +

+ @ngtools/webpack: drop support for string based lazy loading (0dc7327) +

+With this change we drop support for string based lazy loading `./lazy.module#LazyModule` use dynamic imports instead. + +The following options which were used to support the above syntax were removed without replacement. + +- discoverLazyRoutes +- additionalLazyModules +- additionalLazyModuleResources +- contextElementDependencyConstructor + +

+ @angular-devkit/build-angular: enable inlineCritical by default (aa3ea88) +

+Critical CSS inlining is now enabled by default. If you wish to turn this off set `inlineCritical` to `false`. + +See: https://angular.dev/reference/configs/workspace-config#optimization-configuration + +

+ @angular-devkit/build-angular: drop support for zone.js 0.10 (f309516) +

+Minimum supported `zone.js` version is `0.11.4` + +

+ @angular-devkit/build-angular: drop support for ng-packagr version 11 (44e75be) +

+Minimum supported `ng-packagr` version is `12.0.0-next` + +

+ @angular-devkit/build-angular: drop support for karma version 5.2 (fa5cf53) +

+Minimum supported `karma` version is `6.0.0` + +

+ set minimum Node.js version to 12.13 (d1f6169) +

+Node.js version 10 will become EOL on 2021-04-30. +Angular CLI 12 will require Node.js 12.13+ or 14.15+. Node.js 12.13 and 14.15 are the first LTS releases for their respective majors. + +

+ @angular-devkit/build-angular: remove file-loader dependency (6732294) +

+The unsupported/undocumented, Webpack specific functionality to `import`/`require()` a non-module file has been removed. + +Before + +```js +import img from './images/asset.png'; +``` + +After + +```html + +``` + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Keen Yee Liau, Joey Perrott, Doug Parker, Cédric Exbrayat, Douglas Parker, George Kalpakas, Sam Bulatov, Joshua Chapman, Santosh Yadav, David Shevitz, Kristiyan Kostadinov + + + + + +# v12.0.0-rc.3 (2021-05-10) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular/cli (12.0.0-rc.3)

Commit + Description + Notes +
+ + + propagate update's force option to package managers +
+ + + allow unsetting config when value is `undefined` +
+ + + allow config object to be of JSON. +
+ + + disallow additional properties in builders sections +
+ +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott + + + + + +# v12.0.0-rc.2 (2021-05-05) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.0-rc.2)

Commit + Description + Notes +
+ + + disable CSS declaration sorting optimizations + + [Closes #20693]
+
+ +

@angular/cli (12.0.0-rc.2)

Commit + Description + Notes +
+ + + don't display options multiple times in schematics help output +
+ + + change package installation to async +
+ + + infer schematic defaults correctly when using `--project` + + [Closes #20666]
+
+ +

@ngtools/webpack (12.0.0-rc.2)

Commit + Description + Notes +
+ + + rebuild Angular required files asynchronously +
+ + + reduce source file and Webpack module iteration +

@schematics/angular (12.0.0-rc.2)

Commit + Description + Notes +
+ + + add "type" option in enum schematic +
+ + + only run `emitDecoratorMetadata` removal migration in safe workspaces +
+ + + replace `clientProject` with `project` +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Keen Yee Liau, Sam Bulatov, Doug Parker + + + + + +# v12.0.0-rc.1 (2021-04-28) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.0-rc.1)

Commit + Description + Notes +
+ + + remove left-over `forkTypeChecker` option +
+ + + output webpack-dev-server and webpack-dev-middleware errors +
+ + + improve incremental time during Karma tests +
+ + + avoid async downlevel for known ES2015 code +

@angular-devkit/core (12.0.0-rc.1)

Commit + Description + Notes +
+ + + improve handling of set schema values + + [Closes #20594]
+
+ +

@angular/cli (12.0.0-rc.1)

Commit + Description + Notes +
+ + + add package manager name and version in `ng version` output +
+ + + Support XDG Base Directory Specification +

@schematics/angular (12.0.0-rc.1)

Commit + Description + Notes +
+ + + remove jasmine-spec-reporter and ts-node from default workspace +
+ + + remove Protractor from home page +
+ + + remove lint command from package.json + + [Closes #20618]
+
+ +
+ + + avoid unuse imports for canLoad guard generation +
+ + + fix migration for namedChunks and option +

@angular-devkit/schematics-cli (12.0.0-rc.1)

Commit + Description + Notes +
+ + + accept windows like paths for schematics +
+ +--- + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott, Cédric Exbrayat, Doug Parker, Joshua Chapman, Billy Lando, Santosh Yadav, mzocateli + + + + + +# v12.0.0-rc.0 (2021-04-21) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.0-rc.0)

Commit + Description + Notes +
+ + + avoid double build optimizer processing +
+ + + replace Webpack 4 `hashForChunk` hook usage +
+ + + use new Webpack watch API in karma webpack plugin +
+ + + recover from CSS optimization errors +
+ + + disable Webpack 5 automatic public path support +
+ + + always inject live reload client when using live reload +
+ + + change several builder options defaults +
+ + + show warning when using stylus +
+ + + set Tailwind CSS mode when using Tailwind +
+ + + avoid triggering file change after file build +
+ + + use Webpack's GC memory caching in watch mode +

@angular/cli (12.0.0-rc.0)

Commit + Description + Notes +
+ + + ignore `tsickle` during updates +
+ + + run all migrations when updating from or between prereleases +

@ngtools/webpack (12.0.0-rc.0)

Commit + Description + Notes +
+ + + only track actual resource file dependencies +
+ + + cache results of processed inline resources +

@schematics/angular (12.0.0-rc.0)

Commit + Description + Notes +
+ + + set `inlineStyleLanguage` when application `style` option is used +
+ + + set `inlineStyleLanguage` for universal if present in build options +
+ +--- + +# Breaking Changes + +

+ @schematics/angular: remove `stylus` from `style` options (fd729ac) +

+`styl` (Stylus) is no longer a supported value as `style` in `application`, `component`, `ng-new` schematics. Stylus is not actively maintained and only 0.3% of the Angular CLI users use it. + +(cherry picked from commit 0272fc55b67d1a3f986b996c8eb21aea31eedf51) + +

+ @angular-devkit/build-angular: change several builder options defaults (656f8d7) +

+A number of browser and server builder options have had their default values changed. The aim of these changes is to reduce the configuration complexity and support the new "production builds by default" initiative. + +**Browser builder** + +| Option | Previous default value | New default value | +| --------------- | ---------------------- | ----------------- | +| optimization | false | true | +| aot | false | true | +| buildOptimizer | false | true | +| sourceMap | true | false | +| extractLicenses | false | true | +| namedChunks | true | false | +| vendorChunk | true | false | + +**Server builder** + +| Option | Previous default value | New default value | +| ------------ | ---------------------- | ----------------- | +| optimization | false | true | +| sourceMap | true | false | + +(cherry picked from commit 0a74d0d28daf68510459ed73ef048c91bfcabbbc) + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Keen Yee Liau, Joey Perrott, David Shevitz + + + + + +# v12.0.0-next.9 (2021-04-14) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (12.0.0-next.9)

Commit + Description + Notes +
+ + + upgrade to Webpack 5 throughout the build system +
+ + + support processing component inline CSS styles +
+ + + support specifying stylesheet language for inline component styles +
+ + + update karma builder to use non-deprecated API +
+ + + disable webpack cache when using `NG_BUILD_CACHE` +
+ + + remove duplicate application bundle generation complete message +
+ + + mark programmatic builder execution functions as experimental +

@angular-devkit/build-webpack (0.1200.0-next.9)

Commit + Description + Notes +
+ + + support Webpack 5 +

@angular-devkit/core (12.0.0-next.9)

Commit + Description + Notes +
+ + + update schema validator +

@angular/cli (12.0.0-next.9)

Commit + Description + Notes +
+ + + add message update updating from non LTS versions of the CLI +

@ngtools/webpack (12.0.0-next.9)

Commit + Description + Notes +
+ + + support multiple plugin instances per compilation +
+ + + support generating data URIs for inline component styles in JIT +
+ + + support processing inline component styles in AOT +

@schematics/angular (12.0.0-next.9)

Commit + Description + Notes +
+ + + configure new libraries to be published in Ivy partial mode +
+ + + update `jasmine-spec-reporter` to version 7 +
+ + + migrate web workers to support Webpack 5 +
+ + + update web-worker to support Webpack 5 +
+ +--- + +# Breaking Changes + +

+ @angular-devkit/core: update schema validator (0875313) +

+support for JSON Schema draft-04 and draft-06 is removed. If you have schemas using the `id` keyword replace them with `$id`. For an interim period we will auto rename any top level `id` keyword to `$id`. + +**NB**: This change only effects schematics and builders authors. + +

+ @angular-devkit/build-angular: upgrade to Webpack 5 throughout the build system (d883ce5) +

+Webpack 5 generates similar but differently named files for lazy loaded JavaScript files in development configurations (when the `namedChunks` option is enabled). +For the majority of users this change should have no effect on the application and/or build process. Production builds should also not be affected as the `namedChunks` option is disabled by default in production configurations. +However, if a project's post-build process makes assumptions as to the file names then adjustments may need to be made to account for the new naming paradigm. +Such post-build processes could include custom file transformations after the build, integration into service-side frameworks, or deployment procedures. +Example development file name change: `lazy-lazy-module.js` --> `src_app_lazy_lazy_module_ts.js` + +

+ @angular-devkit/build-angular: upgrade to Webpack 5 throughout the build system (d883ce5) +

+Webpack 5 now includes web worker support. However, the structure of the URL within the `Worker` constructor must be in a specific format that differs from the current requirement. +Web worker usage should be updated as shown below (where `./app.worker` should be replaced with the actual worker name): + +Before: + +``` +new Worker('./app.worker', ...) +``` + +After: + +``` +new Worker(new URL('./app.worker', import.meta.url), ...) +``` + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Keen Yee Liau, Doug Parker, Douglas Parker + + + + + +# v12.0.0-next.8 (2021-04-07) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.8)

Commit + Description + Notes +
+ + + remove deprecated i18nLocale and i18nFormat options from i18n-extract +

@ngtools/webpack (12.0.0-next.8)

Commit + Description + Notes +
+ + + remove Webpack plugin for deprecated ViewEngine compiler +

@schematics/angular (12.0.0-next.8)

Commit + Description + Notes +
+ + + run update-i18n migration for server builder +
+ +--- + +# Breaking Changes + +

+ @ngtools/webpack: remove Webpack plugin for deprecated ViewEngine compiler (160102a) +

+Removal of View Engine support from application builds +With the removal of the deprecated View Engine compiler in Angular version 12 for applications, the View Engine Webpack plugin has been removed. +The Ivy-based Webpack plugin is the default used within the Angular CLI. +If using a custom standalone Webpack configuration, the removed `AngularCompilerPlugin` should be replaced with the Ivy-based `AngularWebpackPlugin`. + +

+ @angular-devkit/build-angular: remove deprecated i18n options from server and browser builder (5cf9a08) +

+Removal of deprecated browser and server command options. +- `i18nFile`, use `locales` object in the project metadata instead. +- `i18nFormat`, No longer needed as the format will be determined automatically. +- `i18nLocale`, use `localize` option instead. + +

+ @angular-devkit/build-angular: remove deprecated i18nLocale and i18nFormat options from i18n-extract (eca5a01) +

+Removal of deprecated `extract-i18n` command options +The deprecated `i18nLocale` option has been removed and the `i18n.sourceLocale` within a project's configuration should be used instead. +The deprecated `i18nFormat` option has been removed and the `format` option should be used instead. + +--- + +# Special Thanks + +Charles Lyding, Renovate Bot, Alan Agius, Doug Parker, Joey Perrott + + + + + +# v12.0.0-next.7 (2021-04-02) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.7)

Commit + Description + Notes +
+ + + validate scripts and styles bundleName + + [Closes #20360]
+
+ +
+ + + remove deprecated View Engine support for i18n extraction +
+ + + remove usage of deprecated View Engine compiler +

@angular/cli (12.0.0-next.7)

Commit + Description + Notes +
+ + + ensure update migrations are fully executed +
+ + + exclude deprecated packages with removal migrations from update +

@ngtools/webpack (12.0.0-next.7)

Commit + Description + Notes +
+ + + use correct Webpack asset stage in resource loader +
+ + + only check affected files for Angular semantic diagnostics +

@schematics/angular (12.0.0-next.7)

Commit + Description + Notes +
+ + + remove `entryComponent` from `component` schematic +
+ + + correctly handle adding multi-line strings to `@NgModule` metadata +
+ + + explicitly specify ServiceWorker registration strategy +
+ +--- + +# Breaking Changes + +

+ @angular-devkit/build-angular: remove usage of deprecated View Engine compiler (677913f) +

+Removal of View Engine support from application builds +With the removal of the deprecated View Engine compiler in Angular version 12 for applications, Ivy-based compilation will always be used when building an application. +The default behavior for applications is to use the Ivy compiler when building and no changes are required for these applications. +For applications that have opted-out of Ivy, a warning will be shown and an Ivy-based build will be attempted. If the build fails, +the application may need to be updated to become Ivy compatible. + +

+ @schematics/angular: remove `entryComponent` from `component` schematic (8582ddc) +

+`entryComponent` option has been removed from the `component` schematic as this was intended to be used with the now no longer supported ViewEngine rendering engine. + +

+ @angular-devkit/build-angular: remove view engine app-shell generation (1c2aeeb) +

+App-shell builder now only supports generation using Ivy + +

+ @angular-devkit/build-angular: remove deprecated View Engine support for i18n extraction (012700a) +

+Removal of View Engine support from i18n extraction +With the removal of the deprecated View Engine compiler in Angular version 12 for applications, the `ng extract-i18n` command will now always use the Ivy compiler. +The `--ivy` option has also been removed as Ivy-based extraction is always enabled. +The default behavior for applications is to use the Ivy compiler for building/extraction and no changes are required for these applications. +For applications that have opted-out of Ivy, a warning will be shown and Ivy-based extraction will be attempted. If the extraction fails, +the application may need to be updated to become Ivy compatible. + +--- + +# Special Thanks + +Charles Lyding, Alan Agius, Renovate Bot, George Kalpakas, Joey Perrott, Keen Yee Liau + + + + + +# v12.0.0-next.6 (2021-03-24) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.6)

Commit + Description + Notes +
+ + + ensure output directory is present before writing stats JSON +

@schematics/angular (12.0.0-next.6)

Commit + Description + Notes +
+ + + add production by default optional migration +
+ + + update new workspaces to use Karma 6.3 +
+ + + remove leftover workspace tslint config +
+ +--- + +--- + +# Special Thanks + +Renovate Bot, Alan Agius, Charles Lyding, Keen Yee Liau + + + + + +# v12.0.0-next.5 (2021-03-18) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.5)

Commit + Description + Notes +
+ + + expose legacy-migrate message format +
+ + + integrate JIT mode linker + + [Closes #20281]
+
+ +
+ + + display correct filename for bundles that are ES2016+ +
+ + + don't load an input sourcemap from file when using Babel +
+ + + support writing large Webpack stat outputs +
+ + + skip FESM2015 from `async` transformation +
+ + + remove Webpack Stats.toJson usage in analytics plugin +
+ + + remove Webpack Stats.toJson usage in karma plugin +
+ + + enforce Babel not to load sourcemaps from file +
+ + + disable `showCircularDependencies` by default +

@angular-devkit/build-webpack (0.1200.0-next.5)

Commit + Description + Notes +
+ + + provide output path in builder results +

@angular/cli (12.0.0-next.5)

Commit + Description + Notes +
+ + + confirm ng add action before installation +
+ + + support TypeScript 4.2 +
+ + + remove `project` from required properties in ng-packagr schema +

@ngtools/webpack (12.0.0-next.5)

Commit + Description + Notes +
+ + + remove Webpack 5 deprecation warning in resource loader +
+ + + avoid adding transitive dependencies to Webpack's dependency graph +
+ + + use precalculated dependencies in unused file check +

@schematics/angular (12.0.0-next.5)

Commit + Description + Notes +
+ + + update several TypeScript compilation target (Syntax) +
+ + + remove tslint and codelyzer from new projects + + [Closes #20105]
+
+ + + [Closes #18465]
+
+ +
+ + + remove references to the prod flag +
+ + + fix youtube icon margin +
+ + + only show legacy browsers deprecation warning when option is used +
+ + + remove Native value from viewEncapsulation option +
+ + + use title for svg on home page +
+ +--- + +# Breaking Changes + +

+ @angular/cli: confirm ng add action before installation (985dc1a) +

+The `ng add` command will now ask the user to confirm the package and version prior to installing and executing an uninstalled package. +This new behavior allows a user to abort the action if the version selected is not appropriate or if a typo occurred on the command line and an incorrect package would be installed. +A `--skip-confirmation` option has been added to skip the prompt and directly install and execute the package. This option is useful in CI and non-TTY scenarios such as automated scripts. + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Renovate Bot, Doug Parker, Cédric Exbrayat, Kristiyan Kostadinov, Mouad Ennaciri, Omar Hasan + + + + + +# v12.0.0-next.4 (2021-03-10) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/architect (0.1200.0-next.4)

Commit + Description + Notes +
+ + + add implementation for defaultConfiguration +

@angular-devkit/build-angular (0.1200.0-next.4)

Commit + Description + Notes +
+ + + show warning during build when project requires IE 11 support +
+ + + only remove nomodule and defer attributes empty values + + + [Closes #20207]
+
+ +

@angular-devkit/core (12.0.0-next.4)

Commit + Description + Notes +
+ + + add handling for `defaultConfiguration` target definition property +

@angular/cli (12.0.0-next.4)

Commit + Description + Notes +
+ + + deprecate `--prod` command line argument +
+ + + add `defaultConfiguration` property to architect schema +
+ + + avoid exceptions for expected errors in architect commands +
+ + + add ng-packagr builder schema in IDE schema +

@ngtools/webpack (12.0.0-next.4)

Commit + Description + Notes +
+ + + drop support for string based lazy loading +

@schematics/angular (12.0.0-next.4)

Commit + Description + Notes +
+ + + add migration to update lazy loading string syntax to use dynamic imports +
+ + + add migration to remove `lazyModules` configuration option +
+ + + deprecate `legacyBrowsers` application and ng-new option +
+ + + production builds by default +
+ + + add `additionalProperties` to all schemas +
+ +--- + +# Breaking Changes + +

+ @angular-devkit/build-angular: remove deprecated `lazyModules` option (8d66912) +

+Server and Browser builder `lazyModules` option has been removed without replacement. + +

+ @ngtools/webpack: drop support for string based lazy loading (0dc7327) +

+With this change we drop support for string based lazy loading `./lazy.module#LazyModule` use dynamic imports instead. + +The following options which were used to support the above syntax were removed without replacement. + +- discoverLazyRoutes +- additionalLazyModules +- additionalLazyModuleResources +- contextElementDependencyConstructor + +--- + +# Special Thanks + +Alan Agius, Charles Lyding, Renovate Bot, Joey Perrott + + + + + +# v12.0.0-next.3 (2021-03-03) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.3)

Commit + Description + Notes +
+ + + enable inlineCritical by default +
+ + + remove left-over `experimentalRollupPass` option +
+ + + inline critical font-face rules when using crittical css inlining +

@schematics/angular (12.0.0-next.3)

Commit + Description + Notes +
+ + + update ng new links +
+ +--- + +# Breaking Changes + +

+ @angular-devkit/build-angular: enable inlineCritical by default (aa3ea88) +

+Critical CSS inlining is now enabled by default. If you wish to turn this off set `inlineCritical` to `false`. + +See: https://angular.dev/reference/configs/workspace-config#optimization-configuration + +--- + +# Special Thanks + +Renovate Bot, Charles Lyding, Alan Agius, Keen Yee Liau, Douglas Parker, twerske + + + + + +# v12.0.0-next.2 (2021-02-24) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.2)

Commit + Description + Notes +
+ + + only show index and service worker status once +
+ + + disable declaration and declarationMap + + + [Closes #20103]
+
+ +

@angular/cli (12.0.0-next.2)

Commit + Description + Notes +
+ + + remove npm 7 incompatibility notification +

@schematics/angular (12.0.0-next.2)

Commit + Description + Notes +
+ + + update new project dependencies version + + [Closes #20106]
+
+ +
+ + + augment `universal` schematics to import `platform-server` shims + + [Closes #40559]
+
+ +
+ + + add migration to remove emitDecoratorMetadata +
+ +--- + +--- + +# Special Thanks + +Renovate Bot, Charles Lyding, Alan Agius, Doug Parker, Joey Perrott, Jefiozie, George Kalpakas, Keen Yee Liau + + + + + +# v12.0.0-next.1 (2021-02-17) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.1)

Commit + Description + Notes +
+ + + drop support for ng-packagr version 11 +
+ + + drop support for karma version 5.2 +

@angular-devkit/build-optimizer (0.1200.0-next.1)

Commit + Description + Notes +
+ + + support Webpack 5 +

@angular/cli (12.0.0-next.1)

Commit + Description + Notes +
+ + + support update migration packages with no entry points + + + [Closes #20032]
+
+ +
+ + + ensure odd number Node.js version message is a warning +
+ + + improve error logging when resolving update migrations +

@ngtools/webpack (12.0.0-next.1)

Commit + Description + Notes +
+ + + support Webpack 5 +
+ + + normalize paths when pruning AOT rebuild requests +

@schematics/angular (12.0.0-next.1)

Commit + Description + Notes +
+ + + add migration to use new zone.js entry-points +
+ + + use new zone.js entry-points +
+ +--- + +# Breaking Changes + +

+ @angular-devkit/build-angular: drop support for zone.js 0.10 (f309516) +

+Minimum supported `zone.js` version is `0.11.4` + +

+ @angular-devkit/build-angular: drop support for ng-packagr version 11 (44e75be) +

+Minimum supported `ng-packagr` version is `12.0.0-next` + +

+ @angular-devkit/build-angular: drop support for karma version 5.2 (fa5cf53) +

+Minimum supported `karma` version is `6.0.0` + +--- + +# Special Thanks + +Renovate Bot, Alan Agius, Charles Lyding, Keen Yee Liau, Aravind V Nair + + + + + +# v12.0.0-next.0 (2021-02-11) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

@angular-devkit/build-angular (0.1200.0-next.0)

Commit + Description + Notes +
+ + + add `postcss-preset-env` with stage 3 features +
+ + + ensure i18n extraction sourcemaps are fully configured +
+ + + the root Tailwind configuration file is always picked +
+ + + fixed ignoring of karma plugins config + + + [Closes #19993]
+
+ +

@angular-devkit/core (12.0.0-next.0)

Commit + Description + Notes +
+ + + ensure job input values are processed in order +

@angular/cli (12.0.0-next.0)

Commit + Description + Notes +
+ + + update NPM 7 guidance +

@ngtools/webpack (12.0.0-next.0)

Commit + Description + Notes +
+ + + reduce overhead of Angular compiler rebuild requests +

@schematics/angular (12.0.0-next.0)

Commit + Description + Notes +
+ + + strict mode by default +
+ + + add migration to remove deprecated options from 'angular.json' +
+ + + only update removed v12 options in migration +
+ +--- + +# Breaking Changes + +

+ set minimum Node.js version to 12.13 (d1f6169) +

+Node.js version 10 will become EOL on 2021-04-30. +Angular CLI 12 will require Node.js 12.13+ or 14.15+. Node.js 12.13 and 14.15 are the first LTS releases for their respective majors. + +

+ @angular-devkit/build-angular: remove file-loader dependency (6732294) +

+The unsupported/undocumented, Webpack specific functionality to `import`/`require()` a non-module file has been removed. + +Before + +```js +import img from './images/asset.png'; +``` + +After + +```html + +``` + +--- + +# Special Thanks + +Renovate Bot, Charles Lyding, Alan Agius, Doug Parker, Bruno Baia, Amadou Sall, S. Iftekhar Hossain + +--- + +**Note: For release notes prior to this CHANGELOG see [release notes](https://github.com/angular/angular-cli/releases).** diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000000..501598396449 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering a safe and welcoming environment, we as +the Angular team pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity, gender expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Use welcoming and inclusive language +* Respect each other +* Provide and gracefully accept constructive criticism +* Show empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* The use of sexualized language or imagery +* Unwelcome sexual attention or advances +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Angular team are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Angular team have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, and to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies to all Angular communication channels - online or in person, +and it also applies when an individual is representing the project or its community in +public spaces. Examples of representing a project or community include using an official +project e-mail address, posting via an official social media account, or acting +as an appointed representative at an online or offline event. Representation of +a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the Angular team at conduct@angular.io. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The Angular team +will maintain confidentiality with regard to the reporter of an incident. +Enforcement may result in an indefinite ban from all official Angular communication +channels, or other actions as deemed appropriate by the Angular team. + +Angular maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +### Appeal + +If you are banned you may contest the decision. To do so email conduct@angular.io with the subject line "Repeal Ban for {{your name here}}" and body with the responses to the following: + +* Why do you believe you did not violate the Code of Conduct? +* Were other factors involved in this situation the leadership team may have been unaware of? +* Why do you wish to be a part of the Angular community? + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3968a29e332..8b57f53f5584 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Angular CLI +# Contributing to Angular DevKit -We would love for you to contribute to Angular CLI and help make it even better +We would love for you to contribute to DevKit and help make it even better than it is today! As a contributor, here are the guidelines we would like you to follow: @@ -12,21 +12,26 @@ to follow: - [Coding Rules](#rules) - [Commit Message Guidelines](#commit) - [Signing the CLA](#cla) + - [Spam Policy](#spam-policy) ## Code of Conduct Help us keep Angular open and inclusive. Please read and follow our [Code of Conduct][coc]. ## Got a Question or Problem? -Please, do not open issues for the general support questions as we want to keep GitHub issues for bug reports and feature requests. You've got much better chances of getting your question answered on [StackOverflow](https://stackoverflow.com/questions/tagged/angular-cli) where the questions should be tagged with tag `angular-cli`. +Please, do not open issues for the general support questions as we want to keep GitHub issues for +bug reports and feature requests. You've got much better chances of getting your question answered +on [StackOverflow](https://stackoverflow.com/questions/tagged/angular-devkit) where the questions +should be tagged with tag `angular-cli` or `angular-devkit`. StackOverflow is a much better place to ask questions since: -- there are thousands of people willing to help on StackOverflow -- questions and answers stay available for public viewing so your question / answer might help someone else +- There are thousands of people willing to help on StackOverflow. +- Questions and answers stay available for public viewing so your question / answer might help someone else. - StackOverflow's voting system assures that the best answers are prominently visible. -To save your and our time we will be systematically closing all the issues that are requests for general support and redirecting people to StackOverflow. +To save your and our time we will be systematically closing all the issues that are requests for +general support and redirecting people to StackOverflow. If you would like to chat about the question in real-time, you can reach out via [our gitter channel][gitter]. @@ -38,23 +43,13 @@ If you find a bug in the source code or a mistake in the documentation, you can ## Want a Feature? You can *request* a new feature by [submitting an issue](#submit-issue) to our [GitHub Repository][github]. If you would like to *implement* a new feature, please submit an issue with -a proposal for your work first, to be sure that we can use it. Angular CLI is in developer preview -and we are not ready to accept major contributions ahead of the full release. +a proposal for your work first, to be sure that we can use it. +Please consider what kind of change it is: -First open an issue and outline your proposal so that it can be +* For a **Major Feature**, first open an issue and outline your proposal so that it can be discussed. This will also allow us to better coordinate our efforts, prevent duplication of work, and help you to craft the change so that it is successfully accepted into the project. - -**All features require a proper design and review by team members.** Before starting work, you might want to -discuss with us to figure out: - -* Is this something we want? Sometimes people make feature requests that make sense for them, but aren't valuable - to the rest of the CLI users, or impede on other people's workflow. We try to always put the greater community - first. -* Is the API valid? Does it change currently existing flags, or add new ones? -* What's the impact on the rest of the CLI? Does it affect other commands/flags? - -Answering those questions first in the request might help us make a decision. +* **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). ## Submission Guidelines @@ -62,10 +57,11 @@ Answering those questions first in the request might help us make a decision. Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. -We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. In order to reproduce bugs we will systematically ask you to provide a minimal reproduction scenario using `ng new repro-app`. Having a reproducible scenario gives us wealth of important information without going back & forth to you with additional questions like: +We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. Having a reproducible scenario gives us wealth of important information without going back & forth to you with additional questions like: - version of Angular CLI used -- `.angular-cli.json` configuration +- `angular.json` configuration +- version of Angular DevKit used - 3rd-party libraries and their versions - and most importantly - a use-case that fails @@ -75,7 +71,7 @@ We will be insisting on a minimal reproduce scenario in order to save maintainer Unfortunately we are not able to investigate / fix bugs without a minimal reproduction, so if we don't hear back from you we are going to close an issue that don't have enough info to be reproduced. -You can file new issues by filling out our [new issue form](https://github.com/angular/angular-cli/issues/new). +You can file new issues by selecting from our [new issue templates](https://github.com/angular/angular-cli/issues/new/choose) and filling out the issue template. ### Submitting a Pull Request (PR) @@ -88,12 +84,12 @@ Before you submit your Pull Request (PR) consider the following guidelines: * Make your changes in a new git branch: ```shell - git checkout -b my-fix-branch master + git checkout -b my-fix-branch main ``` * Create your patch, **including appropriate test cases**. * Follow our [Coding Rules](#rules). -* Run the full Angular CLI test suite, as described in the [developer documentation][dev-doc], +* Run the full Angular CLI and DevKit test suite, as described in the [developer documentation][dev-doc], and ensure that all tests pass (coming soon). * Commit your changes using a descriptive commit message that follows our [commit message conventions](#commit). Adherence to these conventions @@ -110,18 +106,25 @@ Before you submit your Pull Request (PR) consider the following guidelines: git push origin my-fix-branch ``` -* In GitHub, send a pull request to `angular-cli:master`. +* In GitHub, send a pull request to `angular/angular-cli:main`. * If we suggest changes then: * Make the required updates. - * Re-run the Angular CLI test suites to ensure tests are still passing. - * Rebase your branch and force push to your GitHub repository (this will update your Pull Request): + * Re-run the Angular DevKit test suites to ensure tests are still passing. +* Once your PR is approved and you are done with any follow up changes: + * Rebase to the current main to pre-emptively address any merge conflicts. ```shell - git rebase master -i + git rebase upstream/main -i git push -f ``` + * Add the `action: merge` label and the correct +[target label](https://github.com/angular/angular/blob/main/docs/TRIAGE_AND_LABELS.md#pr-target) + (if PR author has the project collaborator status, or else the last reviewer + should do this). + * The current caretaker will merge the PR to the target branch(es) within 1-2 + business days. -That's it! Thank you for your contribution! +That's it! 🎉 Thank you for your contribution! #### After your pull request is merged @@ -134,10 +137,10 @@ from the main (upstream) repository: git push origin --delete my-fix-branch ``` -* Check out the master branch: +* Check out the main branch: ```shell - git checkout master -f + git checkout main -f ``` * Delete the local branch: @@ -146,10 +149,10 @@ from the main (upstream) repository: git branch -D my-fix-branch ``` -* Update your master with the latest upstream version: +* Update your local `main` with the latest upstream version: ```shell - git pull --ff upstream master + git pull --ff upstream main ``` ## Coding Rules @@ -164,7 +167,7 @@ To ensure consistency throughout the source code, keep these rules in mind as yo We have very precise rules over how our git commit messages can be formatted. This leads to **more readable messages** that are easy to follow when looking through the **project history**. But also, -we use the git commit messages to **generate the Angular CLI change log**. +we use the git commit messages to **generate the Angular DevKit change log**. ### Commit Message Format Each commit message consists of a **header**, a **body** and a **footer**. The header has a special @@ -189,51 +192,93 @@ If the commit reverts a previous commit, it should begin with `revert: `, follow ### Type Must be one of the following: -* **build**: Changes that affect the build system or external dependencies -* **ci**: Changes to our CI configuration files and scripts -* **docs**: Documentation only changes -* **feat**: A new feature -* **fix**: A bug fix -* **perf**: A code change that improves performance -* **refactor**: A code change that neither fixes a bug nor adds a feature -* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing - semi-colons, etc) -* **test**: Adding missing tests or correcting existing tests +* **build**: Changes to local repository build system and tooling +* **ci**: Changes to CI configuration and CI specific tooling [2] +* **docs**: Changes which exclusively affects documentation. +* **feat**: Creates a new feature [1] +* **fix**: Fixes a previously discovered failure/bug [1] +* **perf**: Improves performance without any change in functionality or API [1] +* **refactor**: Refactor without any change in functionality or API (includes style changes) +* **release**: A release point in the repository [2] +* **test**: Improvements or corrections made to the project's test suite + + +[1] This type MUST have a scope. See the next section for more information.
+[2] This type MUST NOT have a scope. It only applies to general scripts and tooling. ### Scope The scope should be the name of the npm package affected as perceived by the person reading changelog generated from the commit messages. The following is the list of supported scopes: +* **@angular/build** * **@angular/cli** -* **@ngtools/json-schema** -* **@ngtools/logger** +* **@angular/create** +* **@angular/pwa** +* **@angular/ssr** +* **@angular-devkit/architect** +* **@angular-devkit/build-angular** +* **@angular-devkit/build-webpack** +* **@angular-devkit/core** +* **@angular-devkit/schematics** +* **@angular-devkit/schematics-cli** * **@ngtools/webpack** +* **@schematics/angular** -There are currently a few exceptions to the "use package name" rule: - -* **packaging**: used for changes that change the npm package layout in all of our packages, e.g. public path changes, package.json changes done to all packages, d.ts file/format changes, changes to bundles, etc. -* **changelog**: used for updating the release notes in CHANGELOG.md -* none/empty string: useful for `style`, `test` and `refactor` changes that are done across all packages (e.g. `style: add missing semicolons`) ### Subject The subject contains succinct description of the change: * use the imperative, present tense: "change" not "changed" nor "changes" * don't capitalize first letter +* be concise and direct * no dot (.) at the end +### Examples +Examples of valid commit messages: + +* `fix(@angular/cli): prevent the flubber from grassing` +* `refactor(@schematics/angular): move all JSON classes together` + +Examples of invalid commit messages: +* `fix(@angular/cli): add a new XYZ command` + + This is a feature, not a fix. +* `ci(@angular/cli): fix publishing workflow` + + The `ci` type cannot have a scope. + ### Body Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". The body should include the motivation for the change and contrast this with previous behavior. ### Footer -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. +The footer can contain information about breaking changes and deprecations. It is also the place to reference GitHub issues, Jira tickets, and other PRs that are related to this commit or that this commit will close. +For example: + +``` +BREAKING CHANGE: + + + + +Fixes # +``` -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. +or -A detailed explanation can be found in this [document][commit-message-format]. +``` +DEPRECATED: + + + + +Closes # +``` + +Breaking Change section should start with the phrase "BREAKING CHANGE: " followed by a summary of the breaking change, a blank line, and a detailed description of the breaking change that also includes migration instructions. + +Similarly, a Deprecation section should start with "DEPRECATED: " followed by a short description of what is deprecated, a blank line, and a detailed description of the deprecation that also mentions the recommended update path. ## Signing the CLA @@ -244,13 +289,17 @@ changes to be accepted, the CLA must be signed. It's a quick process, we promise * For corporations we'll need you to [print, sign and one of scan+email, fax or mail the form][corporate-cla]. +## Spam Policy + +See [Spam Policy](https://github.com/angular/angular/blob/main/contributing-docs/spam.md) for details. -[coc]: https://github.com/angular/code-of-conduct/blob/master/CODE_OF_CONDUCT.md +[coc]: https://github.com/angular/code-of-conduct/blob/main/CODE_OF_CONDUCT.md [commit-message-format]: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit# -[corporate-cla]: http://code.google.com/legal/corporate-cla-v1.0.html -[dev-doc]: https://github.com/angular/angular-cli#development-hints-for-working-on-angular-cli +[corporate-cla]: https://code.google.com/legal/corporate-cla-v1.0.html +[dev-doc]: https://github.com/angular/angular-cli/blob/main/docs/DEVELOPER.md [GitHub]: https://github.com/angular/angular-cli [gitter]: https://gitter.im/angular/angular-cli -[individual-cla]: http://code.google.com/legal/individual-cla-v1.0.html +[individual-cla]: https://code.google.com/legal/individual-cla-v1.0.html [js-style-guide]: https://google.github.io/styleguide/jsguide.html -[stackoverflow]: http://stackoverflow.com/questions/tagged/angular-cli +[stackoverflow]: https://stackoverflow.com/questions/tagged/angular-devkit + diff --git a/LICENSE b/LICENSE index 47bfda24adf2..5ec8e26b9eec 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2017 Google, Inc. +Copyright (c) 2010-2026 Google LLC. https://angular.dev/license Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 000000000000..da1acc74fe37 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,210 @@ +"""Rules/toolchains for angular_cli with Bazel.""" + +module( + name = "angular_cli", +) + +bazel_dep(name = "platforms", version = "1.1.0") +bazel_dep(name = "yq.bzl", version = "0.4.0") +bazel_dep(name = "rules_nodejs", version = "6.7.7") +bazel_dep(name = "aspect_rules_js", version = "3.5.1") +bazel_dep(name = "aspect_rules_ts", version = "3.10.1") +bazel_dep(name = "rules_pkg", version = "1.3.0") +bazel_dep(name = "rules_cc", version = "0.2.26") +bazel_dep(name = "jq.bzl", version = "0.7.1") +bazel_dep(name = "bazel_lib", version = "3.7.2") +bazel_dep(name = "bazel_skylib", version = "1.9.2") +bazel_dep(name = "aspect_rules_esbuild", version = "0.27.1") +bazel_dep(name = "aspect_rules_jasmine", version = "2.0.4") +bazel_dep(name = "rules_angular") +git_override( + module_name = "rules_angular", + commit = "5c2a1c83782292554ba8504713e54860c72c2aeb", + remote = "https://github.com/angular/rules_angular.git", +) + +bazel_dep(name = "devinfra") +git_override( + module_name = "devinfra", + commit = "c0b8d59ecb851e0dd1808f3d00846d5f2c0c8d47", + remote = "https://github.com/angular/dev-infra.git", +) + +bazel_dep(name = "rules_browsers") +git_override( + module_name = "rules_browsers", + commit = "14e8fd76588d44cd945e10ac1186634f0dfe186f", + remote = "https://github.com/angular/rules_browsers.git", +) + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain( + node_repositories = { + "24.22.1-darwin_arm64": ("node-v24.22.1-darwin-arm64.tar.gz", "node-v24.22.1-darwin-arm64", "89c682593d76cd44233fa53b1563264e7de9cbb189cad169135c5b4fadfbc0a7"), + "24.22.1-darwin_amd64": ("node-v24.22.1-darwin-x64.tar.gz", "node-v24.22.1-darwin-x64", "d132557e7ee2b85af08b0c8c34338e65c6430f2238d01f727728ef42dc83b035"), + "24.22.1-linux_arm64": ("node-v24.22.1-linux-arm64.tar.xz", "node-v24.22.1-linux-arm64", "dbabf7a1cbf947e6059ffad2c27b80041db97dea1e553b62afdee7f13fca336e"), + "24.22.1-linux_ppc64le": ("node-v24.22.1-linux-ppc64le.tar.xz", "node-v24.22.1-linux-ppc64le", "5939d9e66b5a7e58c97f2a287787c5151147f33d43c41ad3261254b148374fdf"), + "24.22.1-linux_s390x": ("node-v24.22.1-linux-s390x.tar.xz", "node-v24.22.1-linux-s390x", "339aa21a40c321a2af55b7f7547117250b3d6f8149568bca59b5e17c2931ac22"), + "24.22.1-linux_amd64": ("node-v24.22.1-linux-x64.tar.xz", "node-v24.22.1-linux-x64", "6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6"), + "24.22.1-windows_amd64": ("node-v24.22.1-win-x64.zip", "node-v24.22.1-win-x64", "d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5"), + }, + node_version = "24.22.1", +) +use_repo( + node, + "nodejs_darwin_amd64", + "nodejs_darwin_arm64", + "nodejs_linux_amd64", + "nodejs_linux_arm64", + "nodejs_toolchains", + "nodejs_windows_amd64", +) + +node_dev = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node", dev_dependency = True) + +# Node.js 22 +node_dev.toolchain( + name = "node22", + node_repositories = { + "22.22.3-darwin_arm64": ("node-v22.22.3-darwin-arm64.tar.gz", "node-v22.22.3-darwin-arm64", "0da7ff74ef8611328c8212f17943368713a2ad953fb7d89a8c8a0eae87c23207"), + "22.22.3-darwin_amd64": ("node-v22.22.3-darwin-x64.tar.gz", "node-v22.22.3-darwin-x64", "45830ba752fa0d892c6dcd640946669801293cac820a33591ded40ac075198ec"), + "22.22.3-linux_arm64": ("node-v22.22.3-linux-arm64.tar.xz", "node-v22.22.3-linux-arm64", "1c4a9933a5e45bc88f54f70b5f91232c127ec49f1a5989d23fb85824c7adf9b7"), + "22.22.3-linux_ppc64le": ("node-v22.22.3-linux-ppc64le.tar.xz", "node-v22.22.3-linux-ppc64le", "edb5478071bd1375e80195ca52f72823998bb5141b1a09e68bc54b3e2eb67754"), + "22.22.3-linux_s390x": ("node-v22.22.3-linux-s390x.tar.xz", "node-v22.22.3-linux-s390x", "ce398c057830d57a24c458177279a17bc51742d5c22dd4cbe97b10dbd43f2617"), + "22.22.3-linux_amd64": ("node-v22.22.3-linux-x64.tar.xz", "node-v22.22.3-linux-x64", "2e5d13569282d016861fae7c8f935e741693c269101a5bebcf761a5376d1f99f"), + "22.22.3-windows_amd64": ("node-v22.22.3-win-x64.zip", "node-v22.22.3-win-x64", "6c8d54f635feff4df76c2ca80f45332eb2ff57d25226edce36592e51a177ee33"), + }, + node_version = "22.22.3", +) + +# Node.js 24 +node_dev.toolchain( + name = "node24", + node_repositories = { + "24.15.0-darwin_arm64": ("node-v24.15.0-darwin-arm64.tar.gz", "node-v24.15.0-darwin-arm64", "372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4"), + "24.15.0-darwin_amd64": ("node-v24.15.0-darwin-x64.tar.gz", "node-v24.15.0-darwin-x64", "ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b"), + "24.15.0-linux_arm64": ("node-v24.15.0-linux-arm64.tar.xz", "node-v24.15.0-linux-arm64", "f3d5a797b5d210ce8e2cb265544c8e482eaedcb8aa409a8b46da7e8595d0dda0"), + "24.15.0-linux_ppc64le": ("node-v24.15.0-linux-ppc64le.tar.xz", "node-v24.15.0-linux-ppc64le", "6a6560a27bd2817013c28c3d917bfe9eebf26bbd4b1d88475190f216cc411fbb"), + "24.15.0-linux_s390x": ("node-v24.15.0-linux-s390x.tar.xz", "node-v24.15.0-linux-s390x", "940d4cbfadf736b34519630a05d144c09f8a5aca291a802f2f559ee1562f6f24"), + "24.15.0-linux_amd64": ("node-v24.15.0-linux-x64.tar.xz", "node-v24.15.0-linux-x64", "472655581fb851559730c48763e0c9d3bc25975c59d518003fc0849d3e4ba0f6"), + "24.15.0-windows_amd64": ("node-v24.15.0-win-x64.zip", "node-v24.15.0-win-x64", "cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62"), + }, + node_version = "24.15.0", +) + +# Node.js 26 +node_dev.toolchain( + name = "node26", + node_repositories = { + "26.0.0-darwin_arm64": ("node-v26.0.0-darwin-arm64.tar.gz", "node-v26.0.0-darwin-arm64", "dcee8564c1a9342f9594dd5e52d533894dfef6b85aa771bbbb870baa3c403235"), + "26.0.0-darwin_amd64": ("node-v26.0.0-darwin-x64.tar.gz", "node-v26.0.0-darwin-x64", "f488ab543fe202d8a2d56e661682117d3c56903a2bf64f2ec1ff7bd421cfd875"), + "26.0.0-linux_arm64": ("node-v26.0.0-linux-arm64.tar.xz", "node-v26.0.0-linux-arm64", "f0f94e55142149a4d34634dc3d7e103921d898512dd0cef995ecb62c5ebd3f29"), + "26.0.0-linux_ppc64le": ("node-v26.0.0-linux-ppc64le.tar.xz", "node-v26.0.0-linux-ppc64le", "4b7f76967a93fea8cda11554f2a7904744afaef65dc3f48c345e99828f50ef4d"), + "26.0.0-linux_s390x": ("node-v26.0.0-linux-s390x.tar.xz", "node-v26.0.0-linux-s390x", "e3bd9df41f777dbb227b1261ea81b1fa9b654901bac8cace50a0b918b5160ab5"), + "26.0.0-linux_amd64": ("node-v26.0.0-linux-x64.tar.xz", "node-v26.0.0-linux-x64", "345d558514c62622b5c7d1f7b5f2a19c31ab1405d217df49f010c5ea8decc0f4"), + "26.0.0-windows_amd64": ("node-v26.0.0-win-x64.zip", "node-v26.0.0-win-x64", "d0418640a36096e00bddb57761aa0b1b98f91904ec4ed2b9dd75cbad723becd7"), + }, + node_version = "26.0.0", +) +use_repo( + node_dev, + "node22_darwin_amd64", + "node22_darwin_arm64", + "node22_linux_amd64", + "node22_linux_arm64", + "node22_toolchains", + "node22_windows_amd64", + "node24_darwin_amd64", + "node24_darwin_arm64", + "node24_linux_amd64", + "node24_linux_arm64", + "node24_toolchains", + "node24_windows_amd64", + "node26_darwin_amd64", + "node26_darwin_arm64", + "node26_linux_amd64", + "node26_linux_arm64", + "node26_toolchains", + "node26_windows_amd64", +) + +pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm") +pnpm.pnpm( + name = "pnpm", + pnpm_version = "12.14.0", + pnpm_version_integrity = "sha512-wYum2HmT09Fky16JSPMrLAbuhUSH4GzY2GeBNC+z5btGNDZwNHEehoArqpw3vqkGzhkprQjXCITJfYAd8TOJpg==", +) +use_repo(pnpm, "pnpm") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "npm", + custom_postinstalls = { + # TODO: Standardize browser management for `rules_js` + "webdriver-manager": "node ./bin/webdriver-manager update --standalone false --gecko false --versions.chrome 106.0.5249.21", + }, + data = [ + "//:package.json", + "//:pnpm-workspace.yaml", + "//modules/testing/builder:package.json", + "//packages/angular/build:package.json", + "//packages/angular/cli:package.json", + "//packages/angular/create/package.json", + "//packages/angular/pwa:package.json", + "//packages/angular/ssr:package.json", + "//packages/angular_devkit/architect:package.json", + "//packages/angular_devkit/build_angular:package.json", + "//packages/angular_devkit/build_webpack:package.json", + "//packages/angular_devkit/core:package.json", + "//packages/angular_devkit/schematics:package.json", + "//packages/angular_devkit/schematics_cli:package.json", + "//packages/ngtools/webpack:package.json", + "//packages/schematics/angular:package.json", + "//tests:package.json", + ], + lifecycle_hooks_envs = { + # TODO: Standardize browser management for `rules_js` + "puppeteer": ["PUPPETEER_DOWNLOAD_PATH=./downloads"], + }, + lifecycle_hooks_execution_requirements = { + # Needed for downloading chromedriver. + # Also `update-config` of webdriver manager would store an absolute path; + # which would then break execution. + "webdriver-manager": ["local"], + }, + pnpm_lock = "//:pnpm-lock.yaml", +) + +# This is needed as by default `.md` files are excluded from the npm package. +# But @angular/core includes best-practices.md file. +# See: https://github.com/aspect-build/rules_js/blob/786a74a158dd36ed073188b0e506c423cd05501a/npm/private/exclude_package_contents_presets.bzl#L29 +npm.npm_exclude_package_contents( + package = "@angular/core", + presets = [], +) +use_repo(npm, "npm") + +rules_ts_ext = use_extension("@aspect_rules_ts//ts:extensions.bzl", "ext") +rules_ts_ext.deps( + name = "angular_cli_npm_typescript", + # Obtained by: npm info typescript@6.0.2 dist.integrity + ts_integrity = "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + ts_version = "6.0.3", +) +use_repo(rules_ts_ext, **{"npm_typescript": "angular_cli_npm_typescript"}) + +rules_angular = use_extension("@rules_angular//setup:extensions.bzl", "rules_angular") +rules_angular.setup( + name = "angular_cli_rules_angular_configurable_deps", + angular_compiler_cli = "//:node_modules/@angular/compiler-cli", + typescript = "//:node_modules/typescript", +) +use_repo(rules_angular, **{"rules_angular_configurable_deps": "angular_cli_rules_angular_configurable_deps"}) + +register_toolchains( + "@devinfra//bazel/git-toolchain:git_linux_toolchain", + "@devinfra//bazel/git-toolchain:git_macos_x86_toolchain", + "@devinfra//bazel/git-toolchain:git_macos_arm64_toolchain", + "@devinfra//bazel/git-toolchain:git_windows_toolchain", + "//tools/toolchains:dummy_cc_windows_no_exec_toolchain", +) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock new file mode 100644 index 000000000000..61568ef85cec --- /dev/null +++ b/MODULE.bazel.lock @@ -0,0 +1,1080 @@ +{ + "lockFileVersion": 28, + "registryFileHashes": { + "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", + "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", + "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", + "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", + "https://bcr.bazel.build/modules/apple_support/2.8.0/MODULE.bazel": "c45f5176057092afba0be6c48a9ea02101666802f8be536947e099e94757f8ed", + "https://bcr.bazel.build/modules/apple_support/2.9.1/MODULE.bazel": "fb209b83ed172af587419acb3340389cfe3260669e5f1c05e1ac3974aca614e0", + "https://bcr.bazel.build/modules/apple_support/2.9.1/source.json": "247d9f86d94c546533fb01e6e18b018486f9a6dff5faccae8838ef12e659b2a7", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.0.0/MODULE.bazel": "e118477db5c49419a88d78ebc7a2c2cea9d49600fe0f490c1903324a2c16ecd9", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/MODULE.bazel": "004ba890363d05372a97248c37205ae64b6fa31047629cd2c0895a9d0c7779e8", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.5/source.json": "ac2c3213df8f985785f1d0aeb7f0f73d5324e6e67d593d9b9470fb74a25d4a9b", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c", + "https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838", + "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.1/MODULE.bazel": "99c3978959edd9892e4b513831b218a13cc84a0215bfe1b972a1e3a771c4670e", + "https://bcr.bazel.build/modules/aspect_rules_esbuild/0.27.1/source.json": "20f515102cbcd0835d90bb3976b193eb58c2a0c534093c85cfd3bf6f06dba748", + "https://bcr.bazel.build/modules/aspect_rules_jasmine/2.0.4/MODULE.bazel": "fbb819eb8b7e5d7f67fdd38f7cecb413e287594cd666ce192c72c8828527775a", + "https://bcr.bazel.build/modules/aspect_rules_jasmine/2.0.4/source.json": "81ffb708333cd98ec3c0b4cc004f4d5cf92a16914b5196a2892c45141bba7cff", + "https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5", + "https://bcr.bazel.build/modules/aspect_rules_js/3.4.0/MODULE.bazel": "88844ac411e1961f4574a92f3c5be5b20d1c6997778c6b88316c5c3b4b60e284", + "https://bcr.bazel.build/modules/aspect_rules_js/3.5.1/MODULE.bazel": "f01b4f1bf26713e875ec0fbe9d9c2146d2b173d6194850a7cb63b579c557aab1", + "https://bcr.bazel.build/modules/aspect_rules_js/3.5.1/source.json": "f2dc028f9110d8aaf262a23267484f1c9d310f8ecf5f832e2a740d192a97756f", + "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.1/MODULE.bazel": "035205a7834b0ed9724ed3c4790e12d1c779ef2520d72496ab234e6307715e6c", + "https://bcr.bazel.build/modules/aspect_rules_ts/3.10.1/source.json": "d42b6560206adb5524caad2abc40ae0b5b34478dd38184a9f6ea4a5fed848cd6", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.2.8/MODULE.bazel": "aa975a83e72bcaac62ee61ab12b788ea324a1d05c4aab28aadb202f647881679", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.4.2/MODULE.bazel": "f31aa84151d31e98cffd43eb7217ccff5ec52bdd5f2d10db8f053aeb23342eca", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.5.1/MODULE.bazel": "9d94e5c22c0019995c0a3216a6abb1717dc3f35acccf4e6267a55d6df38367aa", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.5.2/MODULE.bazel": "c715a5fb6b13a0086c061618f00d033a4ab6d76cbd1083e38d7ca953b5e547f4", + "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.5.2/source.json": "4dd300d44767ad4860098b006dda614e67ab0409ae6ec6e133faad303fe9ede8", + "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", + "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", + "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", + "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", + "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", + "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", + "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d", + "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.41.0/MODULE.bazel": "6e0f87fafed801273c371d41e22a15a6f8abf83fdd7f87d5e44ad317b94433d0", + "https://bcr.bazel.build/modules/bazel_features/1.50.0/MODULE.bazel": "2083ef9c7a469f520890483ccf8e0189d6e71e2117e7752e15e6554433d5ae3e", + "https://bcr.bazel.build/modules/bazel_features/1.50.0/source.json": "e0ee3debde2789ff56e4452e612d126925ba9ab64d4bde79c67f099d2902df9b", + "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", + "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/MODULE.bazel": "d6e00979a98ac14ada5e31c8794708b41434d461e7e7ca39b59b765e6d233b18", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", + "https://bcr.bazel.build/modules/bazel_lib/3.2.0/MODULE.bazel": "39b50d94b9be6bda507862254e20c263f9b950e3160112348d10a938be9ce2c2", + "https://bcr.bazel.build/modules/bazel_lib/3.7.0/MODULE.bazel": "d7c10ed67f0f7f1fda179db8f86c22642581bd614882e1a50545fbe069525173", + "https://bcr.bazel.build/modules/bazel_lib/3.7.2/MODULE.bazel": "cbc920fff00a55b2fd663d40850560a6fb17fd5ef3a9f3d6cbf0d32908dd8a40", + "https://bcr.bazel.build/modules/bazel_lib/3.7.2/source.json": "7a9c2b06a113c4962d329bb68befdb90d0447788a19aabaa32b476a0458e7d28", + "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", + "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", + "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", + "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", + "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/MODULE.bazel": "8c51259b0f4481475586dbfede7591e57b75702e637f840f6138eec80c34b270", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.2/source.json": "41cbde7546542dee2f26e3f10bf1c4ac57909943194b7ac48cc5238a01893aa8", + "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", + "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/MODULE.bazel": "f1b7bb2dd53e8f2ef984b39485ec8a44e9076dda5c4b8efd2fb4c6a6e856a31d", + "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.3/source.json": "ebe931bfe362e4b41e59ee00a528db6074157ff2ced92eb9e970acab2e1089c9", + "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", + "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", + "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/hermetic_launcher/0.0.15/MODULE.bazel": "1a04856a10bd6a3d23bad08673bceb59b061c4bf9b76d335e30386832acc1b33", + "https://bcr.bazel.build/modules/hermetic_launcher/0.0.15/source.json": "aceb903a3bda5131b0a3132ccf77f36f32b96dcb70864049ec8cfdf9c0b0f068", + "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", + "https://bcr.bazel.build/modules/jq.bzl/0.4.0/MODULE.bazel": "a7b39b37589f2b0dad53fd6c1ccaabbdb290330caa920d7ef3e6aad068cd4ab2", + "https://bcr.bazel.build/modules/jq.bzl/0.7.1/MODULE.bazel": "2dc4fd806df09b30aa7e0339f141a661f72df6af237f6db1fc4ca5c5045f1772", + "https://bcr.bazel.build/modules/jq.bzl/0.7.1/source.json": "0dbd7febb9361e34247c952e749f21f258b5123126248624c0d921c4592eac96", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83", + "https://bcr.bazel.build/modules/package_metadata/0.0.3/source.json": "742075a428ad12a3fa18a69014c2f57f01af910c6d9d18646c990200853e641a", + "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", + "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", + "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", + "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", + "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", + "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", + "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", + "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", + "https://bcr.bazel.build/modules/platforms/1.1.0/MODULE.bazel": "1c0c09f5bdcf4b3f924720d2478a3711cb39f4977019ca5988685e5b7e18b3d2", + "https://bcr.bazel.build/modules/platforms/1.1.0/source.json": "fcf351c47596c939140ab0d333dfdd08ed1ea6ce33c2fe70c12493a301cf1344", + "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", + "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", + "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", + "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", + "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", + "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", + "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", + "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", + "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", + "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", + "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", + "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", + "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", + "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", + "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", + "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", + "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", + "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", + "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", + "https://bcr.bazel.build/modules/rules_cc/0.2.20/MODULE.bazel": "f5c07bce5ddcb99be21a0812ff5aadb439e688b7449c6542152363b2fd859c1a", + "https://bcr.bazel.build/modules/rules_cc/0.2.25/MODULE.bazel": "4a3d4f3606d3b3190be495dbc497acca552807d1d5540661ef0d1658472882e3", + "https://bcr.bazel.build/modules/rules_cc/0.2.26/MODULE.bazel": "a9f7b7f4a52747910965bddb1f58fc1fef50604ed1980ebdd16ea6f446d92249", + "https://bcr.bazel.build/modules/rules_cc/0.2.26/source.json": "bf9ff315da6055c1e374d627012cc51947a05b3560674af7d69fbedbb4fde6ef", + "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", + "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", + "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", + "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", + "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", + "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", + "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", + "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", + "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", + "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", + "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", + "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", + "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", + "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", + "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", + "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", + "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", + "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", + "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", + "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", + "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", + "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", + "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", + "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_nodejs/6.2.0/MODULE.bazel": "ec27907f55eb34705adb4e8257952162a2d4c3ed0f0b3b4c3c1aad1fac7be35e", + "https://bcr.bazel.build/modules/rules_nodejs/6.5.0/MODULE.bazel": "546d0cf79f36f9f6e080816045f97234b071c205f4542e3351bd4424282a8810", + "https://bcr.bazel.build/modules/rules_nodejs/6.7.3/MODULE.bazel": "c22a48b2a0dbf05a9dc5f83837bbc24c226c1f6e618de3c3a610044c9f336056", + "https://bcr.bazel.build/modules/rules_nodejs/6.7.7/MODULE.bazel": "e80c95eebba3e38fa28ea0553ec580e4bffbbe8d0e8fa892457fa0e6b616f3d8", + "https://bcr.bazel.build/modules/rules_nodejs/6.7.7/source.json": "60081c2448d70db3c5c0e75f55c0a55a22b2144646c21977c46c4986dacacdb6", + "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", + "https://bcr.bazel.build/modules/rules_pkg/1.3.0/MODULE.bazel": "ae0bdefbacc990c91f843206c90cf0f4be620639a5bf22119043599ba86d51a3", + "https://bcr.bazel.build/modules/rules_pkg/1.3.0/source.json": "58ae84c545141762f7c434c0ce78bfb55ef0be08d84863e7f0503fff135fe4e2", + "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", + "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", + "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", + "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", + "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", + "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", + "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", + "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", + "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", + "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", + "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", + "https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a", + "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", + "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", + "https://bcr.bazel.build/modules/stardoc/0.5.4/MODULE.bazel": "6569966df04610b8520957cb8e97cf2e9faac2c0309657c537ab51c16c18a2a4", + "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", + "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", + "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", + "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", + "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", + "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", + "https://bcr.bazel.build/modules/tar.bzl/0.10.9/MODULE.bazel": "4636f5f6a7c34d3d0653634ed95a34560426d548c5301c6c5924c885ef737b46", + "https://bcr.bazel.build/modules/tar.bzl/0.10.9/source.json": "30a5cf0a8281adce53ab1c5a4291e88b2c161e2df1feb6cba4ee50556f1748d7", + "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", + "https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351", + "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072", + "https://bcr.bazel.build/modules/yq.bzl/0.3.4/MODULE.bazel": "d3a270662f5d766cd7229732d65a5a5bc485240c3007343dd279edfb60c9ae27", + "https://bcr.bazel.build/modules/yq.bzl/0.4.0/MODULE.bazel": "1e4b142e391b62c95eb637fc0e944c17f3ed5276cdc9d7033df72a2f96a2a457", + "https://bcr.bazel.build/modules/yq.bzl/0.4.0/source.json": "e5a3b94fe7ab720818ac2d8150cb19b009daf48c69a72eaab9a4852efb75331d", + "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", + "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" + }, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": { + "general": { + "bzlTransitiveDigest": "7CzJNk/xihiZEjE9MPLxiSxKGDG0VkMFowWQ/xhx+SU=", + "usagesDigest": "ZqBbSnzAi8ZyBL3PXnAGtDMIdC2pcuieJWMY3/94lvA=", + "recordedInputs": [ + "REPO_MAPPING:aspect_rules_esbuild+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_esbuild+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_esbuild+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+", + "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools", + "REPO_MAPPING:aspect_rules_js+,hermetic_launcher hermetic_launcher+", + "REPO_MAPPING:aspect_rules_js+,protobuf protobuf+", + "REPO_MAPPING:aspect_rules_js+,tar.bzl tar.bzl+", + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:bazel_lib+,bazel_lib bazel_lib+", + "REPO_MAPPING:bazel_lib+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:protobuf+,proto_bazel_features bazel_features+", + "REPO_MAPPING:tar.bzl+,bazel_lib bazel_lib+", + "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+" + ], + "generatedRepoSpecs": { + "esbuild_darwin-x64": { + "repoRuleId": "@@aspect_rules_esbuild+//esbuild:repositories.bzl%esbuild_repositories", + "attributes": { + "esbuild_version": "0.19.9", + "integrity": "", + "platform": "darwin-x64" + } + }, + "esbuild_darwin-arm64": { + "repoRuleId": "@@aspect_rules_esbuild+//esbuild:repositories.bzl%esbuild_repositories", + "attributes": { + "esbuild_version": "0.19.9", + "integrity": "", + "platform": "darwin-arm64" + } + }, + "esbuild_linux-x64": { + "repoRuleId": "@@aspect_rules_esbuild+//esbuild:repositories.bzl%esbuild_repositories", + "attributes": { + "esbuild_version": "0.19.9", + "integrity": "", + "platform": "linux-x64" + } + }, + "esbuild_linux-arm64": { + "repoRuleId": "@@aspect_rules_esbuild+//esbuild:repositories.bzl%esbuild_repositories", + "attributes": { + "esbuild_version": "0.19.9", + "integrity": "", + "platform": "linux-arm64" + } + }, + "esbuild_win32-x64": { + "repoRuleId": "@@aspect_rules_esbuild+//esbuild:repositories.bzl%esbuild_repositories", + "attributes": { + "esbuild_version": "0.19.9", + "integrity": "", + "platform": "win32-x64" + } + }, + "esbuild_toolchains": { + "repoRuleId": "@@aspect_rules_esbuild+//esbuild/private:toolchains_repo.bzl%toolchains_repo", + "attributes": { + "esbuild_version": "0.19.9", + "user_repository_name": "esbuild" + } + }, + "npm__esbuild_0.19.9": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", + "attributes": { + "key": "npm__esbuild_0.19.9", + "package": "esbuild", + "version": "0.19.9", + "root_package": "", + "link_workspace": "", + "integrity": "sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==", + "url": "", + "commit": "", + "patch_args": [ + "-p0" + ], + "patches": [], + "custom_postinstall": "", + "npm_auth": "", + "npm_auth_basic": "", + "npm_auth_username": "", + "npm_auth_password": "", + "lifecycle_hooks": [], + "extra_build_content": "", + "generate_package_json_bzl": false, + "generate_bzl_library_targets": false, + "extract_full_archive": false, + "exclude_package_contents": [], + "exclude_package_contents_presets": [] + } + }, + "npm__esbuild_0.19.9__links": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links_rule", + "attributes": { + "key": "npm__esbuild_0.19.9", + "package": "esbuild", + "version": "0.19.9", + "root_package": "", + "deps": {}, + "deps_oss": {}, + "deps_cpus": {}, + "transitive_closure": {}, + "lifecycle_build_target": false, + "lifecycle_hooks_env": [], + "lifecycle_hooks_execution_requirements": [ + "no-sandbox" + ], + "lifecycle_hooks_use_default_shell_env": false, + "bins": {}, + "package_visibility": [ + "//visibility:public" + ], + "replace_package": "", + "exclude_package_contents": [], + "exclude_package_contents_presets": [] + } + } + } + } + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "general": { + "bzlTransitiveDigest": "ihzLOoAnQP6Y5iSYOhEjvb6uxEbA6ZgH8Qikau2lGRU=", + "usagesDigest": "T++ib/Wzi7zER9SrxnCdIvjXsWWPXxls8VWQ+Oa5+Vc=", + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+", + "ENV:ASPECT_TOOLS_TELEMETRY_TEST \\0" + ], + "generatedRepoSpecs": { + "aspect_tools_telemetry_report": { + "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", + "attributes": { + "deps": { + "aspect_rules_js": "3.5.1", + "aspect_rules_ts": "3.10.1", + "aspect_rules_esbuild": "0.27.1", + "aspect_rules_jasmine": "2.0.4", + "aspect_tools_telemetry": "0.5.2" + }, + "last_notice": 0 + } + } + }, + "moduleExtensionMetadata": { + "useAllRepos": "NO", + "reproducible": false + } + } + }, + "@@pybind11_bazel+//:python_configure.bzl%extension": { + "general": { + "bzlTransitiveDigest": "VhEtmxw1yzb9rBZVsKTdti7p+nDM/Fv1p9TmKdO45+Q=", + "usagesDigest": "fycyB39YnXIJkfWCIXLUKJMZzANcuLy9ZE73hRucjFk=", + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel 88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e" + ], + "generatedRepoSpecs": { + "local_config_python": { + "repoRuleId": "@@pybind11_bazel+//:python_configure.bzl%python_configure", + "attributes": {} + }, + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11.BUILD", + "strip_prefix": "pybind11-2.11.1", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.11.1.zip" + ] + } + } + } + } + }, + "@@rules_angular+//setup:extensions.bzl%rules_angular": { + "general": { + "bzlTransitiveDigest": "aS7Uud1IzoU7PPLzH3s6IfFS4b2fa0SRWDi2/fS4bQU=", + "usagesDigest": "NK6UKnTWfOFl5i+flt6LxviEbMHgJEH+JyjGkTh3Vjo=", + "recordedInputs": [], + "generatedRepoSpecs": { + "angular_cli_rules_angular_configurable_deps": { + "repoRuleId": "@@rules_angular+//setup:repositories.bzl%configurable_deps_repo", + "attributes": { + "angular_compiler_cli": "@@//:node_modules/@angular/compiler-cli", + "typescript": "@@//:node_modules/typescript" + } + }, + "rules_angular_configurable_deps": { + "repoRuleId": "@@rules_angular+//setup:repositories.bzl%configurable_deps_repo", + "attributes": { + "angular_compiler_cli": "@@//:node_modules/@angular/compiler-cli", + "typescript": "@@//:node_modules/typescript" + } + } + } + } + }, + "@@rules_browsers+//browsers:extensions.bzl%browsers": { + "general": { + "bzlTransitiveDigest": "CzaP1r3Dbnw1/meKHkb1Xf+G41YEWbZ1i4KAoMNyyRc=", + "usagesDigest": "FmXYJVoVJlnfUU8x8gObSvu4qWcco/9Faw61aC/wBF0=", + "recordedInputs": [], + "generatedRepoSpecs": { + "rules_browsers_chrome_linux": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "27abcf9483817c66c514b4f81a2b3686215962bb93b70e3f6e9ea20b3118072c", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/linux64/chrome-headless-shell-linux64.zip" + ], + "named_files": { + "CHROME-HEADLESS-SHELL": "chrome-headless-shell-linux64/chrome-headless-shell" + }, + "exclude_patterns": [ + "**/*.log" + ], + "exports_files": [ + "chrome-headless-shell-linux64/chrome-headless-shell" + ] + } + }, + "rules_browsers_chrome_mac": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "a8ec56df3d96e53d8127213391cd2fc4d9bd9dbeb21bcddca38896eaac4dce78", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/mac-x64/chrome-headless-shell-mac-x64.zip" + ], + "named_files": { + "CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-x64/chrome-headless-shell" + }, + "exclude_patterns": [ + "**/*.log" + ], + "exports_files": [ + "chrome-headless-shell-mac-x64/chrome-headless-shell" + ] + } + }, + "rules_browsers_chrome_mac_arm": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "f72a6cc55338ed09a3f113d8c42f497e9b64eb4b689347f04cb8100de6954033", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/mac-arm64/chrome-headless-shell-mac-arm64.zip" + ], + "named_files": { + "CHROME-HEADLESS-SHELL": "chrome-headless-shell-mac-arm64/chrome-headless-shell" + }, + "exclude_patterns": [ + "**/*.log" + ], + "exports_files": [ + "chrome-headless-shell-mac-arm64/chrome-headless-shell" + ] + } + }, + "rules_browsers_chrome_win64": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "22d3167ea3f9fba7c5a013661413442043939d3e4c2d5a8eaf030379ffa6718e", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/win64/chrome-headless-shell-win64.zip" + ], + "named_files": { + "CHROME-HEADLESS-SHELL": "chrome-headless-shell-win64/chrome-headless-shell.exe" + }, + "exclude_patterns": [ + "**/*.log" + ], + "exports_files": [ + "chrome-headless-shell-win64/chrome-headless-shell.exe" + ] + } + }, + "rules_browsers_chromedriver_linux": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "8c56e0eeefefebef24f798538713ef60dcd8d663b205d2f13935f36215d937fe", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/linux64/chromedriver-linux64.zip" + ], + "named_files": { + "CHROMEDRIVER": "chromedriver-linux64/chromedriver" + }, + "exclude_patterns": [], + "exports_files": [ + "chromedriver-linux64/chromedriver" + ] + } + }, + "rules_browsers_chromedriver_mac": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "e229297b03f66d48e7965394a7dc14a7ba5661c593612e7115e8f139d4357fd8", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/mac-x64/chromedriver-mac-x64.zip" + ], + "named_files": { + "CHROMEDRIVER": "chromedriver-mac-x64/chromedriver" + }, + "exclude_patterns": [], + "exports_files": [ + "chromedriver-mac-x64/chromedriver" + ] + } + }, + "rules_browsers_chromedriver_mac_arm": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "777bde55d6e5da1409820ca39b38eafa507ab010a8d43ff5d6d7ad1a895c32ce", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/mac-arm64/chromedriver-mac-arm64.zip" + ], + "named_files": { + "CHROMEDRIVER": "chromedriver-mac-arm64/chromedriver" + }, + "exclude_patterns": [], + "exports_files": [ + "chromedriver-mac-arm64/chromedriver" + ] + } + }, + "rules_browsers_chromedriver_win64": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "2ce082a29c852295aa09f94ccc50c5f7e41caae5b80f5016329825e45a27de8c", + "urls": [ + "https://storage.googleapis.com/chrome-for-testing-public/158.0.8117.0/win64/chromedriver-win64.zip" + ], + "named_files": { + "CHROMEDRIVER": "chromedriver-win64/chromedriver.exe" + }, + "exclude_patterns": [], + "exports_files": [ + "chromedriver-win64/chromedriver.exe" + ] + } + }, + "rules_browsers_firefox_linux": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "9549b054ec4cf85f50f6a19d8c6fb79580312e71ebc4ec2cb217ec9af09dc12a", + "urls": [ + "https://archive.mozilla.org/pub/firefox/releases/158.0/linux-x86_64/en-US/firefox-158.0.tar.xz" + ], + "named_files": { + "FIREFOX": "firefox/firefox" + }, + "exclude_patterns": [], + "exports_files": [ + "firefox/firefox" + ] + } + }, + "rules_browsers_firefox_mac": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "dfe8508ee8880cec1578253eb02b9154b5971a55e832d97396f463cd07b5ef67", + "urls": [ + "https://archive.mozilla.org/pub/firefox/releases/158.0/mac/en-US/Firefox%20158.0.dmg" + ], + "named_files": { + "FIREFOX": "Firefox.app/Contents/MacOS/firefox" + }, + "exclude_patterns": [], + "exports_files": [ + "Firefox.app/Contents/MacOS/firefox" + ] + } + }, + "rules_browsers_firefox_mac_arm": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "dfe8508ee8880cec1578253eb02b9154b5971a55e832d97396f463cd07b5ef67", + "urls": [ + "https://archive.mozilla.org/pub/firefox/releases/158.0/mac/en-US/Firefox%20158.0.dmg" + ], + "named_files": { + "FIREFOX": "Firefox.app/Contents/MacOS/firefox" + }, + "exclude_patterns": [], + "exports_files": [ + "Firefox.app/Contents/MacOS/firefox" + ] + } + }, + "rules_browsers_firefox_win64": { + "repoRuleId": "@@rules_browsers+//browsers/private:browser_repo.bzl%browser_repo", + "attributes": { + "sha256": "450ab1c772e02f0c9ef02f489b153d86c45bd9311b202d10dc233f3c0206f8d1", + "urls": [ + "https://archive.mozilla.org/pub/firefox/releases/158.0/win64/en-US/Firefox%20Setup%20158.0.exe" + ], + "named_files": { + "FIREFOX": "core/firefox.exe" + }, + "exclude_patterns": [], + "exports_files": [ + "core/firefox.exe" + ] + } + } + } + } + }, + "@@rules_fuzzing+//fuzzing/private:extensions.bzl%non_module_dependencies": { + "general": { + "bzlTransitiveDigest": "CYUiFDCnL2VGx3uotIu/VuGabgObnZra2zzRUJCX0sU=", + "usagesDigest": "wy6ISK6UOcBEjj/mvJ/S3WeXoO67X+1llb9yPyFtPgc=", + "recordedInputs": [ + "REPO_MAPPING:rules_fuzzing+,bazel_tools bazel_tools" + ], + "generatedRepoSpecs": { + "platforms": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz", + "https://github.com/bazelbuild/platforms/releases/download/0.0.8/platforms-0.0.8.tar.gz" + ], + "sha256": "8150406605389ececb6da07cbcb509d5637a3ab9a24bc69b1101531367d89d74" + } + }, + "rules_python": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "d70cd72a7a4880f0000a6346253414825c19cdd40a28289bdf67b8e6480edff8", + "strip_prefix": "rules_python-0.28.0", + "url": "https://github.com/bazelbuild/rules_python/releases/download/0.28.0/rules_python-0.28.0.tar.gz" + } + }, + "bazel_skylib": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "cd55a062e763b9349921f0f5db8c3933288dc8ba4f76dd9416aac68acee3cb94", + "urls": [ + "https://mirror.bazel.build/github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz", + "https://github.com/bazelbuild/bazel-skylib/releases/download/1.5.0/bazel-skylib-1.5.0.tar.gz" + ] + } + }, + "com_google_absl": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/abseil/abseil-cpp/archive/refs/tags/20240116.1.zip" + ], + "strip_prefix": "abseil-cpp-20240116.1", + "integrity": "sha256-7capMWOvWyoYbUaHF/b+I2U6XLMaHmky8KugWvfXYuk=" + } + }, + "rules_fuzzing_oss_fuzz": { + "repoRuleId": "@@rules_fuzzing+//fuzzing/private/oss_fuzz:repository.bzl%oss_fuzz_repository", + "attributes": {} + }, + "honggfuzz": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@rules_fuzzing+//:honggfuzz.BUILD", + "sha256": "6b18ba13bc1f36b7b950c72d80f19ea67fbadc0ac0bb297ec89ad91f2eaa423e", + "url": "https://github.com/google/honggfuzz/archive/2.5.zip", + "strip_prefix": "honggfuzz-2.5" + } + }, + "rules_fuzzing_jazzer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "ee6feb569d88962d59cb59e8a31eb9d007c82683f3ebc64955fd5b96f277eec2", + "url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer/0.20.1/jazzer-0.20.1.jar" + } + }, + "rules_fuzzing_jazzer_api": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "f5a60242bc408f7fa20fccf10d6c5c5ea1fcb3c6f44642fec5af88373ae7aa1b", + "url": "https://repo1.maven.org/maven2/com/code-intelligence/jazzer-api/0.20.1/jazzer-api-0.20.1.jar" + } + } + } + } + }, + "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { + "general": { + "bzlTransitiveDigest": "03Qju4tW0vE+0RBuZGuV2A4Hx6AiSkdNahYvworx2aM=", + "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", + "recordedInputs": [ + "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" + ], + "generatedRepoSpecs": { + "com_github_jetbrains_kotlin_git": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", + "attributes": { + "urls": [ + "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" + ], + "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" + } + }, + "com_github_jetbrains_kotlin": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", + "attributes": { + "git_repository_name": "com_github_jetbrains_kotlin_git", + "compiler_version": "1.9.23" + } + }, + "com_github_google_ksp": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", + "attributes": { + "urls": [ + "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" + ], + "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", + "strip_version": "1.9.23-1.0.20" + } + }, + "com_github_pinterest_ktlint": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", + "urls": [ + "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" + ], + "executable": true + } + }, + "rules_android": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", + "strip_prefix": "rules_android-0.1.1", + "urls": [ + "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" + ] + } + } + } + } + }, + "@@rules_python+//python/extensions:config.bzl%config": { + "general": { + "bzlTransitiveDigest": "1eE/aNv0So57EZMzcxgDSCFkzlydiaC4TpHowQfrcBk=", + "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", + "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", + "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", + "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", + "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", + "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", + "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", + "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", + "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", + "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", + "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", + "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", + "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", + "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", + "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" + ], + "generatedRepoSpecs": { + "rules_python_internal": { + "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", + "attributes": { + "transition_setting_generators": {}, + "transition_settings": [] + } + }, + "pypi__build": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", + "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__click": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", + "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__colorama": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__importlib_metadata": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", + "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__installer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", + "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__more_itertools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", + "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__packaging": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", + "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pep517": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", + "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", + "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip_tools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", + "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pyproject_hooks": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", + "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__setuptools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", + "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__tomli": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", + "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__wheel": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", + "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__zipp": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", + "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + } + } + } + }, + "@@rules_python+//python/uv:uv.bzl%uv": { + "general": { + "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", + "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,platforms platforms" + ], + "generatedRepoSpecs": { + "uv": { + "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", + "attributes": { + "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", + "toolchain_names": [ + "none" + ], + "toolchain_implementations": { + "none": "'@@rules_python+//python:none'" + }, + "toolchain_compatible_with": { + "none": [ + "@platforms//:incompatible" + ] + }, + "toolchain_target_settings": {} + } + } + } + } + }, + "@@yq.bzl+//yq:extensions.bzl%yq": { + "general": { + "bzlTransitiveDigest": "lH0oIGWeh0sI6/jlDMTxYo34YKkvNfvwdiMIhfM532I=", + "usagesDigest": "27rm7S1Qp4vHAQvOdbtXzYRzPjiLOEI3iAOZPhQu5L0=", + "recordedInputs": [ + "REPO_MAPPING:yq.bzl+,bazel_lib bazel_lib+" + ], + "generatedRepoSpecs": { + "yq_darwin_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "darwin_amd64", + "version": "4.45.2" + } + }, + "yq_darwin_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "darwin_arm64", + "version": "4.45.2" + } + }, + "yq_linux_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_amd64", + "version": "4.45.2" + } + }, + "yq_linux_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_arm64", + "version": "4.45.2" + } + }, + "yq_linux_s390x": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_s390x", + "version": "4.45.2" + } + }, + "yq_linux_riscv64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_riscv64", + "version": "4.45.2" + } + }, + "yq_linux_ppc64le": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "linux_ppc64le", + "version": "4.45.2" + } + }, + "yq_windows_amd64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "windows_amd64", + "version": "4.45.2" + } + }, + "yq_windows_arm64": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo", + "attributes": { + "platform": "windows_arm64", + "version": "4.45.2" + } + }, + "yq_toolchains": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_toolchains_repo", + "attributes": { + "user_repository_name": "yq" + } + }, + "yq": { + "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_host_alias_repo", + "attributes": {} + } + } + } + } + }, + "facts": { + "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { + "exe:12.14.0": "{\"darwin-arm64\":\"sha512-tnrdUB8ecJoehuKKqi1YHCBVl61xtJ9XVGcx5EviC6fWO4DeGI8GtXrjvJxDBM3nuVjJeWistgsUp/N142x4sw==\",\"darwin-x64\":\"sha512-vlBfGkruqQRsEHTW43cPwpYd7QlbIJ//cvpAdb3bVXkI8SgTPoXhCnc05m+ZNiPrrEkBsX/t7KHw9aXpkzPm3Q==\",\"linux-arm64\":\"sha512-7joGShZ44Nk3WpNU+60JoOPjCXlgxhJAq11S2UiVtwCUDgTer1yTPPfxGW8pxaD6046actYLIYDbZNlubHWs0A==\",\"linux-arm64-musl\":\"sha512-DRIe4yQkXBGA/PhVJdzlbnllxBdAtnMMvTVLLcGpsK5FvJz9kxD2Hyxtn68ovmNkVX06f6p45cQwfm3wx2jn1A==\",\"linux-x64\":\"sha512-F6K8e/jWxiuEYJaQtRRUG9ynBSTEHa+t7Lf86MKuOO5UA7WVVMbi+oB8nY6iKW++ncWS2yG6T9faXZGxqNsfoQ==\",\"linux-x64-musl\":\"sha512-btZiZ2Ot0llKK1ndSfA5i3IRX7/S0VrmzseNH/BJ4c+Cady+S81H6vvU1zByggHMyqQhC8OgxO5PiIY7D9bH0Q==\",\"win32-arm64\":\"sha512-DOjlkVB6TtTKeAAe0hNZXn5S7YqESmo1Z6SGbR4BZNQ4isRg31XVHjX2RFTbY8gnZq68IwzyLmPqlEjMmYxofg==\",\"win32-x64\":\"sha512-/LSX6mew/sLf08XqcjemuklNKf1K/Ftpgj4//hK/HKzI2dKbmxDB27a/2OaQ5a0O3/3OFzlssXDziZsi+yVBwQ==\"}" + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "notice_version": "3" + } + }, + "factsVersions": {} +} diff --git a/README.md b/README.md index a10d77e7ad8e..9d3364263312 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,196 @@ -## Angular CLI -### CLI for Angular applications based on the [ember-cli](http://www.ember-cli.com/) project. + + +

Angular CLI - The CLI tool for Angular.

+ +

+
+ Angular CLI logo +

+ The Angular CLI is a command-line interface tool that you use to initialize, develop, scaffold, +
and maintain Angular applications directly from a command shell.
+
+

+ +

+ angular.dev/tools/cli +
+

+ +

+ Contributing Guidelines + · + Submit an Issue + · + Blog +
+
+

+ +
- -[![Build Status](https://img.shields.io/travis/angular/angular-cli/master.svg?label=travis)][travis-badge-url] -[![CircleCI branch](https://img.shields.io/circleci/project/github/RedSparr0w/node-csgo-parser/master.svg?label=circleci)](https://circleci.com/gh/angular/angular-cli) -[![Dependency Status][david-badge]][david-badge-url] -[![devDependency Status][david-dev-badge]][david-dev-badge-url] - -[![npm](https://img.shields.io/npm/v/%40angular/cli.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/v/%40angular/cli/next.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/l/@angular/cli.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/dm/@angular/cli.svg)][npm-badge-url] - -[![Join the chat at https://gitter.im/angular/angular-cli](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/angular/angular-cli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![Caretaker](https://img.shields.io/badge/caretaker-filipesilva-blue.svg)](https://github.com/filipesilva) - -[![GitHub forks](https://img.shields.io/github/forks/angular/angular-cli.svg?style=social&label=Fork)](https://github.com/angular/angular-cli/fork) -[![GitHub stars](https://img.shields.io/github/stars/angular/angular-cli.svg?style=social&label=Star)](https://github.com/angular/angular-cli) - - -## Note +## Documentation -The CLI is now in 1.0. -If you are updating from a beta or RC version, check out our [1.0 Update Guide](https://github.com/angular/angular-cli/wiki/stories-1.0-update). +Get started with Angular CLI, learn the fundamentals and explore advanced topics on our documentation website. -If you wish to collaborate, check out [our issue list](https://github.com/angular/angular-cli/issues). +- [Getting started][quickstart] +- [CLI][cli] +- [Workspace and project file structure][filestructure] +- [Workspace configuration][workspaceconfig] +- [Schematics][schematics] -Before submitting new issues, have a look at [issues marked with the `type: faq` label](https://github.com/angular/angular-cli/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3A%22type%3A%20faq%22%20). +## Development Setup -## Prerequisites -Both the CLI and generated project have dependencies that require Node 6.9.0 or higher, together -with NPM 3 or higher. +### Prerequisites -## Table of Contents +- Install [Node.js] which includes [Node Package Manager][npm] -* [Installation](#installation) -* [Usage](#usage) -* [Generating a New Project](#generating-and-serving-an-angular-project-via-a-development-server) -* [Generating Components, Directives, Pipes and Services](#generating-components-directives-pipes-and-services) -* [Updating Angular CLI](#updating-angular-cli) -* [Development Hints for working on Angular CLI](#development-hints-for-working-on-angular-cli) -* [Documentation](#documentation) -* [License](#license) +### Setting Up a Project -## Installation +Install the Angular CLI globally: -**BEFORE YOU INSTALL:** please read the [prerequisites](#prerequisites) -```bash +``` npm install -g @angular/cli ``` -## Usage +Create workspace: -```bash -ng help +``` +ng new [PROJECT NAME] ``` -### Generating and serving an Angular project via a development server +Run the application: -```bash -ng new PROJECT-NAME -cd PROJECT-NAME +``` +cd [PROJECT NAME] ng serve ``` -Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. -You can configure the default HTTP host and port used by the development server with two command-line options : +Angular is cross-platform, fast, scalable, has incredible tooling, and is loved by millions. -```bash -ng serve --host 0.0.0.0 --port 4201 -``` +## Quickstart -### Generating Components, Directives, Pipes and Services +[Get started in 5 minutes][quickstart]. -You can use the `ng generate` (or just `ng g`) command to generate Angular components: +## Ecosystem -```bash -ng generate component my-new-component -ng g component my-new-component # using the alias +

+ angular ecosystem logos +

-# components support relative path generation -# if in the directory src/app/feature/ and you run -ng g component new-cmp -# your component will be generated in src/app/feature/new-cmp -# but if you were to run -ng g component ../newer-cmp -# your component will be generated in src/app/newer-cmp -# if in the directory src/app you can also run -ng g component feature/new-cmp -# and your component will be generated in src/app/feature/new-cmp -``` -You can find all possible blueprints in the table below: - -Scaffold | Usage ---- | --- -[Component](https://github.com/angular/angular-cli/wiki/generate-component) | `ng g component my-new-component` -[Directive](https://github.com/angular/angular-cli/wiki/generate-directive) | `ng g directive my-new-directive` -[Pipe](https://github.com/angular/angular-cli/wiki/generate-pipe) | `ng g pipe my-new-pipe` -[Service](https://github.com/angular/angular-cli/wiki/generate-service) | `ng g service my-new-service` -[Class](https://github.com/angular/angular-cli/wiki/generate-class) | `ng g class my-new-class` -[Guard](https://github.com/angular/angular-cli/wiki/generate-guard) | `ng g guard my-new-guard` -[Interface](https://github.com/angular/angular-cli/wiki/generate-interface) | `ng g interface my-new-interface` -[Enum](https://github.com/angular/angular-cli/wiki/generate-enum) | `ng g enum my-new-enum` -[Module](https://github.com/angular/angular-cli/wiki/generate-module) | `ng g module my-module` - - - - -angular-cli will add reference to `components`, `directives` and `pipes` automatically in the `app.module.ts`. If you need to add this references to another custom module, follow this steps: - - 1. `ng g module new-module` to create a new module - 2. call `ng g component new-module/new-component` - -This should add the new `component`, `directive` or `pipe` reference to the `new-module` you've created. - -### Updating Angular CLI - -If you're using Angular CLI `1.0.0-beta.28` or less, you need to uninstall `angular-cli` package. It should be done due to changing of package's name and scope from `angular-cli` to `@angular/cli`: -```bash -npm uninstall -g angular-cli -npm uninstall --save-dev angular-cli -``` +- [Angular Framework][adev] +- [Angular Material][angularmaterial] -To update Angular CLI to a new version, you must update both the global package and your project's local package. +## Changelog -Global package: -```bash -npm uninstall -g @angular/cli -npm cache clean -npm install -g @angular/cli@latest -``` +[Learn about the latest improvements][changelog]. -Local project package: -```bash -rm -rf node_modules dist # use rmdir /S/Q node_modules dist in Windows Command Prompt; use rm -r -fo node_modules,dist in Windows PowerShell -npm install --save-dev @angular/cli@latest -npm install -``` +## Upgrading -If you are updating to 1.0 from a beta or RC version, check out our [1.0 Update Guide](https://github.com/angular/angular-cli/wiki/stories-1.0-update). +Check out our [upgrade guide](https://update.angular.dev/) to find out the best way to upgrade your project. -You can find more details about changes between versions in [the Releases tab on GitHub](https://github.com/angular/angular-cli/releases). +## Contributing +### Contributing Guidelines -## Development Hints for working on Angular CLI +Read through our [contributing guidelines][contributing] to learn about our submission process, coding rules and more. -### Working with master +### Want to Help? -```bash -git clone https://github.com/angular/angular-cli.git -cd angular-cli -npm link -``` +Want to report a bug, contribute some code, or improve documentation? Excellent! Read up on our guidelines for [contributing][contributing] and then check out one of our issues labeled as [help wanted](https://github.com/angular/angular-cli/labels/help%20wanted) or [good first issue](https://github.com/angular/angular-cli/labels/good%20first%20issue). -`npm link` is very similar to `npm install -g` except that instead of downloading the package -from the repo, the just cloned `angular-cli/` folder becomes the global package. -Additionally, this repository publishes several packages and we use special logic to load all of them -on development setups. +### Code of Conduct -Any changes to the files in the `angular-cli/` folder will immediately affect the global `@angular/cli` package, -allowing you to quickly test any changes you make to the cli project. +Help us keep Angular open and inclusive. Please read and follow our [Code of Conduct][codeofconduct]. -Now you can use `@angular/cli` via the command line: +### Developer Guide -```bash -ng new foo -cd foo -npm link @angular/cli -ng serve -``` +Read through our [developer guide][developer] to learn about how to build and test the Angular CLI locally. -`npm link @angular/cli` is needed because by default the globally installed `@angular/cli` just loads -the local `@angular/cli` from the project which was fetched remotely from npm. -`npm link @angular/cli` symlinks the global `@angular/cli` package to the local `@angular/cli` package. -Now the `angular-cli` you cloned before is in three places: -The folder you cloned it into, npm's folder where it stores global packages and the Angular CLI project you just created. -You can also use `ng new foo --link-cli` to automatically link the `@angular/cli` package. +## Community -Please read the official [npm-link documentation](https://docs.npmjs.com/cli/link) -and the [npm-link cheatsheet](http://browsenpm.org/help#linkinganynpmpackagelocally) for more information. +Join the conversation and help the community. -To run the Angular CLI test suite use the `node tests/run_e2e.js` command. -It can also receive a filename to only run that test (e.g. `node tests/run_e2e.js tests/e2e/tests/build/dev-build.ts`). +- [X (formerly Twitter)][twitter] +- [Discord][discord] +- [Gitter][gitter] +- [YouTube][youtube] +- [StackOverflow][stackoverflow] +- Find a Local [Meetup][meetup] -As part of the test procedure, all packages will be built and linked. -You will need to re-run `npm link` to re-link the development Angular CLI environment after tests finish. +## Packages +This is a monorepo which contains many tools and packages: -## Documentation -The documentation for the Angular CLI is located in this repo's [wiki](https://github.com/angular/angular-cli/wiki). -## License +### Tools + +| Project | Package | Version | Links | +|---|---|---|---| +**Angular Build System** | [`@angular/build`](https://npmjs.com/package/@angular/build) | [![latest](https://img.shields.io/npm/v/%40angular%2Fbuild/latest.svg)](https://npmjs.com/package/@angular/build) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/build/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-build-builds) +**Angular CLI** | [`@angular/cli`](https://npmjs.com/package/@angular/cli) | [![latest](https://img.shields.io/npm/v/%40angular%2Fcli/latest.svg)](https://npmjs.com/package/@angular/cli) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/cli/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/cli-builds) +**Schematics CLI** | [`@angular-devkit/schematics-cli`](https://npmjs.com/package/@angular-devkit/schematics-cli) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fschematics-cli/latest.svg)](https://npmjs.com/package/@angular-devkit/schematics-cli) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-schematics-cli-builds) + + +### Packages + + +| Project | Package | Version | Links | +|---|---|---|---| +**Angular SSR** | [`@angular/ssr`](https://npmjs.com/package/@angular/ssr) | [![latest](https://img.shields.io/npm/v/%40angular%2Fssr/latest.svg)](https://npmjs.com/package/@angular/ssr) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/ssr/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-ssr-builds) +**Architect** | [`@angular-devkit/architect`](https://npmjs.com/package/@angular-devkit/architect) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Farchitect/latest.svg)](https://npmjs.com/package/@angular-devkit/architect) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/architect/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-architect-builds) +**Build Angular** | [`@angular-devkit/build-angular`](https://npmjs.com/package/@angular-devkit/build-angular) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fbuild-angular/latest.svg)](https://npmjs.com/package/@angular-devkit/build-angular) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/build_angular/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-build-angular-builds) +**Build Webpack** | [`@angular-devkit/build-webpack`](https://npmjs.com/package/@angular-devkit/build-webpack) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fbuild-webpack/latest.svg)](https://npmjs.com/package/@angular-devkit/build-webpack) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/build_webpack/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-build-webpack-builds) +**Core** | [`@angular-devkit/core`](https://npmjs.com/package/@angular-devkit/core) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fcore/latest.svg)](https://npmjs.com/package/@angular-devkit/core) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/core/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-core-builds) +**Schematics** | [`@angular-devkit/schematics`](https://npmjs.com/package/@angular-devkit/schematics) | [![latest](https://img.shields.io/npm/v/%40angular-devkit%2Fschematics/latest.svg)](https://npmjs.com/package/@angular-devkit/schematics) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular_devkit/schematics/README.md) [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-devkit-schematics-builds) + +#### Misc + +| Project | Package | Version | Links | +|---|---|---|---| +**Angular Create** | [`@angular/create`](https://npmjs.com/package/@angular/create) | [![latest](https://img.shields.io/npm/v/%40angular%2Fcreate/latest.svg)](https://npmjs.com/package/@angular/create) | [![README](https://img.shields.io/badge/README--green.svg)](/packages/angular/create/README.md) +**Webpack Angular Plugin** | [`@ngtools/webpack`](https://npmjs.com/package/@ngtools/webpack) | [![latest](https://img.shields.io/npm/v/%40ngtools%2Fwebpack/latest.svg)](https://npmjs.com/package/@ngtools/webpack) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/ngtools-webpack-builds) + +#### Schematics + +| Project | Package | Version | Links | +|---|---|---|---| +**Angular PWA Schematics** | [`@angular/pwa`](https://npmjs.com/package/@angular/pwa) | [![latest](https://img.shields.io/npm/v/%40angular%2Fpwa/latest.svg)](https://npmjs.com/package/@angular/pwa) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/angular-pwa-builds) +**Angular Schematics** | [`@schematics/angular`](https://npmjs.com/package/@schematics/angular) | [![latest](https://img.shields.io/npm/v/%40schematics%2Fangular/latest.svg)](https://npmjs.com/package/@schematics/angular) | [![snapshot](https://img.shields.io/badge/snapshot--blue.svg)](https://github.com/angular/schematics-angular-builds) + -MIT +**Love Angular CLI? Give our repo a star :star: :arrow_up:.** -[travis-badge]: https://travis-ci.org/angular/angular-cli.svg?branch=master -[travis-badge-url]: https://travis-ci.org/angular/angular-cli -[david-badge]: https://david-dm.org/angular/angular-cli.svg -[david-badge-url]: https://david-dm.org/angular/angular-cli -[david-dev-badge]: https://david-dm.org/angular/angular-cli/dev-status.svg -[david-dev-badge-url]: https://david-dm.org/angular/angular-cli?type=dev -[npm-badge]: https://img.shields.io/npm/v/@angular/cli.svg -[npm-badge-url]: https://www.npmjs.com/package/@angular/cli +[contributing]: CONTRIBUTING.md +[developer]: docs/DEVELOPER.md +[quickstart]: https://angular.dev/tutorials/learn-angular +[changelog]: CHANGELOG.md +[documentation]: https://angular.dev/overview +[angularmaterial]: https://material.angular.dev/ +[cli]: https://angular.dev/tools/cli +[adev]: https://angular.dev/ +[workspaceconfig]: https://angular.dev/reference/configs/workspace-config +[schematics]: https://angular.dev/tools/cli/schematics +[filestructure]: https://angular.dev/reference/configs/file-structure +[node.js]: https://nodejs.org/ +[npm]: https://www.npmjs.com/get-npm +[codeofconduct]: https://github.com/angular/angular/blob/main/CODE_OF_CONDUCT.md +[twitter]: https://www.x.com/angular +[discord]: https://discord.gg/angular +[gitter]: https://gitter.im/angular/angular-cli +[stackoverflow]: https://stackoverflow.com/questions/tagged/angular-cli +[youtube]: https://youtube.com/angular +[meetup]: https://www.meetup.com/find/?keywords=angular diff --git a/REPO.bazel b/REPO.bazel new file mode 100644 index 000000000000..9eb8128879bd --- /dev/null +++ b/REPO.bazel @@ -0,0 +1,5 @@ +ignore_directories([ + ".git", + "dist", + "**/node_modules/**", +]) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..1d62d6ccd8ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +Angular is part of Google [Open Source Software Vulnerability Reward Program](https://bughunters.google.com/about/rules/6521337925468160/google-open-source-software-vulnerability-reward-program-rules). For vulnerabilities in Angular, please submit your report [here](https://bughunters.google.com/report). + +For more information, check out [Angular's security policy](https://angular.dev/best-practices/security). + +## Policies + +The following defines some team policies around what does and does not constitute a security vulnerability. + +- Prototype pollution is itself a DoS vulnerability. + - An attacker could pollute any symbol into the wrong type and cause an error, nothing we can do will prevent that. + - Therefore we don't care about any DoS vulnerability which relies on prototype pollution as a precondition. +- We trust the user's source code. + - "A malicious repository can execute untrusted code" is not a viable attack vector. + - A malicious repository could always rebind `node_modules/.bin/ng` to do something malicious, nothing we do can prevent that. + - We assume it is always unsafe to run an untrusted repository outside of a sandbox. +- DoS attacks which break only the attacker's client are not security issues. + - Exception: DoS attacks which waste server resources significantly beyond the cost of the attacker's request may be security issues. +- Memory leaks are considered general bugs, not DoS vulnerabilities. + - While any memory leak is technically a DoS vulnerability in SSR, these are generally low enough impact to not be considered security issues. +- Our public API contract is defined by our TS types, any issue which requires breaking those types (ex. via a cast to any) falls outside Angular's support. + - Ex. It may be possible to leak user state by attaching it to some global data structure in SSR, but we do not consider this to be a valid vulnerability if you have to escape TS types to do it. + +## Invariants + +The following are invariants about Angular's technical infrastructure which limit some potential attack vectors. These statements are asserted by humans, not proven in any formal means. However, any security report should assume them to be true unless and until it can conclusively prove them to be false in an attack. If one of these statements invalidates a security report, that report bears the burden of conclusively demonstrating the statement to be false in its reproduction. + +- Zones don't leak across SSR requests (except for the root) + - Every request gets its own Zone, so data stored on the Zone is unique to that user, no intrinsic risk of leaking user data from one request to an attacker's request. +- Injectors are not shared between applications. + - SSR always creates a new ApplicationRef and root services, no intrinsic risk of leaking user data from one request to an attacker's request. + - Exception: Module-scoped data is shared and is a potential vector for data leakage. +- `ngDevMode` / `ngServerMode` are set at compile time in production deployments and cannot be manipulated by attackers. + +## Prioritization Guide + +Angular is not a product, and almost no vulnerability is an exploit in and of itself, given there is nothing to compromise. Angular can only be exploited through a real deployment of a specific application. The vast majority of potential issues do not affect the default `ng new` app, and require additional steps of varying use/misuse of the framework before introducing a vulnerability on that real application. This goes beyond just an attacker finding the right sequence of characters to cause an exploit, and requires a well-intentioned developer to make a legitimate mistake introducing the vulnerability in the first place. Therefore we need to examine prioritization through the lens of how likely a developer is to make that mistake and the severity of what happens when they do. + +- **High:** A vulnerability impacts ~all users of a major feature in common usage such as SSR, hydration, templating, or routing (ex. SSRF via a malicious `Host` header affects all SSR + `HttpClient` users, which is a fairly common use case). +- **Medium:** A vulnerability impacts users interacting with public APIs in their reasonable usage. +- **Low:** A vulnerability impacts users interacting with public APIs misused in an unintended or unreasonable way (ex. vulnerability from unsanitized user input polluting the `APP_ID` value, which should never be user writable). +- **None:** A vulnerability impacts users of private APIs or explicitly unsupported use cases. + +Experimental / developer preview APIs _are not_ exempted from these priorities. While we make no hard guarantees about these APIs publicly, a vulnerability in such features should be raised to our attention and addressed before reaching stability. diff --git a/SUPPORT.md b/SUPPORT.md deleted file mode 100644 index 70b95eb65532..000000000000 --- a/SUPPORT.md +++ /dev/null @@ -1,16 +0,0 @@ -# Got a Question or Problem? - -Please, do not open issues for the general support questions as we want to keep GitHub issues for bug reports and feature requests. You've got much better chances of getting your question answered on [StackOverflow](https://stackoverflow.com/questions/tagged/angular-cli) where the questions should be tagged with tag `angular-cli`. - -StackOverflow is a much better place to ask questions since: - -- there are thousands of people willing to help on StackOverflow -- questions and answers stay available for public viewing so your question / answer might help someone else -- StackOverflow's voting system assures that the best answers are prominently visible. - -To save your and our time we will be systematically closing all the issues that are requests for general support and redirecting people to StackOverflow. - -If you would like to chat about the question in real-time, you can reach out via [our gitter channel][gitter]. - - -[gitter]: https://gitter.im/angular/angular-cli \ No newline at end of file diff --git a/bin/ng b/bin/ng deleted file mode 100755 index 00d18bf83791..000000000000 --- a/bin/ng +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// Provide a title to the process in `ps` -process.title = '@angular/cli'; - -require('../lib/bootstrap-local'); -require('../packages/@angular/cli/bin/ng'); diff --git a/constants.bzl b/constants.bzl new file mode 100644 index 000000000000..ee06dec88ac6 --- /dev/null +++ b/constants.bzl @@ -0,0 +1,31 @@ +# Engine versions to stamp in a release package.json +RELEASE_ENGINES_NODE = "^22.22.3 || ^24.15.0 || >=26.0.0" +RELEASE_ENGINES_NPM = "^6.11.0 || ^7.5.6 || >=8.0.0" +RELEASE_ENGINES_YARN = ">= 1.13.0" + +NG_PACKAGR_VERSION = "^22.3.0-next.0" +ANGULAR_FW_VERSION = "^22.3.0-next.0" +ANGULAR_FW_PEER_DEP = "^22.0.0 || ^22.3.0-next.0" +NG_PACKAGR_PEER_DEP = "^22.0.0 || ^22.3.0-next.0" + +# Baseline widely-available date in `YYYY-MM-DD` format which defines Angular's +# browser support. This date serves as the source of truth for the Angular CLI's +# default browser set used to determine what downleveling is necessary. +# +# See: https://web.dev/baseline +BASELINE_DATE = "2026-05-07" + +SNAPSHOT_REPOS = { + "@angular/cli": "angular/cli-builds", + "@angular/pwa": "angular/angular-pwa-builds", + "@angular/build": "angular/angular-build-builds", + "@angular/ssr": "angular/angular-ssr-builds", + "@angular-devkit/architect": "angular/angular-devkit-architect-builds", + "@angular-devkit/build-angular": "angular/angular-devkit-build-angular-builds", + "@angular-devkit/build-webpack": "angular/angular-devkit-build-webpack-builds", + "@angular-devkit/core": "angular/angular-devkit-core-builds", + "@angular-devkit/schematics": "angular/angular-devkit-schematics-builds", + "@angular-devkit/schematics-cli": "angular/angular-devkit-schematics-cli-builds", + "@ngtools/webpack": "angular/ngtools-webpack-builds", + "@schematics/angular": "angular/schematics-angular-builds", +} diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md new file mode 100644 index 000000000000..ddb8341e662b --- /dev/null +++ b/docs/DEVELOPER.md @@ -0,0 +1,189 @@ +# Building and Testing Angular CLI + +## Installation + +To get started locally, follow these instructions: + +1. If you haven't done it already, [make a fork of this repo](https://github.com/angular/angular-cli/fork). +2. If you are on Windows, see [the extra steps needed for contributing on Windows](#windows) +3. Clone to your local computer using `git`. +4. Make sure that you have Node `v22.22.3` or higher installed (see `engines` in `package.json`). See instructions [here](https://nodejs.org/en/download/). +5. Install `pnpm`. + - You can install pnpm by running `npm i -g pnpm@12`. + - See detailed instructions [here](https://pnpm.io/installation). +6. Run `pnpm install` from the root of your clone of this project to install dependencies. + +## Building and Installing the CLI + +To make a local build: + +```shell +pnpm build --local +``` + +This generates a number of tarballs in the `dist/` directory. To actually use +the locally built tools, switch to another repository reproducing the specific +issue you want to fix (or just generate a local repo with `ng new`). Then +install the locally built packages: + +```shell +cd "${EXAMPLE_ANGULAR_PROJECT_REPO}" +npm install -D ${CLI_REPO}/dist/*.tgz +``` + +Builds of this example project will use tooling created from the previous local +build and include any local changes. When using the CLI, it will automatically +check for a local install and use that if present. This means you can just run: + +```shell +npm install -g @angular/cli +``` + +to get a global install of the latest CLI release. Then running any `ng` command +in the example project will automatically find and use the local build of the +CLI. + +Note: If you are testing `ng update`, be aware that installing all the tarballs +will also update the framework (`@angular/core`) to the latest version. In this +case, simply install the CLI alone with +`npm install -D ${CLI_REPO}/dist/_angular_cli.tgz`, that way the rest of the +project remains to be upgraded with `ng update`. + +## Debugging + +To debug an invocation of the CLI, [build and install the CLI for an example +project](#building-and-installing-the-cli), then run the desired `ng` command +as: + +```shell +node --inspect-brk node_modules/.bin/ng +``` + +This will trigger a breakpoint as the CLI starts up. You can connect to this +using the supported mechanisms for your IDE, but the simplest option is to open +Chrome to [chrome://inspect](chrome://inspect) and then click on the `inspect` +link for the `node_modules/.bin/ng` Node target. + +Unfortunately, the CLI dynamically `require()`'s other files mid-execution, so +the debugger is not aware of all the source code files before hand. As a result, +it is tough to put breakpoints on files before the CLI loads them. The easiest +workaround is to use the `debugger;` statement to stop execution in the file you +are interested in, and then you should be able to step around and set breakpoints +as expected. + +## Testing + +There are two different test suites which can be run locally: + +### Unit tests + +- Run all tests: `pnpm bazel test //packages/...` +- Run a subset of the tests, use the full Bazel target example: `pnpm bazel test //packages/schematics/angular:test` +- For a complete list of test targets use the following Bazel query: `pnpm bazel query "tests(//packages/...)"` + +When debugging a specific test, change `describe()` or `it()` to `fdescribe()` +and `fit()` to focus execution to just that one test. This will keep the output clean and speed up execution by not running irrelevant tests. + +You can find more info about debugging [tests with Bazel in the docs.](https://github.com/angular/angular-cli/blob/main/docs/process/bazel.md#debugging-jasmine_node_test) + +### End to end tests + +- For a complete list of test targets use the following Bazel query: `pnpm bazel query "tests(//tests/...)"` +- Run a subset of the tests: `pnpm bazel test //tests:e2e_node22 --config=e2e --test_filter="tests/i18n/ivy-localize-*"` +- Use `bazel run` to debug failing tests debugging: `pnpm bazel run //tests:e2e_node22 --config=e2e --test_arg="--glob=tests/basic/aot.ts"` +- Provide additional `e2e_runner` options using `--test_arg`: `--test_arg="--package-manager=yarn"` + +When running the debug commands, Node will stop and wait for a debugger to attach. +You can attach your IDE to the debugger to stop on breakpoints and step through the code. Also, see [IDE Specific Usage](#ide-specific-usage) for a +simpler debug story. + +## IDE Specific Usage + +Some additional tips for developing in specific IDEs. + +### Intellij IDEA / WebStorm + +To load the project in Intellij products, simply `Open` the repository folder. +Do **not** `Import Project`, because that will overwrite the existing +configuration. + +Once opened, the editor should automatically detect run configurations in the +workspace. Use the drop down to choose which one to run and then click the `Run` +button to start it. When executing a debug target, make sure to click the +`Debug` icon to automatically attach the debugger (if you click `Run`, Node will +wait forever for a debugger to attach). + +![Intellij IDEA run configurations](images/run-configurations.png) + +### VS Code + +In order to debug some Angular CLI behaviour using Visual Studio Code, you can run `npm run build`, and then use a launch configuration like the following: + +```json +{ + "type": "node", + "request": "launch", + "name": "ng serve", + "cwd": "", + "program": "${workspaceFolder}/dist/@angular/cli/bin/ng", + "args": [ + "", + ...other arguments + ], + "console": "integratedTerminal" +} +``` + +Then you can add breakpoints in `dist/@angular` files. + +For more information about Node.js debugging in VS Code, see the related [VS Code Documentation](https://code.visualstudio.com/docs/nodejs/nodejs-debugging). + +## CPU Profiling + +In order to investigate performance issues, CPU profiling is often useful. + +### Creating a profile + +Node.js 16+ users can use the Node.js command line argument `--cpu-prof` to create a CPU profile. + +```bash +node --cpu-prof node_modules/.bin/ng build +``` + +In addition to this one, another, more elaborated way to capture a CPU profile using the Chrome Devtools is detailed in https://github.com/angular/angular-cli/issues/8259#issue-269908550. + +#### Opening a profile + +You can use the Chrome Devtools to process it. To do so: + +1. open `chrome://inspect` in Chrome +1. click on "Open dedicated DevTools for Node" +1. go to the "profiler" tab +1. click on the "Load" button and select the generated `.cpuprofile` file +1. on the left panel, select the associated file + +## Creating New Packages + +Adding a package to this repository means running two separate commands: + +1. `schematics devkit:package PACKAGE_NAME`. This will update the `.monorepo.json` file, and create the + base files for the new package (package.json, src/index, etc). +1. `pnpm admin templates`. This will update the README and all other template files that might + have changed when adding a new package. + +For private packages, you will need to add a `"private": true` key to your package.json manually. +This will require re-running the template admin script. + +## Windows + +To contribute to Angular using a Windows machine, you need to use the [Windows Linux Subsystem](https://learn.microsoft.com/en-us/windows/wsl/about) (also known as WSL). +Installing WSL on your machine requires a few extra steps, but we believe it's generally useful for developing on Windows: + +1. Run `wsl --install` from Powershell (as administrator). +2. Restart your machine. +3. Enter the WSL environment by running: `wsl`. +4. Continue with the developer guide as if you were on a native Linux system. + +For a more detailed guide, refer to the official Microsoft documentation: [Installing WSL](https://learn.microsoft.com/en-us/windows/wsl/install). + +**Note:** Angular continues to support native Windows development via the `ng` CLI and rigorously tests on Windows for every code change. This recommendation specifically applies to contributing to the Angular codebase itself. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000000..1aa9fc8f9262 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# `/docs` Folder + +This folder is used for all documentation. It contains a number of subfolders with short +descriptions here. + +## `/docs/design` + +Design documents on GitHub, in Markdown format. The number of design documents available is limited. + +## `/docs/process` + +Description of various caretaker and workflow processes. + +## `/docs/specifications` + +Specifications for support of Angular CLI features. These are meant to be read directly on GitHub +by developers of libraries who want to integrate with the Angular CLI. diff --git a/docs/design/analytics.md b/docs/design/analytics.md new file mode 100644 index 000000000000..0eb3bad26d8f --- /dev/null +++ b/docs/design/analytics.md @@ -0,0 +1,101 @@ +# Usage Metrics Gathering + +This document list exactly what is gathered and how. + +Any change to analytics should most probably include a change to this document. + +## Dimensions and Metrics + +Google Analytics Custom Dimensions are used to track system values and flag values. These +dimensions are aggregated automatically on the backend. + +One dimension per flag, and although technically there can be an overlap between 2 commands, for +simplicity it should remain unique across all CLI commands. The dimension is the value of the +`x-user-analytics` field in the `schema.json` files. + +### Adding dimension or metic. +1. Create the dimension or metric in [Google Analytics](https://analytics.google.com/) first. These are not tracked if they aren't + defined in Google Analytics. +1. Use the ID of the dimension as the `x-user-analytics` value in the `schema.json` file. +1. New dimension and metrics PRs need to be approved by the tooling lead and require a new [Launch](http://go/launch). + +### Deleting a dimension or metic. +1. Archive the dimension and metric in [Google Analytics](https://analytics.google.com/). + + +**DO NOT ADD `x-user-analytics` FOR VALUES THAT ARE USER IDENTIFIABLE (PII), FOR EXAMPLE A +PROJECT NAME TO BUILD OR A MODULE NAME.** + +### Limits +| Item | Standard property limits | +|-------------------------------- |-------------------------- | +| Event-scoped custom dimensions | 50 | +| User-scoped custom dimensions | 25 | +| All custom metrics | 50 | + +### List of User Custom Dimensions + + +| Name | Parameter | Type | +|:---:|:---|:---| +| UserId | `up.ng_user_id` | `string` | +| OsArchitecture | `up.ng_os_architecture` | `string` | +| NodeVersion | `up.ng_node_version` | `string` | +| NodeMajorVersion | `upn.ng_node_major_version` | `number` | +| AngularCLIVersion | `up.ng_cli_version` | `string` | +| AngularCLIMajorVersion | `upn.ng_cli_major_version` | `number` | +| PackageManager | `up.ng_package_manager` | `string` | +| PackageManagerVersion | `up.ng_pkg_manager_version` | `string` | +| PackageManagerMajorVersion | `upn.ng_pkg_manager_major_v` | `number` | + + +### List of Event Custom Dimensions + + +| Name | Parameter | Type | +|:---:|:---|:---| +| Command | `ep.ng_command` | `string` | +| SchematicCollectionName | `ep.ng_schematic_collection_name` | `string` | +| SchematicName | `ep.ng_schematic_name` | `string` | +| Standalone | `ep.ng_standalone` | `string` | +| SSR | `ep.ng_ssr` | `string` | +| Style | `ep.ng_style` | `string` | +| Routing | `ep.ng_routing` | `string` | +| InlineTemplate | `ep.ng_inline_template` | `string` | +| InlineStyle | `ep.ng_inline_style` | `string` | +| BuilderTarget | `ep.ng_builder_target` | `string` | +| Aot | `ep.ng_aot` | `string` | +| Optimization | `ep.ng_optimization` | `string` | + + +### List of Event Custom Metrics + + +| Name | Parameter | Type | +|:---:|:---|:---| +| AllChunksCount | `epn.ng_all_chunks_count` | `number` | +| LazyChunksCount | `epn.ng_lazy_chunks_count` | `number` | +| InitialChunksCount | `epn.ng_initial_chunks_count` | `number` | +| ChangedChunksCount | `epn.ng_changed_chunks_count` | `number` | +| DurationInMs | `epn.ng_duration_ms` | `number` | +| CssSizeInBytes | `epn.ng_css_size_bytes` | `number` | +| JsSizeInBytes | `epn.ng_js_size_bytes` | `number` | +| NgComponentCount | `epn.ng_component_count` | `number` | +| AllProjectsCount | `epn.all_projects_count` | `number` | +| LibraryProjectsCount | `epn.libs_projects_count` | `number` | +| ApplicationProjectsCount | `epn.apps_projects_count` | `number` | + + +## Debugging + +Using `NG_DEBUG=1` will enable Google Analytics debug mode, To view the debug events, in Google Analytics go to `Configure > DebugView`. + +## Disabling Usage Analytics + +There are 2 ways of disabling usage analytics: + +1. using `ng analytics disable --global` (or changing the global configuration file yourself). This is the same + as answering "No" to the prompt. +1. There is an `NG_CLI_ANALYTICS` environment variable that overrides the global configuration. + That flag is a string that represents the User ID. If the string `"false"` is used it will + disable analytics for this run. diff --git a/docs/design/build-system-overview.dot b/docs/design/build-system-overview.dot new file mode 100644 index 000000000000..e792d7b42d81 --- /dev/null +++ b/docs/design/build-system-overview.dot @@ -0,0 +1,15 @@ +digraph G { + node [shape=rectangle]; + "assets array" -> "copy-webpack-plugin"; + "*.ts" -> "@ngtools/webpack" -> "@angular-devkit/build-optimizer"; + "*.js" -> "source-map-loader" -> "@angular-devkit/build-optimizer"; + "@angular-devkit/build-optimizer" -> "webpack module concatenation" -> "webpack bundling" -> "terser-webpack-plugin"; + "scripts array" -> "terser-webpack-plugin"; + "*.css" -> "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer"; + "*.scss\|sass" -> "sass-loader" -> "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer"; + "*.less" -> "less-loader" -> "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer"; + "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer" -> "raw-loader, ./optimize-css-webpack-plugin.ts" [label="component style?"]; + "raw-loader" -> "./optimize-css-webpack-plugin.ts" + "postcss-loader with postcss-import, ./postcss-cli-resources.ts, autoprefixer" -> "style-loader, ./raw-css-loader.ts, and mini-css-extract-plugin" [label="global style?"]; + "style-loader, ./raw-css-loader.ts, and mini-css-extract-plugin" -> "./optimize-css-webpack-plugin.ts" +} diff --git a/docs/design/build-system.md b/docs/design/build-system.md new file mode 100644 index 000000000000..d4d1d15b33bc --- /dev/null +++ b/docs/design/build-system.md @@ -0,0 +1,194 @@ +# Build System + +Angular CLI includes a first-party build system for Angular applications distributed as `@angular-devkit/build-angular`. +This build system is responsible for creating a standalone single-page application (SPA) from user source files and third-party dependencies. + +`@angular-devkit/build-angular` itself integrates with the rest of Angular CLI by being an [Architect builder](https://angular.dev/tools/cli/cli-builder). +This document describes a top level view of the functionality in `@angular-devkit/build-angular`, referred as just the "build system". +Deprecated or soon to be removed features are not described here. + +In broad strokes the main areas are: + +- loading and processing sources +- code splitting +- production optimizations +- post-processing steps + +Many tools are used in this process, and most of these steps happen within a [Webpack](https://webpack.js.org/) build. +We maintain a number of Webpack-centric plugins in this repository, some of these are public but most are private since they are very specific to our setup. + +## Overview diagram + +Below is a diagram of processing sources go through. +It's not strictly accurate because some options remove certain processing stages while adding others. +This diagram doesn't show these conditionals and only shows the possible processing steps. +Relative paths, such as `./raw-css-loader.ts`, refer to internal plugins, while other names usually refer to public npm packages. + +![Overview diagram](https://g.gravizo.com/source/svg?https://raw.githubusercontent.com/angular/angular-cli/main/docs/design/build-system-overview.dot) + +## Loading and processing sources + +Sources for Angular CLI browser apps are comprised of TypeScript files, style sheets, assets, scripts, and third party dependencies. +A given build will load these sources from disk, process them, and bundle them together. + +### TypeScript and Ahead-Of-Time Compilation + +Angular builds rely heavily on TypeScript-specific functionality for [Ahead-of-Time template compilation](https://angular.dev/tools/cli/aot-compiler) (AOT). +Outside Angular CLI, this is performed by the Angular Compiler (`ngc`), provided by `@angular/compiler-cli`. +To avail of Ahead-of-Time template compilation within a Webpack compilation we use and distribute the `@ngtools/webpack` Webpack plugin. + +Typescript sources are loaded from disk and compiled in-memory into JavaScript files that are stored in a virtual file system and made available to Webpack. +During compilation we also perform a number of code transformations using TypeScript transformers that enable automatic usage of AOT, internationalization features, and server-side rendering. + +AOT compilation requires loading HTML and CSS resources, referenced on Angular Components, as standalone strings with no external dependencies. +However, Webpack compilations operate on the basis of modules and references between them. +It's not possible to get the full compilation result for a given resource before the end of the compilation in webpack. +To obtain the standalone string for a HTML/CSS resource we compile it using a separate Webpack child compilation then extract the results. +These child compilations inherit configuration and access to the same files as the parent compilation, but have their own compilation life cycle and complete independently. + +The build system allows specifying replacements for specific files by replacing what path is loaded from the virtual file system. +This is used for conditional loading of code at build time. + +### Stylesheets + +Two types of stylesheets are used in the build system: global stylesheets and component stylesheets. +Global stylesheets are injected into the `index.html` file, while component stylesheets are loaded directly into compiled Angular components. + +The build system supports plain CSS stylesheets as well as the Sass and LESS CSS pre-processors. +Stylesheet processing functionality is provided by `sass-loader`, `less-loader`, `postcss-loader`, `postcss-import`, augmented in the build system by custom webpack plugins. + +### Assets + +Assets in the build system refer specifically to a list of files or directories that are meant to be copied verbatim as build artifacts. +These files are not processed and commonly include images, favicons, pdfs and other generic file types. +They are loaded into the compilation using `copy-webpack-plugin`. + +### Scripts + +Scripts in the build system refer specifically to JavaScript files that are meant to be loaded directly on `index.html` without being processed. +They are loaded into the compilation using a custom webpack plugin. + +### Third party dependencies + +Third party dependencies are mostly inside `node_modules` and are referenced via imports in source files. +Stylesheet third party dependencies are treated mostly the same as sources. + +JavaScript third party dependencies suffer a more involved process. +They are first resolved to a folder in `node_modules` via [Node Module Resolution](https://nodejs.org/api/modules.html#modules_modules). +A given module might have several different entry points, for instance one for use in NodeJS and another one for using in the browser. +Although `package.json` only officially supports listing one entry point under the `main` key, it's common for npm packages to list [other entry points](https://2ality.com/2017/04/setting-up-multi-platform-packages.html). +Each entry point is listed in under a key in that module's `package.json` whose value is a string containing a relative file path. +We use `es2015 > browser > module > main` a priority list of keys, where the first key matched name determines which entry point to use. +For instance, for a module that has both `browser` and `main` entry points, we pick `browser`. + +Once the actual JavaScript file is determined, it is loaded into the compilation together with it's source map. + +This resolution strategy supports the [Angular Package Format](https://docs.google.com/document/d/1CZC2rcpxffTDfRDs6p1cfbmKNLA6x5O-NtkJglDaBVs/edit#heading=h.k0mh3o8u5hx). + +## Code splitting + +Code is automatically split into different files (or chunks, for js files) based on a few different triggers. + +The main TypeScript entry point and it's dependencies are bundled into the `main` chunk. +Global styles and scripts get one file per entry, named after themselves. + +JavaScript code imported only via dynamic imports is automatically split into a separate chunk that is loaded asynchronously named after the file containing the dynamic import. +If multiple asynchronous chunks contain a reference to the same module, it is placed in a new asynchronously loaded chunk named after the other chunks that use it. + +There is also a special chunk called `runtime` that contains the module loading logic and is loaded before the others. + +## Optimizations + +The build system contains optimizations aimed at improving the performance (for development builds) or the size of artifacts (for production builds). +These are often mutually exclusive and thus we cannot just default to always using them. + +### Development optimizations + +Development optimizations focus on reducing rebuild time on watched builds. +Although faster is always better, our threshold is to keep rebuilds even for large projects below 2 seconds. + +Computation needed to bundle code grows with its total size because of the cost of string concatenation and source map operations. +Third party dependencies that are initially loaded are split into a synchronously loaded chunk called `vendor`. +Splitting the infrequently changed vendor code from the frequently changed source code thus helps make rebuilds faster. + +When processing stylesheets, Webpack stores the intermediate modules as JavaScript code. +The JavaScript wrapper code makes stylesheets larger and `mini-css-extract-plugin` must be used to obtain the actually stylesheet content into a CSS file. +In development however, we skip the CSS extraction and leave it as JavaScript code for faster rebuild times. + +Watched builds split the processing load of TypeScript compilation between file emission on the main process and type checking on a forked process. +Large projects can also opt-out of AOT compilation for faster rebuilds. + +### Production optimizations + +Angular CLI focuses on enabling tree-shaking (removing unused modules) and dead code elimination (removing unused module code). +These two categories have high potential for size reduction because of network effects: removing code can lead to more code being removed. + +The main tool we use to achieve this goal are the dead code elimination capabilities of [Terser](https://github.com/terser/terser). +We also use Terser's mangling, by which names, but not properties, are renamed to shorter forms. +The main characteristics of Terser to keep in mind is that it operates via static analysis and does not support the indirection introduced by module loading. +Thus the rest of the pipeline is directed towards providing Terser with code that can be removed via static analysis in large single modules scopes. + +To this end we developed [@angular-devkit/build-optimizer](https://github.com/angular/angular-cli/tree/main/packages/angular_devkit/build_optimizer), a post-processing tool for TS code. +Build Optimizer searches for code patterns produced by the TypeScript and Angular compiler that are known to inhibit dead code elimination, and converts them into equivalent structures that enable it instead (the link above contains some examples). +It also adds Terser [annotations](https://github.com/terser/terser#annotations) marking top-level functions as free from side effects for libraries that have the `sideEffects` flag set to false in `package.json`. + +Webpack itself also contains two major features that enable tree-shaking and dead code elimination: [`sideEffects` flag](https://github.com/webpack/webpack/tree/master/examples/side-effects) support and [module concatenation](https://webpack.js.org/plugins/module-concatenation-plugin/). +Having the `sideEffects` flag set to false in `package.json` of a library means that library has no top-level side-effects and only exposes imports, which allows Webpack to rewrite imports to that library directly to the modules used and not including non-imported modules at all. +Module concatenation allows Webpack to collect in a single module the content of several modules, which in turn allows Terser to more easily remove unused code since there is no module loading indirection between those modules. + +One significant pitfall of this optimization strategy is the use of code splitting. +Using code splitting is desirable in order to speed up loading of web apps by deferring code that is not necessary on the initial load. +But since code splitting necessarily makes use of module loading, it is at odds with Terser-based optimizations. + +The use of lazy loading can not only prevent further optimizations, but also regress the currently possible ones by [preventing module concatenation](https://webpack.js.org/plugins/module-concatenation-plugin/#optimization-bailouts). +Modules that were concatenated when lazy modules are not present might not be concatenated anymore after lazy loading is introduced because these modules now need to be accessed from the lazy modules and thus get their own module scope. + +Aside from tree-shaking, scripts and styles (as defined in the sources above) also undergo optimizations via [Terser](https://github.com/terser/terser) and [CleanCSS](https://github.com/jakubpawlowicz/clean-css) respectively. + +## Post-processing steps + +There are some steps that are meant to operate over whole applications and thus happen after the compilation finishes and outputs files. +The steps are described in the order in which they are executed during a build. +The execution order was determined based on the complexity of the step with a primary goal of minimizing the repetition of computationally expensive operations. + +### Differential Loading + +Differential loading is a strategy that allows your web application to support multiple browsers, but only load the necessary code that the browser needs. +When differential loading is enabled, the CLI generates two distinct variants of application bundles. + +- The first contains ES2015 syntax, takes advantage of built-in support in modern browsers, ships fewer polyfills, and results in a smaller total size. +- The second contains code in the older ES5 syntax, along with all necessary polyfills for Angular to function. This results in a larger total size, but supports older browsers. + +This process as designed has the advantage that only one full compilation of the application in ES2015 syntax is required. +This removes a large amount of otherwise unnecessary and duplicate processing such as module resolution and dead code elimination. +It also guarantees that the application is ES5 compliant including third-party code. +The two variants of application bundles are created by the following steps: + +1. A full build of the application is performed using an ES2015 output target. + The application's global stylesheets, scripts, and assets are also processed during this step via the full build. These elements are reused for both of application variants. +2. A copy of the JavaScript output application files are transformed (commonly referred to as down-leveled) to ES5 compatible syntax. + ES2015+ syntax elements such as classes are converted into functionally equivalent ES5 code structures. +3. An additional ES5-only polyfills file is generated that contains the required Angular polyfills for ES5-only browsers. +4. A single index HTML file is created that references both application variants and is designed to only load the appropriate files for each browser. + +To support loading the file sets in the appropriate browsers, the HTML `script` element's `type` and `nomodule` attributes are leveraged. +Browsers will only load a script with a known type. +The ES2015 files are referenced using a type of `module` which is only supported on browsers that support ES2015+ code. +Since browsers that do not support ES2015+ code also do not support the `module` script type, these scripts are ignored for browsers that cannot parse and execute the ES2015 code. +Browsers that support the `module` script type also support the `nomodule` attribute. +This attribute instructs a browser that supports module scripts to ignore the script with the attribute. There is one browser exception in this case: Safari 10.1. +This browser supports module scripts but does not support the `nomodule` attribute. +To support this case, a special polyfill script is included to provide a workaround for the browser. +This arrangement of script elements ensures that ES5-only browsers will only execute the ES5 script files and browsers that support ES2015+ will only execute the ES2015 script files. + +### Localization (i18n) + +The second of these post-processing steps is build-time localization. +The final js bundles are processed using `@angular/localize`, replacing any locale-specific translations. +This sort of localization produces one application for each locale, each in their own folders. + +### Service Worker + +The third and last post-processing step is the creation of a [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API). +A listing of final application files is taken, fingerprinted according to their content, and added to the service worker manifest. +This must be the last step because it needs each application file to not be modified further. diff --git a/docs/design/deployurl-basehref.md b/docs/design/deployurl-basehref.md new file mode 100644 index 000000000000..71a804c073e4 --- /dev/null +++ b/docs/design/deployurl-basehref.md @@ -0,0 +1,18 @@ +| | Deploy URL | Base HREF | +| ------------------------------------------- | :--------: | :-------: | +| Initial scripts (index.html) | ✅ 👍 | ✅ 👍 | +| Initial stylesheets (index.html) | ✅ 👍 | ✅ 👍 | +| Lazy scripts (routes/import()/etc.) | ✅ 👍 | ✅ 👍 | +| Processed CSS resources (images/fonts/etc.) | ✅ 👍 | ✅ 👍 | +| Relative template (HTML) assets | ❌ 👎 | ✅ 👍 | +| Angular Router Default Base (APP_BASE_HREF) | ❌ | ✅ \*1 | +| Single reference in deployed Application | ❌ 👎 | ✅ 👍 | +| Special resource logic within CLI | ✅ 👎 | ❌ 👍 | +| Relative fetch/XMLHttpRequest | ❌ | ✅ | + +✅ - has/affects the item/trait +❌ - does not have/affect the item/trait +👍 - favorable behavior +👎 - unfavorable behavior + +\*1 -- Users with more complicated setups may need to manually configure the `APP_BASE_HREF` token within the application. (e.g., application routing base is `/` but assets/scripts/etc. are at `/assets/`) diff --git a/docs/design/docker-deploy.md b/docs/design/docker-deploy.md deleted file mode 100644 index 634fdc8b3e21..000000000000 --- a/docs/design/docker-deploy.md +++ /dev/null @@ -1,454 +0,0 @@ -# Docker Deploy - Design - -## Abstract - -Add convenience for users to containerize and deploy their Angular application via Docker. - -Provide tasks for common Docker workflows: - -* Generate starter `Dockerfile` and `docker-compose.yml` files. -* Build and Push an Angular app image to a Docker Registry. -* Deploy and run an Angular app container in different Docker Machine environments. - - -## Requirements - -1. Requires user to have Docker CLI tools installed. - (See also: ["Implementation Approaches"](#implementation-approaches)) -1. User is free to use the Angular CLI without Docker (and vice versa). By default, do not generate Docker files upon creation of a new project (`ng new`). -1. Don't recreate the wheel. Docker CLI tools are very full featured on their own. Implement the common Docker use cases that make it convenient for Angular applications. -1. Don't inhibit users from using the standalone Docker CLI tools for other use cases. -1. Assumes 1:1 Dockerfile with the Angular project. Support for multiple services under the same project is outside the scope of this initial design. -1. Generated starter Dockerfile will use an Nginx base image for the default server. The built ng app and Nginx configuration for HTML5 Fallback will be copied into the image. -1. User is free to modify and customize all generated files directly without involvement by the Angular CLI. -1. Image builds will support all Docker build options. -1. Container deploys will support all Docker run options. -1. Deploying to a Docker Machine can be local or remote. -1. Deploys can be made to different environments (dev, stage, prod) on the same or different Docker Machines. -1. Image pushes can be made to Docker Hub, AWS ECR, and other public/private registries. -1. Adhere to [Docker security best practices](https://docs.docker.com/engine/security/). -1. Use sensible defaults to make it easy for users to get started. -1. Support `--dry-run` and `--verbose` flags. - - -## Proposed CLI API - -### Overview - -Initialize the project for Docker builds and deploys: -``` -$ ng docker init [--environment env] -``` - -Build and push a Docker image of the Angular app to the registry: -``` -$ ng docker push [--registry url] -``` - -Deploy and run the Angular app on a Docker Machine: -``` -$ ng docker deploy [--environment env] -``` - -### Command - Docker Init - -The command `ng docker init` generates starter `Dockerfile`, `.dockerignore`, and `docker-compose.yml` files for builds and and deploys. - -Most users will start with one local 'default' Docker Machine (Mac/Win), or a local native Docker environment on Linux, where they will perform builds and run containers for development testing. Without additional arguments, this command prepares the Angular project for working with that default environment. - -**Arguments:** - -* `--environment {env}` ; initialize for a particular environment (ie. dev, stage, prod). Defaults to `"default"`. -* `--machine {machineName}` ; choose a particular Docker Machine for this environment. Defaults to the environment name. -* `--service-name {serviceName}` ; the name for the webservice that serves the angular application. Defaults to `project.name` -* `--service-port {servicePort}` ; the service port that should be mapped on the host. Defaults to `8000`. -* `--use-image` ; initializes the environment for deploying with an image, rather than performing a build. By default, this is `false` and the `docker-compose.yml` file will be initialized for builds. -* `--image-org {orgName}` ; the org name to use for image pushes. Defaults to `null`. -* `--image-name {imageName}` ; the image name to use for image pushes. Also applies when `--use-image` is `true`. Defaults to `serviceName`. -* `--image-registry {address}` ; the registry address to use for image pushes. Defaults to `registry.hub.docker.com`. Also applies when `--use-image` is `true`. - -**Example - Init default environment:** - -```bash -$ ng docker:init --image-org my-username - -Generated 'Dockerfile' -Generated '.dockerignore' -Generated 'docker-compose.yml' - -Docker is ready! - -You can build and push a Docker image of your application to a docker registry using: - $ ng docker push - -Build and run your application using: - $ ng docker deploy -``` - -**Other requirements:** - -If the Docker CLI tools are not found, display an error with instructions on how to install Docker Toolbox. If no Docker Machines are found, display an error with instructions for creating a machine. - -The command should verify that the `machineName` is valid, and be able to distinguish whether it is a remote machine or a native local Docker environment (ie. Linux, Docker Native beta). - -A notice will be displayed for any files that already exist. No existing files will be overwritten or modified. Users are free to edit and maintain the generated files. - -If an `env` name is provided, other than "default", generate compose files with the env name appended, ie. `docker-compose-${env}.yml`. - -If `--use-image == false` and the selected machine for the environment is a Docker Swarm machine, warn the user. Docker Swarm clusters cannot use the `build:` option in compose, since the resulting built image will not be distributed to other nodes. Swarm requires using the `image:` option in compose, pushing the image to a registry beforehand so that the Swarm nodes have a place to pull the image from (see [Swarm Limitations](https://docs.docker.com/compose/swarm/#building-images)). - -Certain configuration values will be stored in the project's ngConfig `.angular-cli.json` for use with other docker commands (ie. deploy, logs, exec). See also: [Saved State](#saved-state) section. - -Provide instructions on what the user can do after initialization completes (push, deploy). - -Users who do not wish to push images to a registry should not be forced to. - -#### Example - `Dockerfile` blueprint - -```Dockerfile -# You are free to change the contents of this file -FROM nginx - -# Configure for angular fallback routes -COPY nginx.conf /etc/nginx/nginx.conf - -# Copy built app to wwwroot -COPY dist /usr/share/nginx/html -``` - -#### Example - `.dockerignore` blueprint - -``` -.git -.gitignore -.env* -node_modules -docker-compose*.yml -``` - -#### Example - `docker-compose.yml` blueprint - -For default case, when `--use-image == false`: - -```yaml -version: "2" -services: - ${serviceName}: - build: . - image: ${imageName} - ports: - - "${servicePort}:80" -``` - -When `--use-image == true`: - -```yaml -version: "2" -services: - ${serviceName}: - image: \${NG_APP_IMAGE} - ports: - - "${servicePort}:80" -``` - -The `\${NG_APP_IMAGE}` is a Compose variable, not an Angilar CLI var. It will be substituted during a `docker deploy` command with an environment variable of the desired tag. (See [Compose Variable Substitution](https://docs.docker.com/compose/compose-file/#variable-substitution) for more info) - -Separate `docker-compose-{environment}.yml` files are used to deploy to different [environments](https://docs.docker.com/compose/extends/#example-use-case), for use with the `ng docker deploy` command. - - -### Command - Docker Push - -The command `ng docker push` builds a Docker image of the Angular app from the `Dockerfile`, and pushes the image with a new tag to a registry address. - -Example - Build and push (with auth): -```bash -$ ng docker push --tag 1.0.0 --tag-latest -Building image... -Tagging "1.0.0"... -Tagging "latest" -Docker image built! bc2043fdd1e8 - -Pushing to registry... -> Enter your registry credentials -Username (username): -Password: - -Push complete! -my-username/my-ng-app:1.0.0 -my-username/my-ng-app:latest -``` - -#### Arguments - -* `--machine {machineName}` ; the Docker Machine to use for the build. Defaults to the "default" environment's `machineName`. -* `--image-org {orgName}` ; the org name to use for image pushes. Defaults to `null`. -* `--image-name {imageName}` ; the image name to use for image pushes. Defaults to `serviceName`. -* `--image-registry {address}` ; the registry address to use for image pushes. Defaults to `registry.hub.docker.com`. -* `--tag {tag}` ; the tag for the newly built image. Defaults to the current `package.json` "version" property value. -* `--tag-latest` ; optionally and additionally apply the "latest" tag. Defaults to `false`. -* `--no-cache` ; do not use cache when building the image. Defaults to `false`. -* `--force-rm` ; always remove intermediate containers. Defaults to `false`. -* `--pull` ; always attempt to pull a newer version of the image. Defaults to `false`. - -The `--no-cache`, `--force-rm`, and `--pull` are [compose build options](https://docs.docker.com/compose/reference/build/). - -Try an initial push. If an authentication failure occurs, attempt to login via `docker login`, or with `aws ecr get-login` (if the registry address matches `/\.dkr\.ecr\./`). Proxy the CLI input to the login commands. Avoid storing any authentication values in ngConfig. - -The `serviceName`, `registryAddress`, `orgName`, and `imageName` defaults will first be retrieved from ngConfig, which were saved during the initial `ng docker init` command. If any of these values do not exist, warn the user with instructions to add via `ng docker init` or `ng set name=value`. - -#### Internal Steps - -1. `docker-machine env {machineName}` (if remote) -1. Rebuild app for production -1. `docker-compose build {serviceName}` -1. `docker tag {imageName} {registryAddress}:{orgName}/{imageName}:{imageTag}` -1. `docker push {registryAddress}:{orgName}/{imageName}:{imageTag}` -1. `tag-latest == true` ? - 1. `docker tag {imageName} {registryAddress}:{orgName}/{imageName}:latest` - 1. `docker push {registryAddress}:{orgName}/{imageName}:latest` - - -### Command - Docker Deploy - -The command `ng docker deploy` will deploy an environment's compose configuration to a Docker Machine. It starts the containers in the background and leaves them running. - -Consider a command alias: `ng docker run`. - -Example - Default environment deploy: -```bash -$ ng docker deploy -Building... -Deploying to default environment... -Deploy complete! -``` - -Example - Deploying to a named environment, without builds: -```bash -$ ng docker:deploy --environment stage -$ ng docker:deploy --environment prod --tag 1.0.1 -``` - -Example - Deploying a specific service from the compose file: -```bash -$ ng docker deploy --services my-ng-app -``` - -#### Options - -* `--environment {env}` ; -* `--tag {tag}` ; The tag to use whe deploying to non-build Docker Machine environments; Defaults to `package.json` "version" property value. -* `--services {svc1} {svc2} ... {svcN}` ; -* `--no-cache` ; do not use cache when building the image. Defaults to `false`. -* `--force-rm` ; always remove intermediate containers. Defaults to `false`. -* `--pull` ; always attempt to pull a newer version of the image. Defaults to `false`. -* `--force-recreate` ; recreate containers even if their configuration and image haven't changed. Defaults to `false`. -* `--no-recreate` ; if containers already exist, don't recreate them. Defaults to `false`. - -The `--services` option allows for specific services to be deployed. By default, all services within the corresponding compose file will be deployed. - -The `--no-cache`, `--force-rm`, and `--pull` are [compose build options](https://docs.docker.com/compose/reference/build/). - -The `--force-recreate`, `--no-recreate` are [compose up options](https://docs.docker.com/compose/reference/up/). - -Successive deploys should only restart the updated services and not affect other existing running services. - -> If there are existing containers for a service, and the service’s configuration or image was changed after the container’s creation, docker-compose up picks up the changes by stopping and recreating the containers (preserving mounted volumes). To prevent Compose from picking up changes, use the --no-recreate flag. - -Use the `tag` value for the `${NG_APP_IMAGE_TAG}` environment variable substitution in the compose file. - -Use the `env` value to namespace the `--project-name` of the container set, for use with docker-compose when using different environment deploys on the same Docker Machine. (See also: [Compose overview](https://docs.docker.com/compose/reference/overview/)) - -#### Internal Steps - -1. `docker-machine env {machineName}` (if remote) -1. `--use-image == true` ? - 1. `export NG_APP_IMAGE={registryAddress}:{orgName}/{imageName}:{tag}` - 1. `docker-compose up -d [services]` -1. `--use-image == false` ? - 1. Rebuild app for production - 1. `docker-compose build {serviceName}` - 1. `docker-compose up -d [services]` - - -## Saved State - -Example ngConfig model for saved docker command state (per project): -``` -{ - docker: { - orgName: 'myusername', - imageName: 'ngapp', - registryAddress: 'registry.hub.docker.com', - environments: { - default: { - machineName: 'default', - isImageDeploy: false, - serviceName: 'ngapp' - }, - stage: { - machineName: 'stage', - isImageDeploy: true, - serviceName: 'ngapp' - } - } - } -} -``` - - -## Other Enhancements - -* Ability to list the configured deploy environments. -* Autocomplete environment names for `ng docker deploy`. -* New command wrappers for `docker-compose logs`, and `docker exec` commands. -* Create an Angular development environment image with everything packaged inside. Users can run the container with local volume mounts, and toolset will watch/rebuild for local file changes. Only requires Docker to be installed to get started with Angular development. There are some Node.js complexities to solve when adding new package.json dependencies, and many users report issues with file watchers and docker volume mounts. -* Deployment support to other container scheduling systems: Kubernetes, Marathon/Mesos, AWS ECS and Beanstalk, Azure Service Fabric, etc. - - -## Appendix - -### Implementation Approaches - -Two internal implementation approaches to consider when interfacing with Docker from Node.js: - -1. Docker Remote API via Node.js modules -1. Docker CLI tools via `child_process.exec` - -#### Docker Remote API - - - -> The API tends to be REST. However, for some complex commands, like attach or pull, the HTTP connection is hijacked to transport stdout, stdin and stderr. - -The API has been going through a high rate of change and has some awkward inconsistencies: - -> Build Image: reply is chunked into JSON objects like {"stream":"data"} and {"error":"problem"} instead of multiplexing stdout and stderr like the rest of the API. - -Example Image Build with `dockerode` module: - -```js -var Docker = require('dockerode'); -var tar = require('tar-fs'); - -var docker = new Docker({ - host: options.dockerHostIp, - port: options.dockerPort || 2375, - ca: fs.readFileSync(`${options.dockerCertPath}/ca.pem`), - cert: fs.readFileSync(`${options.dockerCertPath}/cert.pem`), - key: fs.readFileSync(`${options.dockerCertPath}/key.pem`) -}); -var buildImagePromise = Promise.denodeify(docker.buildImage); -var tarStream = tar.pack(project.root); - -buildImagePromise(tarStream, { - t: options.imageName -}).then((output) => { - var imageHash = output.match(/Successfully built\s+([a-f0-9]+)/m)[1]; - ui.writeLine(chalk.green(`Docker image built! ${imageHash}`)); -}); -``` - -Tradeoffs with this approach: - -* Does not require Docker CLI tools to be installed. -* Requires cert files for access to remote Docker Machines. -* Programmatic interface. -* Requires more configuration on the part of Angular CLI. -* Configuration imposes a learning curve on existing Docker users. -* No Docker-Compose API support. Multi-container management features would need to be duplicated. -* Maintenance effort to keep API updated. -* Dependent upon 3rd-party Docker module support, or creating our own. - -#### Execute Docker CLI Commands - -This method wraps the following Docker CLI tools with `exec()` calls, formatting the command arguments and parsing their output: - -* `docker-machine` : Used to initialize the `docker` client context for communication with a specific Docker Machine host, and for gathering host information (IP address). -* `docker` : The primary Docker client CLI. Good for pushing images to a registry. -* `docker-compose` : Manages multi-container deployments on a Docker Machine using a YAML format for defining container options. Can also be used to build images. - -Example image build with `docker-machine` and `docker-compose` CLI commands: - -```js -var execPromise = Promise.denodeify(require('child_process').exec); - -function getDockerEnv(machineName) { - return execPromise(`docker-machine env ${machineName}`) - .then((stdout) => { - let dockerEnv = {}; - stdout.split(/\r?\n/) - .forEach((line) => { - let m = line.match(/^export\s+(.+?)="(.*?)"$/); - if (m) dockerEnv[m[1]] = m[2]; - }); - return dockerEnv; - }); -} - -function buildImage() { - return getDockerEnv(options.buildMachineName) - .then((dockerEnv) => { - let execOptions = { - cwd: project.root, - env: dockerEnv - }; - return execPromise(`docker-compose build ${options.dockerServiceName}`, execOptions); - }) - .then((stdout) => { - var imageHash = stdout.match(/Successfully built\s+([a-f0-9]+)/m)[1]; - ui.writeLine(chalk.green(`Docker image built! ${imageHash}`)); - return imageHash - }); -} -``` - -Tradeoffs with this approach: - -* Requires user to manually install Docker CLI tools -* Must build interface around CLI commands, formatting the command arguments and parsing the output. -* Can leverage Docker-Compose and its configuration format for multi-container deploys. -* Configuration of build and deploy options is simplified in Angular CLI. -* User has the flexibility of switching between `ng` commands and Docker CLI tools without having to maintain duplicate configuration. -* Lower risk of Docker compatibility issues. User has control over their Docker version. - -##### Node.js Docker Modules - -Module | Created | Status | Dependencies ---- | --- | --- | --- -[dockerode](https://www.npmjs.com/package/dockerode) | Sep 1, 2013 | Active | 14 -[dockerizer](https://www.npmjs.com/package/dockerizer) | Feb 1, 2016 | New | 125 -[docker.io](https://www.npmjs.com/package/docker.io) | Jun 1, 2013 | Outdated | ? - -[List of Docker client libraries](https://docs.docker.com/engine/reference/api/remote_api_client_libraries/) - -#### Summary - -The "2. Docker CLI tools via `child_process.exec`" method is recommended based on the following: - -* The requirement of having the Docker CLI tools installed is not generally a problem, and they would likely already be installed by the majority of users using these features. -* Maintenance to Angular CLI would likely be easier using the Docker CLI, having less configuration, documentation, and updates than the Remote API method. -* Multi-container deploys is a common use-case. Utilizing the Docker Compose features, format, and documentation is a big win. -* Since this project is a CLI itself, using the Docker CLI tools isn't too far a leap. -* Users who do not use these features are not forced to install Docker CLI. Conversely, the Remote API method might incur a small penalty of installing unused NPM modules (ie. `dockerode`). - - -### Container Deployment APIs in the Wild - -* [Docker Run](https://docs.docker.com/engine/reference/run/) -* [Docker-Compose File](https://docs.docker.com/compose/compose-file/) -* [Kubernetes Pod](http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_pod) -* [Marathon App](https://mesosphere.github.io/marathon/docs/rest-api.html#post-v2-apps) -* [Tutum Container](https://docs.tutum.co/v2/api/#container) -* [AWS Elastic Beanstalk/ECS Task Definition](http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_defintions.html) -* [Azure Service Fabric App](https://azure.microsoft.com/en-us/documentation/articles/service-fabric-deploy-remove-applications/) -* [Heroku Docker CLI](https://github.com/heroku/heroku-docker) -* [Redspread](https://github.com/redspread/spread) -* [Docker Universal Control Plane](https://www.docker.com/products/docker-universal-control-plane) -* [Puppet Docker Module](https://github.com/garethr/garethr-docker) -* [Chef Docker Cookbook](https://supermarket.chef.io/cookbooks/docker) -* [Ansible Docker Module](http://docs.ansible.com/ansible/docker_module.html) -* [Bamboo Docker Tasks](https://confluence.atlassian.com/bamboo/configuring-the-docker-task-in-bamboo-720411254.html) -* [Freight Forwarder Manifest](http://freight-forwarder.readthedocs.org/en/latest/config/overview.html) -* [Gulp Docker Tasks](https://www.npmjs.com/package/gulp-docker) -* [Grunt Dock Tasks](https://www.npmjs.com/package/grunt-dock) -* [Robo Docker Tasks](http://robo.li/tasks/Docker/) diff --git a/docs/design/ngConfig.md b/docs/design/ngConfig.md index e37f1cf0e689..df40002ac3d5 100644 --- a/docs/design/ngConfig.md +++ b/docs/design/ngConfig.md @@ -2,13 +2,13 @@ ## Goals -Currently, a project scaffolded with the CLI have no way of specifying options and configurations affecting their projects. There are ways to affect the build (with the `angular-cli-build.js` file), but the following questions cannot be answered without actual project options: +Currently, a project scaffolded with the CLI has no way of specifying options and configurations affecting their projects. There are ways to affect the build (with the `angular-cli-build.js` file), but the following questions cannot be answered without actual project options: -* Where in my directory is my karma.conf file? -* What is my firebase database URL? -* Where is my client code? -* How can I use a different lazy-loading boundary prefix (or none at all)? -* Any other backend I want to run prior to `ng serve`? +- Where in my directory is my karma.conf file? +- What is my firebase database URL? +- Where is my client code? +- How can I use a different lazy-loading boundary prefix (or none at all)? +- Any other backend I want to run prior to `ng serve`? # Proposed Solution @@ -18,16 +18,15 @@ One solution would be to keep the data in the `package.json`. Unfortunately, the Instead of polluting the package file, a `.angular-cli.json` file will be created that contains all the values. Access to that file will be allowed to the user if he knows the structure of the file (unknown keys will be kept but ignored), and it's easy to read and write. - ## Fallback -There should be two `.angular-cli.json` files; one for the project and a general one. The general one should contain information that can be useful when scaffolding new apps, or informations about the user. +There should be two `.angular-cli.json` files; one for the project and a general one. The general one should contain information that can be useful when scaffolding new apps, or information about the user. The project `.angular-cli.json` goes into the project root. The global configuration should live at `$HOME/.angular-cli.json`. ## Structure -The structure should be defined by a JSON schema (see [here](http://json-schema.org/)). The schema will be used to generate the `d.ts`, but that file will be kept in the file system along the schema for IDEs. +The structure should be defined by a JSON schema (see [here](http://json-schema.org/)). The schema will be used to generate the `d.ts`, but that file will be kept in the file system along with the schema for IDEs. Every PR that would change the schema should include the update to the `d.ts`. @@ -39,7 +38,7 @@ Every PR that would change the schema should include the update to the `d.ts`. The new command `get` should be used to output values on the terminal. It takes a set of flags and an optional array of [paths](#path); -* `--glob` or `-g`; the path follows a glob format, where `*` can be replaced by any amount of characters and `?` by a single character. This will output `name=value` for each values matched. +- `--glob` or `-g`; the path follows a glob format, where `*` can be replaced by any amount of characters and `?` by a single character. This will output `name=value` for each values matched. Otherwise, outputs the value of the path passed in. If multiple paths are passed in, they follow the format of `name=value`. @@ -47,14 +46,14 @@ Otherwise, outputs the value of the path passed in. If multiple paths are passed The new command `set` should be used to set values in the local configuration file. It takes a set of flags and an optional array of `[path](#path)=value`; -* `--global`; sets the value in the global configuration. -* `--remove`; removes the key (no value should be passed in). +- `--global`; sets the value in the global configuration. +- `--remove`; removes the key (no value should be passed in). The schema needs to be taken into account when setting the value of the field; -* If the field is a number, the string received from the command line is parsed. `NaN` throws an error. -* If the field is an object, an error is thrown. -* If the path is inside an object but the object hasn't been defined yet, sets the object with empty values (use the schema to create a valid object). +- If the field is a number, the string received from the command line is parsed. `NaN` throws an error. +- If the field is an object, an error is thrown. +- If the path is inside an object but the object hasn't been defined yet, sets the object with empty values (use the schema to create a valid object). #### Path @@ -70,19 +69,23 @@ A model should be created that will include loading and saving the configuration **The model should be part of the project and created on the `project` object.** -That model can be used internally by the tool to get information. It will include a proxy handler that throws if an operation doesn't respect the schema. It will also sets values on globals and locals depending on which branches you access. +That model can be used internally by the tool to get information. It will include a proxy handler that throws if an operation doesn't respect the schema. It will also set values on globals and locals depending on which branches you access. A simple API would return the TypeScript interface: ```typescript class Config { // ... - get local(): ICliConfig { /* ... */ } - get global(): ICliConfig { /* ... */ } + get local(): ICliConfig { + /* ... */ + } + get global(): ICliConfig { + /* ... */ + } } ``` -The `local` and `global` getters return proxies that respect the JSON Schema defined for the Angular config. These proxies allow users to not worry about the existence of values; those values will only be created on disc when they are setted. +The `local` and `global` getters return proxies that respect the JSON Schema defined for the Angular config. These proxies allow users to not worry about the existence of values; those values will only be created on disc when they are set. Also, `local` will always defer to the same key-path in `global` if a value isn't available. If a value is set and the parent exists in `global`, it should be created to `local` such that it's saved locally to the project. The proxies only care about the end points of `local` and `global`, not the existence of a parent in either. @@ -115,14 +118,14 @@ The following stands true: ```typescript const config = new Config(/* ... */); -console.log(config.local.key1.key2.value); // 0, even if it doesn't exist. -console.log(config.local.key1.key2.value2); // 2, local overrides. -console.log(config.local.key1.key2.value3); // 3. -console.log(config.local.key1.key2.value4); // Schema's default value. +console.log(config.local.key1.key2.value); // 0, even if it doesn't exist. +console.log(config.local.key1.key2.value2); // 2, local overrides. +console.log(config.local.key1.key2.value3); // 3. +console.log(config.local.key1.key2.value4); // Schema's default value. -console.log(config.global.key1.key2.value); // 0. -console.log(config.global.key1.key2.value2); // 1, only global. -console.log(config.global.key1.key2.value3); // Schema's default value. +console.log(config.global.key1.key2.value); // 0. +console.log(config.global.key1.key2.value2); // 1, only global. +console.log(config.global.key1.key2.value3); // Schema's default value. config.local.key1.key2.value = 1; // Now the value is 1 inside the local. Global stays the same. @@ -132,7 +135,7 @@ config.local.key1.key2.value3 = 5; config.global.key1.key2.value4 = 99; // The local config stays the same. -console.log(config.local.key1.key2.value4); // 99, the global value. +console.log(config.local.key1.key2.value4); // 99, the global value. -config.save(); // Commits if there's a change to global and/or local. +config.save(); // Commits if there's a change to global and/or local. ``` diff --git a/docs/design/third-party-libraries.md b/docs/design/third-party-libraries.md index d07feeb8cd65..69ac6a7c8de5 100644 --- a/docs/design/third-party-libraries.md +++ b/docs/design/third-party-libraries.md @@ -10,18 +10,18 @@ with metadata that we expect. This document explores ways to improve on the proc The following use cases need to be supported by `ng install`: 1. Support for _any_ thirdparties, ultimately falling back to running `npm install` only and -doing nothing else, if necessary. + doing nothing else, if necessary. 1. Not a new package manager. 1. Metadata storage by thirdparties in `package.json`. This must be accessible by plugins for -the build process or even when scaffolding. + the build process or even when scaffolding. 1. Proper configuration of SystemJs in the browser. 1. SystemJS configuration managed by the user needs to be kept separated from the autogenerated -part. + part. 1. The build process right now is faulty because the `tmp/` directory used by Broccoli is -inside the project. TypeScript when using `moduleResolution: "node"` can resolve the -`node_modules` directory (since it's in an ancestor folder), which means that TypeScript -compiles properly, but the build does not copy files used by TypeScript to `dist/`. We need -to proper map the imports and move them to `tmp` before compiling, then to `dist/` properly. + inside the project. TypeScript when using `moduleResolution: "node"` can resolve the + `node_modules` directory (since it's in an ancestor folder), which means that TypeScript + compiles properly, but the build does not copy files used by TypeScript to `dist/`. We need + to proper map the imports and move them to `tmp` before compiling, then to `dist/` properly. 1. Potentially hook into scaffolding. 1. Potentially hook into the build. 1. Safe uninstall path. @@ -34,27 +34,32 @@ Here's a few stories that should work right off: $ ng new my-app && cd my-app/ $ ng install jquery ``` + ^(this makes jQuery available in the index) ```sh $ ng install angularfire2 > Please specify your Firebase database URL [my-app.firebaseio.com]: _ ``` + ^(makes firebase available and provided to the App) ```sh $ ng install angularfire2 --dbUrl=my-firebase-db.example.com ``` + ^(skip prompts for values passed on the command line) ```sh $ ng install angularfire2 --quiet ``` + ^(using `--quiet` to skip all prompts and use default values) ```sh $ ng install less ``` + ^(now compiles CSS files with less for the appropriate extensions) # Proposed Solution @@ -80,13 +85,16 @@ The `install` task will perform the following subtasks: These packages can be used to wrap libraries that we want to support but can't update easily, like Jasmine or LESS. + 1. **Install typings.** See the [Typings](#typings) section. 1. **Run `postinstall` scripts.** # Proof of Concept + A proof of concept is being developed. # Hooks + The third party library can implement hooks into the scaffolding, and the build system. The CLI's tasks will look for the proper hooks prior to running and execute them. @@ -96,12 +104,14 @@ checking for the `package['angular-cli']['hooks']['${hookName}']`. The hooks are tree, there is no guarantee for the order of execution. ## Install Hooks + The only tricky part here is install hooks. The installed package should recursively call its `(pre|post|)install` hooks only on packages that are newly installed. It should call `(pre|post|)reinstall` on those who were already installed. A map of the currently installed packages should be kept before performing `npm install`. # appData + The `angular-cli` key in the generated app should be used for Angular CLI specific data. This includes the CLI configuration itself, as well as third-parties library configuration. @@ -137,20 +147,21 @@ in the data without user interaction. The special strings `${...}` can be used t with special values in the default string values. The values are taken from the `package.json`. We use a declarative style to enforce sensible defaults and make sure developers think about -this. *In the case where developers want more complex interaction, they can use the -install/uninstall hooks to prompt users.* But 3rd party libraries developers need to be aware +this. _In the case where developers want more complex interaction, they can use the +install/uninstall hooks to prompt users._ But 3rd party libraries developers need to be aware that those hooks might not prompt users (no STDIN) and throw an error. # Providers + Adding Angular providers to the app should be seamless. The install process will create a -`providers.js` from all the providers contained in all the dependencies. The User can blacklist +`providers.js` from all the providers contained in all the dependencies. The User can disclude providers it doesn't want. The `providers.js` file will always be overwritten by the `install` / `uninstall` process. It needs to exist for toolings to be able to understand dependencies. These providers are global to the application. -In order to blacklist providers from being global, the user can use the `--no-global-providers` +In order to prevent providers from being global, the user can use the `--no-global-providers` flag during installation, or can change the dependencies by using `ng providers`. As an example: ```bash @@ -163,15 +174,18 @@ ng providers database angularfirebase2 Or, alternatively, the user can add its own providers and dependencies to its components. # Dependencies + Because dependencies are handled by `npm`, we don't have to handle it. # Typings + Typings should be added, but failure to find typings should not be a failure of installation. The user might still want to install custom typings himself in the worst case. The `typings` package can be used to install/verify that typings exist. If the typings do not exist natively, we should tell the user to install the ambient version if he wants to. # Index.html + We do not touch the `index.html` file during the installation task. The default page should link to a SystemJS configuration file that is auto generated by the CLI and not user configurable (see SystemJS below). If the user install a third party library, like jQuery, and @@ -181,6 +195,7 @@ The `index.html` also includes a section to configure SystemJS that can be manag This is separate from the generated code. # SystemJS + It is important that SystemJS works without any modifications by the user. It is also important to leave the liberty to the user to change the SystemJS configuration so that it fits their needs. @@ -190,6 +205,7 @@ being pulled from a CDN in production. During the `ng build` process for product SystemJS configuration script will be rebuilt to fetch from the CDN. # Upgrade Strategy + The upgrade process simply uses NPM. If new appData is added, it should be added manually using a migration hook for `postinstall`. diff --git a/docs/documentation/_Sidebar.md b/docs/documentation/_Sidebar.md deleted file mode 100644 index 0a2ada937078..000000000000 --- a/docs/documentation/_Sidebar.md +++ /dev/null @@ -1,3 +0,0 @@ -* [Angular CLI](home) -* [Generate](generate) -* [Stories](stories) diff --git a/docs/documentation/angular-cli.md b/docs/documentation/angular-cli.md deleted file mode 100644 index 4d836dfe714a..000000000000 --- a/docs/documentation/angular-cli.md +++ /dev/null @@ -1,102 +0,0 @@ - - -# Angular CLI Config Schema - -## Options - -- **project**: The global configuration of the project. - - *name* (`string`): The name of the project. - - *ejected*(`boolean`): Whether or not this project was ejected. Default is `false`. - - -- **apps** (`array`): Properties of the different applications in this project. - - *name* (`string`): Name of the app. - - *root* (`string`): The root directory of the app. - - *outDir* (`string`): The output directory for build results. Default is `dist/`. - - *assets* (`array`): List of application assets. - - *deployUrl* (`string`): URL where files will be deployed. - - *index* (`string`): The name of the start HTML file. Default is `index.html` - - *main* (`string`): The name of the main entry-point file. - - *polyfills* (`string`): The name of the polyfills entry-point file. Loaded before the app. - - *test* (`string`): The name of the test entry-point file. - - *tsconfig* (`string`): The name of the TypeScript configuration file. Default is `tsconfig.app.json`. - - *testTsconfig* (`string`): The name of the TypeScript configuration file for unit tests. - - *prefix* (`string`): The prefix to apply to generated selectors. - - *serviceWorker* (`boolean`): Experimental support for a service worker from @angular/service-worker. Default is `false`. - - *showCircularDependencies* (`boolean`): Show circular dependency warnings on builds. Default is `true`. - - *styles* (`string|array`): Global styles to be included in the build. - - *stylePreprocessorOptions* : Options to pass to style preprocessors. - - *includePaths* (`array`): Paths to include. Paths will be resolved to project root. - - *scripts* (`array`): Global scripts to be included in the build. - - *environmentSource* (`string`): Source file for environment config. - - *environments* (`object`): Name and corresponding file for environment config. - -- **e2e**: Confirguration for end-to-end tests. - - *protractor* - - *config* (`string`): Path to the config file. - -- **lint** (`array`): Properties to be passed to TSLint. - - *files* (`string|array`): File glob(s) to lint. - - *project* (`string`): Location of the tsconfig.json project file. Will also use as files to lint if 'files' property not present. - - *tslintConfig* (`string`): Location of the tslint.json configuration. Default is `tslint.json`. - - *exclude* (`string|array`): File glob(s) to ignore. - - -- **test**: Configuration for unit tests. - - *karma* - - *config* (`string`): Path to the karma config file. - - *codeCoverage* - - *exclude* (`array`): Globs to exclude from code coverage. - -- **defaults**: Specify the default values for generating. - - *styleExt* (`string`): The file extension to be used for style files. - - *poll* (`number`): How often to check for file updates. - - *class*: Options for generating a class. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `false`. - - *component*: Options for generating a component. - - *flat* (`boolean`): Flag to indicate if a dir is created. Default is `false`. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `true`. - - *inlineStyle* (`boolean`): Specifies if the style will be in the ts file. Default is `false`. - - *inlineTemplate* (`boolean`): Specifies if the template will be in the ts file. Default is `false`. - - *viewEncapsulation* (`string`): Specifies the view encapsulation strategy. Can be one of `Emulated`, `Native` or `None`. - - *changeDetection* (`string`): Specifies the change detection strategy. Can be one of `Default` or `OnPush`. - - *directive*: Options for generating a directive. - - *flat* (`boolean`): Flag to indicate if a dir is created. Default is `true`. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `true`. - - *guard*: Options for generating a guard. - - *flat* (`boolean`): Flag to indicate if a dir is created. Default is `true`. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `true`. - - *interface*: Options for generating a interface. - - *prefix* (`string`): Prefix to apply to interface names. (i.e. I) - - *module*: Options for generating a module. - - *flat* (`boolean`): Flag to indicate if a dir is created. Default is `false`. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `false`. - - *pipe*: Options for generating a pipe. - - *flat* (`boolean`): Flag to indicate if a dir is created. Default is `true`. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `true`. - - *service*: Options for generating a service. - - *flat* (`boolean`): Flag to indicate if a dir is created. Default is `true`. - - *spec* (`boolean`): Specifies if a spec file is generated. Default is `true`. - - *build*: Properties to be passed to the build command. - - *sourcemaps* (`boolean`): Output sourcemaps. - - *baseHref* (`string`): Base url for the application being built. - - *progress* (`boolean`): Log progress to the console while building. Default is `true`. - - *poll* (`number`): Enable and define the file watching poll time period (milliseconds). - - *deleteOutputPath* (`boolean`): Delete output path before build. Default is `true`. - - *preserveSymlinks* (`boolean`): Do not use the real path when resolving modules. Default is `false`. - - *showCircularDependencies* (`boolean`): Show circular dependency warnings on builds. Default is `true`. - - *namedChunks* (`boolean`): Use file name for lazy loaded chunks. - - *serve*: Properties to be passed to the serve command - - *port* (`number`): The port the application will be served on. Default is `4200`. - - *host* (`string`): The host the application will be served on. Default is `localhost`. - - *ssl* (`boolean`): Enables ssl for the application. Default is `false`. - - *sslKey* (`string`): The ssl key used by the server. Default is `ssl/server.key`. - - *sslCert* (`string`): The ssl certificate used by the server. Default is `ssl/server.crt`. - - *proxyConfig* (`string`): Proxy configuration file. - -- **packageManager** (`string`): Specify which package manager tool to use. Options include `npm`, `cnpm` and `yarn`. - -- **warnings**: Allow people to disable console warnings. - - *nodeDeprecation* (`boolean`): Show a warning when the node version is incompatible. Default is `true`. - - *packageDeprecation* (`boolean`): Show a warning when the user installed angular-cli. Default is `true`. - - *versionMismatch* (`boolean`): Show a warning when the global version is newer than the local one. Default is `true`. diff --git a/docs/documentation/build.md b/docs/documentation/build.md deleted file mode 100644 index 09b453aeb8f3..000000000000 --- a/docs/documentation/build.md +++ /dev/null @@ -1,368 +0,0 @@ - - -# ng build - -## Overview -`ng build` compiles the application into an output directory - -### Creating a build - -```bash -ng build -``` - -The build artifacts will be stored in the `dist/` directory. - -All commands that build or serve your project, `ng build/serve/e2e`, will delete the output -directory (`dist/` by default). -This can be disabled via the `--no-delete-output-path` (or `--delete-output-path=false`) flag. - -### Build Targets and Environment Files - -`ng build` can specify both a build target (`--target=production` or `--target=development`) and an -environment file to be used with that build (`--environment=dev` or `--environment=prod`). -By default, the development build target and environment are used. - -The mapping used to determine which environment file is used can be found in `.angular-cli.json`: - -```json -"environmentSource": "environments/environment.ts", -"environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -These options also apply to the serve command. If you do not pass a value for `environment`, -it will default to `dev` for `development` and `prod` for `production`. - -```bash -# these are equivalent -ng build --target=production --environment=prod -ng build --prod --env=prod -ng build --prod -# and so are these -ng build --target=development --environment=dev -ng build --dev --e=dev -ng build --dev -ng build -``` - -You can also add your own env files other than `dev` and `prod` by doing the following: -- create a `src/environments/environment.NAME.ts` -- add `{ "NAME": 'src/environments/environment.NAME.ts' }` to the `apps[0].environments` object in `.angular-cli.json` -- use them via the `--env=NAME` flag on the build/serve commands. - -### Base tag handling in index.html - -When building you can modify base tag (``) in your index.html with `--base-href your-url` option. - -```bash -# Sets base tag href to /myUrl/ in your index.html -ng build --base-href /myUrl/ -ng build --bh /myUrl/ -``` - -### Bundling & Tree-Shaking - -All builds make use of bundling and limited tree-shaking, while `--prod` builds also run limited -dead code elimination via UglifyJS. - -### `--dev` vs `--prod` builds - -Both `--dev`/`--target=development` and `--prod`/`--target=production` are 'meta' flags, that set other flags. -If you do not specify either you will get the `--dev` defaults. - -Flag | `--dev` | `--prod` ---- | --- | --- -`--aot` | `false` | `true` -`--environment` | `dev` | `prod` -`--output-hashing` | `media` | `all` -`--sourcemaps` | `true` | `false` -`--extract-css` | `false` | `true` -`--named-chunks`   | `true` | `false` - -`--extract-licenses` Extract all licenses in a separate file, in the case of production builds only. -`--i18n-file` Localization file to use for i18n. -`--prod` also sets the following non-flaggable settings: -- Adds service worker if configured in `.angular-cli.json`. -- Replaces `process.env.NODE_ENV` in modules with the `production` value (this is needed for some libraries, like react). -- Runs UglifyJS on the code. - -### CSS resources - -Resources in CSS, such as images and fonts, will be copied over automatically as part of a build. -If a resource is less than 10kb it will also be inlined. - -You'll see these resources be outputted and fingerprinted at the root of `dist/`. - -### Service Worker - -There is experimental service worker support for production builds available in the CLI. -To enable it, run the following commands: -``` -npm install @angular/service-worker --save -ng set apps.0.serviceWorker=true -``` - -On `--prod` builds a service worker manifest will be created and loaded automatically. -Remember to disable the service worker while developing to avoid stale code. - -Note: service worker support is experimental and subject to change. - -## Options -
- aot -

- --aot default value: false -

-

- Build using Ahead of Time compilation. -

-
- -
- app -

- --app (aliases: -a) -

-

- Specifies app name or index to use. -

-
- -
- base-href -

- --base-href (aliases: -bh) -

-

- Base url for the application being built. -

-
- -
- deploy-url -

- --deploy-url (aliases: -d) -

-

- URL where files will be deployed. -

-
- -
- environment -

- --environment (aliases: -e) -

-

- Defines the build environment. -

-
- -
- extract-css -

- --extract-css (aliases: -ec) -

-

- Extract css from global styles onto css files instead of js ones. -

-
- -
- i18n-file -

- --i18n-file -

-

- Localization file to use for i18n. -

-
- -
- i18n-format -

- --i18n-format -

-

- Format of the localization file specified with --i18n-file. -

-
- -
- locale -

- --locale -

-

- Locale to use for i18n. -

-
- -
- missing-translation -

- --missing-translation -

-

- How to handle missing translations for i18n. -

-

- Values: error, warning, ignore -

-
- -
- output-hashing -

- --output-hashing (aliases: -oh) -

-

- Define the output filename cache-busting hashing mode. -

-

- Values: none, all, media, bundles -

-
- -
- output-path -

- --output-path (aliases: -op) -

-

- Path where output will be placed. -

-
- -
- delete-output-path -

- --delete-output-path (aliases: -dop) default value: true -

-

- Delete the output-path directory. -

-
- -
- poll -

- --poll -

-

- Enable and define the file watching poll time period (milliseconds). -

-
- -
- progress -

- --progress (aliases: -pr) default value: true -

-

- Log progress to the console while building. -

-
- -
- sourcemap -

- --sourcemap (aliases: -sm, sourcemaps) -

-

- Output sourcemaps. -

-
- -
- stats-json -

- --stats-json -

-

- Generates a stats.json file which can be analyzed using tools such as: webpack-bundle-analyzer or https://webpack.github.io/analyse. -

-
- -
- target -

- --target (aliases: -t, -dev, -prod) default value: development -

-

- Defines the build target. -

-
- -
- vendor-chunk -

- --vendor-chunk (aliases: -vc) default value: true -

-

- Use a separate bundle containing only vendor libraries. -

-
- -
- common-chunk -

- --common-chunk (aliases: -cc) default value: true -

-

- Use a separate bundle containing code used across multiple bundles. -

-
- -
- verbose -

- --verbose (aliases: -v) default value: false -

-

- Adds more details to output logging. -

-
- -
- watch -

- --watch (aliases: -w) -

-

- Run build when files change. -

-
- -
- show-circular-dependencies -

- --show-circular-dependencies (aliases: -scd) -

-

- Show circular dependency warnings on builds. -

-
- -
- build-optimizer -

- --build-optimizer -

-

- (Experimental) Enables @angular-devkit/build-optimizer optimizations when using `--aot`. -

-
- -
- named-chunks -

- --named-chunks (aliases: -nm) -

-

- Use file name for lazy loaded chunks. -

-
diff --git a/docs/documentation/config.md b/docs/documentation/config.md deleted file mode 100644 index 0ed74469eb0f..000000000000 --- a/docs/documentation/config.md +++ /dev/null @@ -1,36 +0,0 @@ - - -# ng get - -## Overview -`ng get [key]` Get a value from the configuration. -`[key]` should be in JSON path format. Example: `a[3].foo.bar[2]`. - -## Options -
- global -

- --global default value: false -

-

- Get the value in the global configuration (in your home directory). -

-
- - -# ng set - -## Overview -`ng set [key]=[value]` Set a value in the configuration. -`[key]` should be in JSON path format. Example: `a[3].foo.bar[2]`. - -## Options -
- global -

- --global default value: false -

-

- Get the value in the global configuration (in your home directory). -

-
diff --git a/docs/documentation/doc.md b/docs/documentation/doc.md deleted file mode 100644 index f6ea8b2d8d33..000000000000 --- a/docs/documentation/doc.md +++ /dev/null @@ -1,18 +0,0 @@ - - -# ng doc - -## Overview -`ng doc [search term]` Opens the official Angular API documentation for a given keyword on [angular.io](https://angular.io). - -## Options - -
- search -

- --search (alias: -s) default value: false -

-

- Search for the keyword in the whole [angular.io](https://angular.io) documentation instead of just the API. -

-
diff --git a/docs/documentation/e2e.md b/docs/documentation/e2e.md deleted file mode 100644 index 84b21fd14c36..000000000000 --- a/docs/documentation/e2e.md +++ /dev/null @@ -1,65 +0,0 @@ - - -# ng e2e - -## Overview -`ng e2e` serves the application and runs end-to-end tests - -### Running end-to-end tests - -```bash -ng e2e -``` - -End-to-end tests are run via [Protractor] (https://angular.github.io/protractor/). - -## Options -
- config -

- --config (aliases: -c) -

-

- Use a specific config file. Defaults to the protractor config file in `.angular-cli.json`. -

-
- -
- element-explorer -

- --element-explorer (aliases: -ee) default value: false -

-

- Start Protractor's [Element Explorer](https://github.com/angular/protractor/blob/master/docs/debugging.md#testing-out-protractor-interactively) for debugging. -

-
- -
- serve -

- --serve (aliases: -s) default value: true -

-

- Compile and Serve the app. All serve options are also available. The live-reload option defaults to false, and the default port will be random. -

-
- -
- specs -

- --specs (aliases: -sp) default value: [] -

-

- Override specs in the protractor config. Can send in multiple specs by repeating flag (ng e2e --specs=spec1.ts --specs=spec2.ts). -

-
- -
- webdrive-update -

- --webdrive-update (aliases: -wu) default value: true -

-

- Try to update webdriver. -

-
diff --git a/docs/documentation/eject.md b/docs/documentation/eject.md deleted file mode 100644 index 651eabd0fbcc..000000000000 --- a/docs/documentation/eject.md +++ /dev/null @@ -1,226 +0,0 @@ - - -# ng eject - -## Overview -`ng eject` ejects your app and output the proper webpack configuration and scripts - -### Ejecting the CLI - -```bash -ng eject -``` - -## Options -
- aot -

- --aot -

-

- Build using Ahead of Time compilation. -

-
- -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- base-href -

- --base-href (aliases: -bh) -

-

- Base url for the application being built. -

-
- -
- deploy-url -

- --deploy-url (aliases: -d) -

-

- URL where files will be deployed. -

-
- -
- environment -

- --environment (aliases: -e) -

-

- Defines the build environment. -

-
- -
- extract-css -

- --extract-css (aliases: -ec) -

-

- Extract css from global styles onto css files instead of js ones. -

-
- -
- force -

- --force default value: false -

-

- Overwrite any webpack.config.js and npm scripts already existing. -

-
- -
- i18n-file -

- --i18n-file -

-

- Localization file to use for i18n. -

-
- -
- i18n-format -

- --i18n-format -

-

- Format of the localization file specified with --i18n-file. -

-
- -
- locale -

- --locale -

-

- Locale to use for i18n. -

-
- -
- missing-translation -

- --missing-translation -

-

- How to handle missing translations for i18n. -

-

- Values: error, warning, ignore -

-
- -
- output-hashing -

- --output-hashing (aliases: -oh) default value: -

-

- Define the output filename cache-busting hashing mode. Possible values: none, all, media, bundles -

-
- -
- output-path -

- --output-path (aliases: -op) default value: -

-

- Path where output will be placed. -

-
- -
- poll -

- --poll -

-

- Enable and define the file watching poll time period (milliseconds) . -

-
- -
- progress -

- --progress (aliases: -pr) default value: true -

-

- Log progress to the console while building. -

-
- -
- sourcemap -

- --sourcemap (aliases: -sm, sourcemaps) -

-

- Output sourcemaps. -

-
- -
- target -

- --target (aliases: -t, -dev, -prod) default value: development -

-

- Defines the build target. -

-
- -
- vendor-chunk -

- --vendor-chunk (aliases: -vc) default value: true -

-

- Use a separate bundle containing only vendor libraries. -

-
- -
- common-chunk -

- --common-chunk (aliases: -cc) default value: true -

-

- Use a separate bundle containing code used across multiple bundles. -

-
- -
- verbose -

- --verbose (aliases: -v) default value: false -

-

- Adds more details to output logging. -

-
- -
- watch -

- --watch (aliases: -w) -

-

- Run build when files change. -

-
diff --git a/docs/documentation/generate.md b/docs/documentation/generate.md deleted file mode 100644 index 6dd875a1cee4..000000000000 --- a/docs/documentation/generate.md +++ /dev/null @@ -1,61 +0,0 @@ - - -# ng generate - -## Overview -`ng generate [name]` generates the specified blueprint - -## Available blueprints: - - [class](generate/class) - - [component](generate/component) - - [directive](generate/directive) - - [enum](generate/enum) - - [guard](generate/guard) - - [interface](generate/interface) - - [module](generate/module) - - [pipe](generate/pipe) - - [service](generate/service) - -## Options -
- dry-run -

- --dry-run (aliases: -d) default value: false -

-

- Run through without making any changes. Will list all files that would have been created when running ng generate. -

-
- -
- lint-fix -

- --lint-fix (aliases: -lf) -

-

- Use lint to fix files after generation. -

-

- You can also set default true to use lint every time after generation. To do this, change the value in .angular-cli.json (defaults.lintFix). -

-
- -
- verbose -

- --verbose (aliases: -v) default value: false -

-

- Adds more details to output logging. -

-
- -
- collection -

- --collection (aliases: -c) default value: @schematics/angular -

-

- Schematics collection to use. -

-
diff --git a/docs/documentation/generate/class.md b/docs/documentation/generate/class.md deleted file mode 100644 index e1ccd6f5c1b0..000000000000 --- a/docs/documentation/generate/class.md +++ /dev/null @@ -1,27 +0,0 @@ - - -# ng generate class - -## Overview -`ng generate class [name]` generates a class - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
diff --git a/docs/documentation/generate/component.md b/docs/documentation/generate/component.md deleted file mode 100644 index 12ce166e1de8..000000000000 --- a/docs/documentation/generate/component.md +++ /dev/null @@ -1,117 +0,0 @@ - - -# ng generate component - -## Overview -`ng generate component [name]` generates a component - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- change-detection -

- --change-detection (aliases: -cd) -

-

- Specifies the change detection strategy. -

-
- -
- flat -

- --flat default value: false -

-

- Flag to indicate if a dir is created. -

-
- -
- export -

- --export default value: false -

-

- Specifies if declaring module exports the component. -

-
- -
- inline-style -

- --inline-style (aliases: -is) default value: false -

-

- Specifies if the style will be in the ts file. -

-
- -
- inline-template -

- --inline-template (aliases: -it) default value: false -

-

- Specifies if the template will be in the ts file. -

-
- -
- module -

- --module (aliases: -m) -

-

- Allows specification of the declaring module's file name (e.g `app.module.ts`). -

-
- -
- prefix -

- --prefix -

-

- Specifies whether to use the prefix. -

-
- -
- skip-import -

- --skip-import default value: false -

-

- Allows for skipping the module import. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
- -
- view-encapsulation -

- --view-encapsulation (aliases: -ve) -

-

- Specifies the view encapsulation strategy. -

-
diff --git a/docs/documentation/generate/directive.md b/docs/documentation/generate/directive.md deleted file mode 100644 index 9ed8f37046c7..000000000000 --- a/docs/documentation/generate/directive.md +++ /dev/null @@ -1,77 +0,0 @@ - - -# ng generate directive - -## Overview -`ng generate directive [name]` generates a directive - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- export -

- --export default value: false -

-

- Specifies if declaring module exports the component. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Allows specification of the declaring module. -

-
- -
- prefix -

- --prefix -

-

- Specifies whether to use the prefix. -

-
- -
- skip-import -

- --skip-import -

-

- Allows for skipping the module import. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
diff --git a/docs/documentation/generate/enum.md b/docs/documentation/generate/enum.md deleted file mode 100644 index c7b4b66ff19f..000000000000 --- a/docs/documentation/generate/enum.md +++ /dev/null @@ -1,17 +0,0 @@ - - -# ng generate enum - -## Overview -`ng generate enum [name]` generates an enumeration - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
diff --git a/docs/documentation/generate/guard.md b/docs/documentation/generate/guard.md deleted file mode 100644 index 3435dd172f66..000000000000 --- a/docs/documentation/generate/guard.md +++ /dev/null @@ -1,45 +0,0 @@ -# ng generate guard - -## Overview -`ng generate guard [name]` generates a guard - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- flat -

- --flat -

-

- Indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Specifies where the guard should be provided. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
diff --git a/docs/documentation/generate/interface.md b/docs/documentation/generate/interface.md deleted file mode 100644 index 8aa09de4ca76..000000000000 --- a/docs/documentation/generate/interface.md +++ /dev/null @@ -1,24 +0,0 @@ - - -# ng generate interface - -## Overview -`ng generate interface [name] ` generates an interface - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- type -

- Optional String to specify the type of interface. -

-
diff --git a/docs/documentation/generate/module.md b/docs/documentation/generate/module.md deleted file mode 100644 index 55559f122406..000000000000 --- a/docs/documentation/generate/module.md +++ /dev/null @@ -1,59 +0,0 @@ - - -# ng generate module - -## Overview -`ng generate module [name]` generates an NgModule - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Specifies where the module should be imported. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
- -
- routing -

- --routing -

-

- Specifies if a routing module file should be generated. -

-
- - diff --git a/docs/documentation/generate/pipe.md b/docs/documentation/generate/pipe.md deleted file mode 100644 index b8ee09497f3a..000000000000 --- a/docs/documentation/generate/pipe.md +++ /dev/null @@ -1,67 +0,0 @@ - - -# ng generate pipe - -## Overview -`ng generate pipe [name]` generates a pipe - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- export -

- --export -

-

- Specifies if declaring module exports the pipe. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Allows specification of the declaring module. -

-
- -
- skip-import -

- --skip-import -

-

- Allows for skipping the module import. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
diff --git a/docs/documentation/generate/service.md b/docs/documentation/generate/service.md deleted file mode 100644 index eb94f5c5b9bc..000000000000 --- a/docs/documentation/generate/service.md +++ /dev/null @@ -1,47 +0,0 @@ - - -# ng generate service - -## Overview -`ng generate service [name]` generates a service - -## Options -
- app -

- --app (aliases: -a) default value: 1st app -

-

- Specifies app name to use. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Specifies where the service should be provided. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
diff --git a/docs/documentation/home.md b/docs/documentation/home.md deleted file mode 100644 index beae5b4afd30..000000000000 --- a/docs/documentation/home.md +++ /dev/null @@ -1,63 +0,0 @@ - - -# Angular CLI - -### Overview -The Angular CLI is a tool to initialize, develop, scaffold and maintain [Angular](https://angular.io) applications - -### Getting Started -To install the Angular CLI: -``` -npm install -g @angular/cli -``` - -Generating and serving an Angular project via a development server -[Create](new) and [run](serve) a new project: -``` -ng new my-project -cd my-project -ng serve -``` -Navigate to http://localhost:4200/. The app will automatically reload if you change any of the source files. - -### Bundling - -All builds make use of bundling, and using the `--prod` flag in `ng build --prod` -or `ng serve --prod` will also make use of uglifying and tree-shaking functionality. - -### Running unit tests - -```bash -ng test -``` - -Tests will execute after a build is executed via [Karma](http://karma-runner.github.io/0.13/index.html), and it will automatically watch your files for changes. You can run tests a single time via `--watch=false` or `--single-run`. - -### Running end-to-end tests - -```bash -ng e2e -``` - -Before running the tests make sure you are serving the app via `ng serve`. -End-to-end tests are run via [Protractor](https://angular.github.io/protractor/). - -### Additional Commands -* [ng new](new) -* [ng serve](serve) -* [ng generate](generate) -* [ng lint](lint) -* [ng test](test) -* [ng e2e](e2e) -* [ng build](build) -* [ng get/ng set](config) -* [ng doc](doc) -* [ng eject](eject) -* [ng xi18n](xi18n) - -## Angular CLI Config Schema -* [Config Schema](angular-cli) - -### Additional Information -There are several [stories](stories) which will walk you through setting up -additional aspects of Angular applications. diff --git a/docs/documentation/lint.md b/docs/documentation/lint.md deleted file mode 100644 index 95d94bb5b73d..000000000000 --- a/docs/documentation/lint.md +++ /dev/null @@ -1,47 +0,0 @@ - - -# ng lint - -## Overview -`ng lint` will lint you app code using tslint. - -## Options -
- fix -

- --fix default value: false -

-

- Fixes linting errors (may overwrite linted files). -

-
- -
- force -

- --force default value: false -

-

- Succeeds even if there was linting errors. -

-
- -
- type-check -

- --type-check default value: false -

-

- Controls the type check for linting. -

-
- -
- format -

- --format (aliases: -t) default value: prose -

-

- Output format (prose, json, stylish, verbose, pmd, msbuild, checkstyle, vso, fileslist). -

-
diff --git a/docs/documentation/new.md b/docs/documentation/new.md deleted file mode 100644 index 9400ce2ef5e2..000000000000 --- a/docs/documentation/new.md +++ /dev/null @@ -1,168 +0,0 @@ - - -# ng new - -## Overview -`ng new [name]` creates a new angular application. - -Default applications are created in a directory of the same name, with an initialized Angular application. - -## Options -
- directory -

- --directory (alias: -dir) default value: dir -

-

- The directory name to create the app in. -

-
- -
- dry-run -

- --dry-run (alias: -d) default value: false -

-

- Run through without making any changes. Will list all files that would have been created when running ng new. -

-
- -
- inline-style -

- --inline-style (alias: -is) default value: false -

-

- Should have an inline style. -

-
- -
- inline-template -

- --inline-template (alias: -it) default value: false -

-

- Should have an inline template. -

-
- -
- minimal -

- --minimal default value: false -

-

- Should create a minimal app. -

-
- -
- prefix -

- --prefix (alias: -p) default value: app -

-

- The prefix to use for all component selectors. -

-

- You can later change the value in .angular-cli.json (apps[0].prefix). -

-
- -
- routing -

- --routing default value: false -

-

- Generate a routing module. -

-
- -
- skip-commit -

- --skip-commit (alias: -sc) default value: false -

-

- Skip committing the first commit to git. -

-
- -
- skip-git -

- --skip-git (alias: -sg) default value: false -

-

- Skip initializing a git repository. -

-
- -
- skip-install -

- --skip-install (alias: -si) default value: false -

-

- Skip installing packages. -

-
- -
- skip-tests -

- --skip-tests (aliases: -st) default value: false -

-

- Skip creating spec files. -

-

- Skip including e2e functionality. -

-
- -
- source-dir -

- --source-dir (alias: -sd) default value: src -

-

- The name of the source directory. -

-

- You can later change the value in .angular-cli.json (apps[0].root). -

-
- -
- style -

- --style default value: css -

-
- The style file default extension. Possible values: -
    -
  • css
  • -
  • scss
  • -
  • less
  • -
  • sass
  • -
  • styl (stylus)
  • -
-
-

- You can later change the value in .angular-cli.json (defaults.styleExt). -

-
- -
- verbose -

- --verbose (alias: -v) default value: false -

-

- Adds more details to output logging. -

-
diff --git a/docs/documentation/serve.md b/docs/documentation/serve.md deleted file mode 100644 index 155a3545d588..000000000000 --- a/docs/documentation/serve.md +++ /dev/null @@ -1,316 +0,0 @@ - - -# ng serve - -## Overview -`ng serve` builds the application and starts a web server. - -All the build Options are available in serve, below are the additional options. - -## Options -
- host -

- --host (aliases: -H) default value: localhost -

-

- Listens only on localhost by default. -

-
- -
- hmr -

- --hmr default value: false -

-

- Enable hot module replacement. -

-
- -
- live-reload -

- --live-reload (aliases: -lr) default value: true -

-

- Whether to reload the page on change, using live-reload. -

-
- -
- public-host -

- --public-host (aliases: --live-reload-client) -

-

- Specify the URL that the browser client will use. -

-
- -
- disable-host-check -

- --disable-host-check default value: false -

-

- Don't verify connected clients are part of allowed hosts. -

-
- -
- open -

- --open (aliases: -o) default value: false -

-

- Opens the url in default browser. -

-
- -
- port -

- --port (aliases: -p) default value: 4200 -

-

- Port to listen to for serving. -

-
- -
- ssl -

- --ssl -

-

- Serve using HTTPS. -

-
- -
- ssl-cert -

- --ssl-cert (aliases: -) default value: -

-

- SSL certificate to use for serving HTTPS. -

-
- -
- ssl-key -

- --ssl-key -

-

- SSL key to use for serving HTTPS. -

-
- -
- aot -

- --aot -

-

- Build using Ahead of Time compilation. -

-
- -
- base-href -

- --base-href (aliases: -bh) -

-

- Base url for the application being built. -

-
- -
- deploy-url -

- --deploy-url (aliases: -d) -

-

- URL where files will be deployed. -

-
- -
- environment -

- --environment (aliases: -e) -

-

- Defines the build environment. -

-
- -
- extract-css -

- --extract-css (aliases: -ec) -

-

- Extract css from global styles onto css files instead of js ones. -

-
- -
- i18n-file -

- --i18n-file -

-

- Localization file to use for i18n. -

-
- -
- i18n-format -

- --i18n-format -

-

- Format of the localization file specified with --i18n-file. -

-
- -
- locale -

- --locale -

-

- Locale to use for i18n. -

-
- -
- missing-translation -

- --missing-translation -

-

- How to handle missing translations for i18n. -

-

- Values: error, warning, ignore -

-
- -
- output-hashing -

- --output-hashing (aliases: -oh) default value: -

-

- Define the output filename cache-busting hashing mode. Possible values: none, all, media, bundles -

-
- -
- output-path -

- --output-path (aliases: -op) default value: -

-

- Path where output will be placed. -

-
- -
- poll -

- --poll -

-

- Enable and define the file watching poll time period (milliseconds) . -

-
- -
- progress -

- --progress (aliases: -pr) default value: true -

-

- Log progress to the console while building. -

-
- -
- proxy-config -

- --proxy-config -

-

- Use a proxy configuration file to send some requests to a backend server rather than the webpack dev server. -

-
- -
- sourcemap -

- --sourcemap (aliases: -sm, sourcemaps) -

-

- Output sourcemaps. -

-
- -
- target -

- --target (aliases: -t, -dev, -prod) default value: development -

-

- Defines the build target. -

-
- -
- vendor-chunk -

- --vendor-chunk (aliases: -vc) default value: true -

-

- Use a separate bundle containing only vendor libraries. -

-
- -
- common-chunk -

- --common-chunk (aliases: -cc) default value: true -

-

- Use a separate bundle containing code used across multiple bundles. -

-
- -
- verbose -

- --verbose (aliases: -v) default value: false -

-

- Adds more details to output logging. -

-
- -
- watch -

- --watch (aliases: -w) -

-

- Run build when files change. -

-
- - -## Note -When running `ng serve`, the compiled output is served from memory, not from disk. This means that the application being served is not located on disk in the `dist` folder. diff --git a/docs/documentation/stories.md b/docs/documentation/stories.md deleted file mode 100644 index babf2802e116..000000000000 --- a/docs/documentation/stories.md +++ /dev/null @@ -1,33 +0,0 @@ - - -# Stories describing how to do more with the CLI - - - [1.0 Update](stories/1.0-update) - - [Asset Configuration](stories/asset-configuration) - - [Autocompletion](stories/autocompletion) - - [Configure Hot Module Replacement](stories/configure-hmr) - - [CSS Preprocessors](stories/css-preprocessors) - - [Global Lib](stories/global-lib) - - [Global Scripts](stories/global-scripts) - - [Global Styles](stories/global-styles) - - [Angular Flex Layout](stories/include-angular-flex) - - [Angular Material](stories/include-angular-material) - - [AngularFire](stories/include-angularfire) - - [Bootstrap](stories/include-bootstrap) - - [Font Awesome](stories/include-font-awesome) - - [Moving Into the CLI](stories/moving-into-the-cli) - - [Moving Out of the CLI](stories/moving-out-of-the-cli) - - [Proxy](stories/proxy) - - [Routing](stories/routing) - - [3rd Party Lib](stories/third-party-lib) - - [Corporate Proxy](stories/using-corporate-proxy) - - [Internationalization (i18n)](stories/internationalization) - - [Serve from Disk](stories/disk-serve) - - [Code Coverage](stories/code-coverage) - - [Application Environments](stories/application-environments) - - [Autoprefixer Configuration](stories/autoprefixer) - - [Deploy to GitHub Pages](stories/github-pages) - - [Linked Library](stories/linked-library) - - [Multiple apps](stories/multiple-apps) - - [Continuous Integration](stories/continuous-integration) - - [Universal Rendering](stories/universal-rendering) diff --git a/docs/documentation/stories/1.0-update.md b/docs/documentation/stories/1.0-update.md deleted file mode 100644 index a9fc9d8d3f25..000000000000 --- a/docs/documentation/stories/1.0-update.md +++ /dev/null @@ -1,503 +0,0 @@ -# Angular CLI migration guide - -In this migration guide we'll be looking at some of the major changes to CLI projects in the -last two months. - -Most of these changes were not breaking changes and your project should work fine without them. - -But if you've been waiting for the perfect time to update, this is it! -Along with major rebuild speed increases, we've been busy adding a lot of features. - -Documentation has also completely moved to [the wiki](https://github.com/angular/angular-cli/wiki). -The new [Stories](https://github.com/angular/angular-cli/wiki/stories) section covers common usage -scenarios, so be sure to have a look! - -Below are the changes between a project generated two months ago, with `1.0.0-beta.24` and -a `1.0.0` project. -If you kept your project up to date you might have a lot of these already. - -You can find more details about changes between versions in [the releases tab on GitHub](https://github.com/angular/angular-cli/releases). - -If you prefer, you can also generate a new project in a separate folder using - `ng new upgrade-project --skip-install` and compare the differences. - -## @angular/cli - -Angular CLI can now be found on NPM under `@angular/cli` instead of `angular-cli`, and upgrading is a simple 3 step process: - -1. Uninstall old version -2. Update node/npm if necessary -3. Install new version - -### 1. Uninstall old version - -If you're using Angular CLI `beta.28` or less, you need to uninstall the `angular-cli` packages: -```bash -npm uninstall -g angular-cli # Remove global package -npm uninstall --save-dev angular-cli # Remove from package.json -``` - -Otherwise, uninstall the `@angular/cli` packages: -```bash -npm uninstall -g @angular/cli # Remove global package -npm uninstall --save-dev @angular/cli # Remove from package.json -``` - -Also purge the cache and local packages: -``` -rm -rf node_modules dist # Use rmdir on Windows -npm cache clean -``` - -At this point, you should not have Angular CLI on your system anymore. If invoking Angular CLI at the commandline reveals that it still exists on your system, you will have to manually remove it. See _Manually removing residual Angular CLI_. - -### 2. Update node/npm if necessary - -Angular CLI now has a minimum requirement of Node 6.9.0 or higher, together with NPM 3 or higher. - -If your Node or NPM versions do not meet these requirements, please refer to [the documentation](https://docs.npmjs.com/getting-started/installing-node) on how to upgrade. - -### 3. Install the new version - -To update Angular CLI to a new version, you must update both the global package and your project's local package: - -```bash -npm install -g @angular/cli@latest # Global package -npm install --save-dev @angular/cli@latest # Local package -npm install # Restore removed dependencies -``` - -### Manually removing residual Angular CLI - -If you accidentally updated NPM before removing the old Angular CLI, you may find that uninstalling the package using `npm uninstall` is proving fruitless. This _could_ be because the global install (and uninstall) path changed between versions of npm from `/usr/local/lib` to `/usr/lib`, and hence, no longer searches through the old directory. In this case you'll have to remove it manually: - -`rm -rf /usr/local/lib/node_modules/@angular/cli` - -If the old Angular CLI package _still_ persists, you'll need to research how to remove it before proceeding with the upgrade. - -## .angular-cli.json - -`angular-cli.json` is now `.angular-cli.json`, but we still accept the old config file name. - -A few new properties have changed in it: - -### Schema - -Add the `$schema` property above project for handy IDE support on your config file: - -``` -"$schema": "./node_modules/@angular/cli/lib/config/schema.json", -``` - -### Polyfills - -There is now a dedicated entry for polyfills ([#3812](https://github.com/angular/angular-cli/pull/3812)) -inside `apps[0].polyfills`, between `main` and `test`: - -``` -"main": "main.ts", -"polyfills": "polyfills.ts", -"test": "test.ts", -``` - -Add it and remove `import './polyfills.ts';` from `src/main.ts` and `src/test.ts`. - -We also added a lot of descriptive comments to the existing `src/polyfills.ts` file, explaining -which polyfills are needed for what browsers. -Be sure to check it out in a new project! - -### Environments - -A new `environmentSource` entry ([#4705](https://github.com/angular/angular-cli/pull/4705)) -replaces the previous source entry inside environments. - -To migrate angular-cli.json follow the example below: - -Before: -``` -"environments": { - "source": "environments/environment.ts", - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -After: - -``` -"environmentSource": "environments/environment.ts", -"environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -### Linting - -The CLI now uses the TSLint API ([#4248](https://github.com/angular/angular-cli/pull/4248)) -to lint several TS projects at once. - -There is a new `lint` entry in `.angular-cli.json` between `e2e` and `test` where all linting -targets are listed: - -``` -"e2e": { - "protractor": { - "config": "./protractor.conf.js" - } -}, -"lint": [ - { - "project": "src/tsconfig.app.json" - }, - { - "project": "src/tsconfig.spec.json" - }, - { - "project": "e2e/tsconfig.e2e.json" - } -], -"test": { - "karma": { - "config": "./karma.conf.js" - } -}, -``` - -### Generator defaults - -Now you can list generator defaults per generator ([#4389](https://github.com/angular/angular-cli/pull/4389)) -in `defaults`. - -Instead of: -``` -"defaults": { - "styleExt": "css", - "prefixInterfaces": false, - "inline": { - "style": false, - "template": false - }, - "spec": { - "class": false, - "component": true, - "directive": true, - "module": false, - "pipe": true, - "service": true - } -} -``` - -You can instead list the flags as they appear on [the generator command](https://github.com/angular/angular-cli/wiki/generate-component): -``` -"defaults": { - "styleExt": "css", - "component": { - "inlineTemplate": false, - "spec": true - } -} -``` - -## One tsconfig per app - -CLI projects now use one tsconfig per app ([#4924](https://github.com/angular/angular-cli/pull/4924)). - -- `src/tsconfig.app.json`: configuration for the Angular app. -``` -{ - "compilerOptions": { - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", - "lib": [ - "es2017", - "dom" - ], - "outDir": "../out-tsc/app", - "module": "es2015", - "baseUrl": "", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts" - ] -} -``` -- `src/tsconfig.spec.json`: configuration for the unit tests. -``` -{ - "compilerOptions": { - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "../out-tsc/spec", - "module": "commonjs", - "target": "es5", - "baseUrl": "", - "types": [ - "jasmine", - "node" - ] - }, - "files": [ - "test.ts" - ], - "include": [ - "**/*.spec.ts", - "**/*.d.ts" - ] -} - -``` -- `e2e/tsconfig.e2e.json`: configuration for the e2e tests. -``` -{ - "compilerOptions": { - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017" - ], - "outDir": "../out-tsc/e2e", - "module": "commonjs", - "target": "es5", - "types":[ - "jasmine", - "node" - ] - } -} - -``` - -There is an additional root-level `tsconfig.json` that is used for IDE integration. -``` -{ - "compileOnSave": false, - "compilerOptions": { - "outDir": "./dist/out-tsc", - "baseUrl": "src", - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", - "typeRoots": [ - "node_modules/@types" - ], - "lib": [ - "es2017", - "dom" - ] - } -} -``` - -You can delete `e2e/tsconfig.json` and `src/tsconfig.json` after adding these. - -Also update `.angular-cli.json` to use them inside `apps[0]`: - -``` -"tsconfig": "tsconfig.app.json", -"testTsconfig": "tsconfig.spec.json", -``` - -Then update `protractor.conf.js` to use the e2e config as well: -``` -beforeLaunch: function() { - require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' - }); -}, -``` - -These configs have an `types` array where you should add any package you install via `@types/*`. -This array helps keep typings isolated to the apps that really need them and avoid problems with -duplicate typings. - -For instance, the unit test `tsconfig` has `jasmine` and `node`, which correspond to -`@types/jasmine` and `@types/node`. -Add any typings you've installed to the appropriate `tsconfig` as well. - -## typings.d.ts - -There's a new `src/typings.d.ts` file that serves two purposes: -- provides a centralized place for users to add their own custom typings -- makes it easy to use components that use `module.id`, present in the documentation and in snippets - -``` -/* SystemJS module definition */ -declare var module: NodeModule; -interface NodeModule { - id: string; -} -``` - -## package.json - -We've updated a lot of packages over the last months in order to keep projects up to date. - -Additions or removals are found in bold below. - -Packages in `dependencies`: -- `@angular/*` packages now have a `^4.0.0` minimum -- `core-js` remains unchanged at `^2.4.1` -- `rxjs` was updated to `^5.1.0` -- `ts-helpers` was **removed** -- `zone.js` was updated to `^0.8.4` - -Packages in `devDependencies`: -- `@angular/cli` at `1.0.0` replaces `angular-cli` -- `@angular/compiler-cli` is also at `^4.0.0` -- `@types/jasmine` remains unchanged and pinned at `2.5.38` -- `@types/node` was updated to `~6.0.60` -- `codelyzer` was updated to `~2.0.0` -- `jasmine-core` was updated to `~2.5.2` -- `jasmine-spec-reporter` was updated to `~3.2.0` -- `karma` was updated to `~1.4.1` -- `karma-chrome-launcher` was updated to `~2.0.0` -- `karma-cli` was updated to `~1.0.1` -- `karma-jasmine` was updated to `~1.1.0` -- `karma-jasmine-html-reporter` was **added** at `^0.2.2` -- `karma-coverage-istanbul-reporter` was **added** at `^0.2.0`, replacing `karma-remap-istanbul` -- `karma-remap-istanbul` was **removed** -- `protractor` was updated to `~5.1.0` -- `ts-node` was updated to `~2.0.0` -- `tslint` was updated to `~4.5.0` -- `typescript` was updated to `~2.1.0` - -See the [karma](#karma.conf.js) and [protractor](#protractor.conf.js) sections below for more -information on changed packages. - -The [Linting rules](#Linting rules) section contains a list of rules that changed due to updates. - -We also updated the scripts section to make it more simple: - -``` -"scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "test": "ng test", - "lint": "ng lint", - "e2e": "ng e2e" -}, -``` - -## karma.conf.js - -Karma configuration suffered some changes to improve the code-coverage functionality, -use the new `@angular/cli` package, and the new HTML reporter. - -In the `frameworks` array update the CLI package to `@angular/cli`. - -In the `plugins` array: -- add `require('karma-jasmine-html-reporter')` and `require('karma-coverage-istanbul-reporter')` -- remove `require('karma-remap-istanbul')` -- update the CLI plugin to `require('@angular/cli/plugins/karma')` - -Add a new `client` option just above `patterns`: -``` -client:{ - clearContext: false // leave Jasmine Spec Runner output visible in browser -}, -files: [ -``` - -Change the preprocessor to use the new CLI package: -``` -preprocessors: { - './src/test.ts': ['@angular/cli'] -}, -``` - -Replace `remapIstanbulReporter` with `coverageIstanbulReporter`: -``` -coverageIstanbulReporter: { - reports: [ 'html', 'lcovonly' ], - fixWebpackSourcePaths: true -}, -``` - -Remove the config entry from `angularCli`: -``` -angularCli: { - environment: 'dev' -}, -``` - -Update the reporters to use `coverage-istanbul` instead of `karma-remap-istanbul`, and -add `kjhtml` (short for karma-jasmine-html-reporter): -``` -reporters: config.angularCli && config.angularCli.codeCoverage - ? ['progress', 'coverage-istanbul'] - : ['progress', 'kjhtml'], -``` - -## protractor.conf.js - -Protractor was updated to the new 5.x major version, but you shouldn't need to change much -to take advantage of all its new features. - -Replace the spec reporter import from: -``` -var SpecReporter = require('jasmine-spec-reporter'); -``` -to -``` -const { SpecReporter } = require('jasmine-spec-reporter'); -``` - -Remove `useAllAngular2AppRoots: true`. - -Update `beforeLaunch` as described in [One tsconfig per app](#one-tsconfig-per-app): -``` -beforeLaunch: function() { - require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' - }); -}, -``` - -Update `onPrepare`: -``` -onPrepare: function() { - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); -} -``` - -## Linting rules - -The updated versions of `tslint` and `codelyzer` contain a few rule changes that you should -apply to your `tslint.json`: - -Add these new rules: -``` -"callable-types": true, -"import-blacklist": [true, "rxjs"], -"import-spacing": true, -"interface-over-type-literal": true, -"no-empty-interface": true, -"no-string-throw": true, -"prefer-const": true, -"typeof-compare": true, -"unified-signatures": true, -``` - -Update `no-inferrable-types` to `"no-inferrable-types": [true, "ignore-params"]`. diff --git a/docs/documentation/stories/application-environments.md b/docs/documentation/stories/application-environments.md deleted file mode 100644 index d3c4b18d054c..000000000000 --- a/docs/documentation/stories/application-environments.md +++ /dev/null @@ -1,122 +0,0 @@ -# Application Environments - -## Configuring available environments - -`.angular-cli.json` contains an **environments** section. By default, this looks like: - -``` json -"environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -You can add additional environments as required. To add a **staging** environment, your configuration would look like: - -``` json -"environments": { - "dev": "environments/environment.ts", - "staging": "environments/environment.staging.ts", - "prod": "environments/environment.prod.ts" -} -``` - -## Adding environment-specific files - -The environment-specific files are set out as shown below: - -``` -└── src - └── environments - ├── environment.prod.ts - └── environment.ts -``` - -If you wanted to add another environment for **staging**, your file structure would become: - -``` -└── src - └── environments - ├── environment.prod.ts - ├── environment.staging.ts - └── environment.ts -``` - -## Amending environment-specific files - -`environment.ts` contains the default settings. If you take a look at this file, it should look like: - -``` TypeScript -export const environment = { - production: false -}; -``` - -If you compare this to `environment.prod.ts`, which looks like: - -``` TypeScript -export const environment = { - production: true -}; -``` - -You can add further variables, either as additional properties on the `environment` object, or as separate objects, for example: - -``` TypeScript -export const environment = { - production: false, - apiUrl: 'http://my-api-url' -}; -``` - -## Using environment-specific variables in your application - -Given the following application structure: - -``` -└── src - └── app - ├── app.component.html - └── app.component.ts - └── environments - ├── environment.prod.ts - ├── environment.staging.ts - └── environment.ts -``` - -Using environment variables inside of `app.component.ts` might look something like this: - -``` TypeScript -import { Component } from '@angular/core'; -import { environment } from './../environments/environment'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] -}) -export class AppComponent { - constructor() { - console.log(environment.production); // Logs false for default environment - } - title = 'app works!'; -} -``` - -## Environment-specific builds - -Running: - -``` -ng build -``` - -Will use the defaults found in `environment.ts` - -Running: - -``` -ng build --env=staging -``` - -Will use the values from `environment.staging.ts` diff --git a/docs/documentation/stories/asset-configuration.md b/docs/documentation/stories/asset-configuration.md deleted file mode 100644 index 0f1a2a170053..000000000000 --- a/docs/documentation/stories/asset-configuration.md +++ /dev/null @@ -1,39 +0,0 @@ -# Project assets - -You use the `assets` array in `.angular-cli.json` to list files or folders you want to copy as-is -when building your project. - -By default, the `src/assets/` folder and `src/favicon.ico` are copied over. - -```json -"assets": [ - "assets", - "favicon.ico" -] -``` - -You can also further configure assets to be copied by using objects as configuration. - -The array below does the same as the default one: - -```json -"assets": [ - { "glob": "**/*", "input": "./assets/", "output": "./assets/" }, - { "glob": "favicon.ico", "input": "./", "output": "./" }, -] -``` - -`glob` is the a [node-glob](https://github.com/isaacs/node-glob) using `input` as base directory. -`input` is relative to the project root (`src/` default), while `output` is - relative to `outDir` (`dist` default). - - You can use this extended configuration to copy assets from outside your project. - For instance, you can copy assets from a node package: - - ```json -"assets": [ - { "glob": "**/*", "input": "../node_modules/some-package/images", "output": "./some-package/" }, -] -``` - -The contents of `node_modules/some-package/images/` will be available in `dist/some-package/`. \ No newline at end of file diff --git a/docs/documentation/stories/autocompletion.md b/docs/documentation/stories/autocompletion.md deleted file mode 100644 index 324d6a2faa58..000000000000 --- a/docs/documentation/stories/autocompletion.md +++ /dev/null @@ -1,21 +0,0 @@ -# Autocompletion - -To turn on auto completion use the following commands: - -For bash: -```bash -ng completion --bash >> ~/.bashrc -source ~/.bashrc -``` - -For zsh: -```bash -ng completion --zsh >> ~/.zshrc -source ~/.zshrc -``` - -Windows users using gitbash: -```bash -ng completion --bash >> ~/.bash_profile -source ~/.bash_profile -``` \ No newline at end of file diff --git a/docs/documentation/stories/autoprefixer.md b/docs/documentation/stories/autoprefixer.md deleted file mode 100644 index 78b55b553b64..000000000000 --- a/docs/documentation/stories/autoprefixer.md +++ /dev/null @@ -1,43 +0,0 @@ -# Change target browsers for Autoprefixer - -Currently, the CLI uses [Autoprefixer](https://github.com/postcss/autoprefixer) to ensure compatibility -with different browser and browser versions. You may find it necessary to target specific browsers -or exclude certain browser versions from your build. - -Internally, Autoprefixer relies on a library called [Browserslist](https://github.com/ai/browserslist) -to figure out which browsers to support with prefixing. - -There are a few ways to tell Autoprefixer what browsers to target: - -### Add a browserslist property to the `package.json` file -``` -"browserslist": [ - "> 1%", - "last 2 versions" -] -``` - -### Add a new file to the project directory called `.browserslistrc` -``` -### Supported Browsers - -> 1% -last 2 versions -``` - -Autoprefixer will look for the configuration file/property to use when it prefixes your css. -Check out the [browserslist repo](https://github.com/ai/browserslist) for more examples of how to target -specific browsers and versions. - -_Side note:_ -Those who are seeking to produce a [progressive web app](https://developers.google.com/web/progressive-web-apps/) and are using [Lighthouse](https://developers.google.com/web/tools/lighthouse/) to grade the project will -need to add the following browserslist config to their package.json file to eliminate the [old flexbox](https://developers.google.com/web/tools/lighthouse/audits/old-flexbox) prefixes: - -`package.json` config: -``` -"browserslist": [ - "last 2 versions", - "not ie <= 10", - "not ie_mob <= 10" -] -``` diff --git a/docs/documentation/stories/code-coverage.md b/docs/documentation/stories/code-coverage.md deleted file mode 100644 index 354063cc1242..000000000000 --- a/docs/documentation/stories/code-coverage.md +++ /dev/null @@ -1,32 +0,0 @@ -# Code Coverage - -With the Angular CLI we can run unit tests as well as create code coverage reports. Code coverage reports allow us to see any parts of our code base that may not be properly tested by our unit tests. - -To generate a coverage report run the following command in the root of your project - -```bash -ng test --watch=false --code-coverage -``` - -Once the tests complete a new `/coverage` folder will appear in the project. In your Finder or Windows Explorer open the `index.html` file. You should see a report with your source code and code coverage values. - -Using the code coverage percentages we can estimate how much of our code is tested. It is up to your team to determine how much code should be unit tested. - -## Code Coverage Enforcement - -If your team decides on a set minimum amount to be unit tested you can enforce this minimum with the Angular CLI. For example our team would like the code base to have a minimum of 80% code coverage. To enable this open the `karma.conf.js` and add the following in the `coverageIstanbulReporter:` key - -```javascript -coverageIstanbulReporter: { - reports: [ 'html', 'lcovonly' ], - fixWebpackSourcePaths: true, - thresholds: { - statements: 80, - lines: 80, - branches: 80, - functions: 80 - } -} -``` - -The `thresholds` property will enforce a minimum of 80% code coverage when the unit tests are run in the project. \ No newline at end of file diff --git a/docs/documentation/stories/configure-hmr.md b/docs/documentation/stories/configure-hmr.md deleted file mode 100644 index c00352e6b346..000000000000 --- a/docs/documentation/stories/configure-hmr.md +++ /dev/null @@ -1,150 +0,0 @@ -# Configure Hot Module Replacement - -Hot Module Replacement (HMR) is a WebPack feature to update code in a running app without rebuilding it. -This results in faster updates and less full page-reloads. - -You can read more about HRM by visiting [this page](https://webpack.github.io/docs/hot-module-replacement.html). - -In order to get HMR working with Angular CLI we first need to add a new environment and enable it. - -Next we need to update the bootstrap process of our app to enable the -[@angularclass/hmr](https://github.com/AngularClass/angular-hmr) module. - -### Add environment for HMR - -Create a file called `src/environments/environment.hmr.ts` with the following contents: - -```typescript - -export const environment = { - production: false, - hmr: true -}; -``` - -Update `src/environments/environment.prod.ts` and add the `hrm: false` flag to the environment: - -```typescript -export const environment = { - production: true, - hmr: false -}; -``` - -Update `src/environments/environment.ts` and add the `hrm: false` flag to the environment: - -```typescript -export const environment = { - production: false, - hmr: false -}; -``` - - -Update `.angular-cli.json` by adding the new environment the existing environments object: - -```json -"environmentSource": "environments/environment.ts", -"environments": { - "dev": "environments/environment.ts", - "hmr": "environments/environment.hmr.ts", - "prod": "environments/environment.prod.ts" -}, -``` - -Run `ng serve` with the flag `--hmr -e=hmr` to enable hmr and select the new environment: - -```bash -ng serve --hmr -e=hmr -``` - -Create a shortcut for this by updating `package.json` and adding an entry to the script object: - -```json -"scripts": { - ... - "hmr": "ng serve --hmr -e=hmr" -} -``` - - -### Add dependency for @angularclass/hmr and configure app - -In order to get HMR working we need to install the dependency and configure our app to use it. - - -Install the `@angularclass/hmr` module as a dev-dependency - -```bash -$ npm install --save-dev @angularclass/hmr -``` - - -Create a file called `src/hmr.ts` with the following content: - -```typescript -import { NgModuleRef, ApplicationRef } from '@angular/core'; -import { createNewHosts } from '@angularclass/hmr'; - -export const hmrBootstrap = (module: any, bootstrap: () => Promise>) => { - let ngModule: NgModuleRef; - module.hot.accept(); - bootstrap().then(mod => ngModule = mod); - module.hot.dispose(() => { - const appRef: ApplicationRef = ngModule.injector.get(ApplicationRef); - const elements = appRef.components.map(c => c.location.nativeElement); - const makeVisible = createNewHosts(elements); - ngModule.destroy(); - makeVisible(); - }); -}; -``` - - -Update `src/main.ts` to use the file we just created: - -```typescript -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -import { hmrBootstrap } from './hmr'; - -if (environment.production) { - enableProdMode(); -} - -const bootstrap = () => platformBrowserDynamic().bootstrapModule(AppModule); - -if (environment.hmr) { - if (module[ 'hot' ]) { - hmrBootstrap(module, bootstrap); - } else { - console.error('HMR is not enabled for webpack-dev-server!'); - console.log('Are you using the --hmr flag for ng serve?'); - } -} else { - bootstrap(); -} -``` - - -### Starting the development environment with HMR enabled - -Now that everything is set up we can run the new configuration: - -```bash -$ npm run hmr -``` - -When starting the server Webpack will tell you that it’s enabled: - - - NOTICE Hot Module Replacement (HMR) is enabled for the dev server. - - -Now if you make changes to one of your components they changes should be visible automatically without a complete browser refresh. - - diff --git a/docs/documentation/stories/continuous-integration.md b/docs/documentation/stories/continuous-integration.md deleted file mode 100644 index 1745f8cf4240..000000000000 --- a/docs/documentation/stories/continuous-integration.md +++ /dev/null @@ -1,148 +0,0 @@ -# Continuous Integration - -One of the best ways to keep your project bug free is through a test suite, but it's easy to forget -to run tests all the time. - -That's where Continuous Integration (CI) servers come in. -You can set up your project repository so that your tests run on every commit and pull request. - -There are paid CI services like [Circle CI](https://circleci.com/) and -[Travis CI](https://travis-ci.com/), and you can also host your own for free using -[Jenkins](https://jenkins.io/) and others. - -Even though Circle CI and Travis CI are paid services, they are provided free for open source -projects. -You can create a public project on GitHub and add these services without paying. - -We're going to see how to update your test configuration to run in CI environments, and how to -set up Circle CI and Travis CI. - - -## Update test configuration - -Even though `ng test` and `ng e2e` already run on your environment, they need to be adjusted to -run in CI environments. - -When using Chrome in CI environments it has to be started without sandboxing. -We can achieve that by editing our test configs. - -In `karma.conf.js`, add a custom launcher called `ChromeNoSandbox` below `browsers`: - -``` -browsers: ['Chrome'], -customLaunchers: { - ChromeNoSandbox: { - base: 'Chrome', - flags: ['--no-sandbox'] - } -}, -``` - -Create a new file in the root of your project called `protractor-ci.conf.js`, that extends -the original `protractor.conf.js`: - -``` -const config = require('./protractor.conf').config; - -config.capabilities = { - browserName: 'chrome', - chromeOptions: { - args: ['--no-sandbox'] - } -}; - -exports.config = config; -``` - -Now you can run the following commands to use the `--no-sandbox` flag: - -``` -ng test --single-run --no-progress --browser=ChromeNoSandbox -ng e2e --no-progress --config=protractor-ci.conf.js -``` - -For CI environments it's also a good idea to disable progress reporting (via `--no-progress`) -to avoid spamming the server log with progress messages. - - -## Using Circle CI - -Create a folder called `.circleci` at the project root, and inside of it create a file called -`config.yml`: - -```yaml -version: 2 -jobs: - build: - working_directory: ~/my-project - docker: - - image: circleci/node:6-browsers - steps: - - checkout - - restore_cache: - key: my-project-{{ .Branch }}-{{ checksum "package.json" }} - - run: npm install - - save_cache: - key: my-project-{{ .Branch }}-{{ checksum "package.json" }} - paths: - - "node_modules" - - run: xvfb-run -a npm run test -- --single-run --no-progress --browser=ChromeNoSandbox - - run: xvfb-run -a npm run e2e -- --no-progress --config=protractor-ci.conf.js - -``` - -We're doing a few things here: - - - - `node_modules` is cached. - - [npm run](https://docs.npmjs.com/cli/run-script) is used to run `ng` because `@angular/cli` is - not installed globally. The double dash (`--`) is needed to pass arguments into the npm script. - - `xvfb-run` is used to run `npm run` to run a command using a virtual screen, which is needed by - Chrome. - -Commit your changes and push them to your repository. - -Next you'll need to [sign up for Circle CI](https://circleci.com/docs/2.0/first-steps/) and -[add your project](https://circleci.com/add-projects). -Your project should start building. - -Be sure to check out the [Circle CI docs](https://circleci.com/docs/2.0/) if you want to know more. - - -## Using Travis CI - -Create a file called `.travis.yml` at the project root: - -```yaml -dist: trusty -sudo: false - -language: node_js -node_js: - - "6" - -cache: - directories: - - ./node_modules - -install: - - npm install - -script: - # Use Chromium instead of Chrome. - - export CHROME_BIN=chromium-browser - - xvfb-run -a npm run test -- --single-run --no-progress --browser=ChromeNoSandbox - - xvfb-run -a npm run e2e -- --no-progress --config=protractor-ci.conf.js - -``` - -Although the syntax is different, we're mostly doing the same steps as were done in the -Circle CI config. -The only difference is that Travis doesn't come with Chrome, so we use Chromium instead. - -Commit your changes and push them to your repository. - -Next you'll need to [sign up for Travis CI](https://travis-ci.org/auth) and -[add your project](https://travis-ci.org/profile). -You'll need to push a new commit to trigger a build. - -Be sure to check out the [Travis CI docs](https://docs.travis-ci.com/) if you want to know more. diff --git a/docs/documentation/stories/css-preprocessors.md b/docs/documentation/stories/css-preprocessors.md deleted file mode 100644 index b897b0893af0..000000000000 --- a/docs/documentation/stories/css-preprocessors.md +++ /dev/null @@ -1,32 +0,0 @@ -# CSS Preprocessor integration - -Angular CLI supports all major CSS preprocessors: -- sass/scss ([http://sass-lang.com/](http://sass-lang.com/)) -- less ([http://lesscss.org/](http://lesscss.org/)) -- stylus ([http://stylus-lang.com/](http://stylus-lang.com/)) - -To use these preprocessors simply add the file to your component's `styleUrls`: - -```javascript -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] -}) -export class AppComponent { - title = 'app works!'; -} -``` - -When generating a new project you can also define which extension you want for -style files: - -```bash -ng new sassy-project --style=sass -``` - -Or set the default style on an existing project: - -```bash -ng set defaults.styleExt scss -``` diff --git a/docs/documentation/stories/disk-serve.md b/docs/documentation/stories/disk-serve.md deleted file mode 100644 index b3f4663b630b..000000000000 --- a/docs/documentation/stories/disk-serve.md +++ /dev/null @@ -1,23 +0,0 @@ -# Serve from Disk - -The CLI supports running a live browser reload experience to users by running `ng serve`. This will compile the application upon file saves and reload the browser with the newly compiled application. This is done by hosting the application in memory and serving it via [webpack-dev-server](https://webpack.js.org/guides/development/#webpack-dev-server). - -If you wish to get a similar experience with the application output to disk please use the steps below. This practice will allow you to ensure that serving the contents of your `dist` dir will be closer to how your application will behave when it is deployed. - -## Environment Setup -### Install a web server -You will not be using webpack-dev-server, so you will need to install a web server for the browser to request the application. There are many to choose from but a good one to try is [lite-server](https://github.com/johnpapa/lite-server) as it will auto-reload your browser when new files are output. - -## Usage -You will need two terminals to get the live-reload experience. The first will run the build in a watch mode to compile the application to the `dist` folder. The second will run the web server against the `dist` folder. The combination of these two processes will mimic the same behavior of ng serve. - -### 1st terminal - Start the build -```bash -ng build --watch -``` - -### 2nd terminal - Start the web server -```bash -lite-server --baseDir="dist" -``` -When using `lite-server` the default browser will open to the appropriate URL. diff --git a/docs/documentation/stories/github-pages.md b/docs/documentation/stories/github-pages.md deleted file mode 100644 index cce3c10a5cb5..000000000000 --- a/docs/documentation/stories/github-pages.md +++ /dev/null @@ -1,21 +0,0 @@ -# Deploy to GitHub Pages - -A simple way to deploy your Angular app is to use -[GitHub Pages](https://help.github.com/articles/what-is-github-pages/). - -The first step is to [create a GitHub account](https://github.com/join), and then -[create a repository](https://help.github.com/articles/create-a-repo/) for your project. -Make a note of the user name and project name in GitHub. - -Then all you need to do is run `ng build --prod --output-path docs --base-href PROJECT_NAME`, where -`PROJECT_NAME` is the name of your project in GitHub. -Make a copy of `docs/index.html` and name it `docs/404.html`. - -Commit your changes and push. On the GitHub project page, configure it to -[publish from the docs folder](https://help.github.com/articles/configuring-a-publishing-source-for-github-pages/#publishing-your-github-pages-site-from-a-docs-folder-on-your-master-branch). - -And that's all you need to do! Now you can see your page at -`https://USER_NAME.github.io/PROJECT_NAME/`. - -You can also use [angular-cli-ghpages](https://github.com/angular-buch/angular-cli-ghpages), a full -featured package that does this all this for you and has extra functionality. diff --git a/docs/documentation/stories/global-lib.md b/docs/documentation/stories/global-lib.md deleted file mode 100644 index 3583b5f143d2..000000000000 --- a/docs/documentation/stories/global-lib.md +++ /dev/null @@ -1,35 +0,0 @@ -# Global Library Installation - -Some javascript libraries need to be added to the global scope, and loaded as if -they were in a script tag. We can do this using the `apps[0].scripts` and -`apps[0].styles` properties of `.angular-cli.json`. - -As an example, to use [Bootstrap 4](http://v4-alpha.getbootstrap.com/) this is -what you need to do: - -First install Bootstrap from `npm`: - -```bash -npm install bootstrap@next -``` - -Then add the needed script files to `apps[0].scripts`: - -```json -"scripts": [ - "../node_modules/jquery/dist/jquery.js", - "../node_modules/tether/dist/js/tether.js", - "../node_modules/bootstrap/dist/js/bootstrap.js" -], -``` - -Finally add the Bootstrap CSS to the `apps[0].styles` array: -```json -"styles": [ - "../node_modules/bootstrap/dist/css/bootstrap.css", - "styles.css" -], -``` - -Restart `ng serve` if you're running it, and Bootstrap 4 should be working on -your app. diff --git a/docs/documentation/stories/global-scripts.md b/docs/documentation/stories/global-scripts.md deleted file mode 100644 index fe12d7473e03..000000000000 --- a/docs/documentation/stories/global-scripts.md +++ /dev/null @@ -1,56 +0,0 @@ -# Global scripts - -You can add Javascript files to the global scope via the `apps[0].scripts` -property in `.angular-cli.json`. -These will be loaded exactly as if you had added them in a ` - - - - - %SCRIPTS% - - - - - - - diff --git a/packages/@angular/cli/plugins/karma-debug.html b/packages/@angular/cli/plugins/karma-debug.html deleted file mode 100644 index 649d59817e04..000000000000 --- a/packages/@angular/cli/plugins/karma-debug.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - -%X_UA_COMPATIBLE% - Karma DEBUG RUNNER - - - - - - - - - - - - - - %SCRIPTS% - - - - - - - diff --git a/packages/@angular/cli/plugins/karma-webpack-throw-error.ts b/packages/@angular/cli/plugins/karma-webpack-throw-error.ts deleted file mode 100644 index 601e1ec4c79f..000000000000 --- a/packages/@angular/cli/plugins/karma-webpack-throw-error.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Force Webpack to throw compilation errors. Useful with karma-webpack when in single-run mode. -// Workaround for https://github.com/webpack-contrib/karma-webpack/issues/66 - -export class KarmaWebpackThrowError { - constructor() { } - - apply(compiler: any): void { - compiler.plugin('done', (stats: any) => { - if (stats.compilation.errors.length > 0) { - throw new Error(stats.compilation.errors.map((err: any) => err.message || err)); - } - }); - } -} diff --git a/packages/@angular/cli/plugins/karma.ts b/packages/@angular/cli/plugins/karma.ts deleted file mode 100644 index 2853440cbb1d..000000000000 --- a/packages/@angular/cli/plugins/karma.ts +++ /dev/null @@ -1,276 +0,0 @@ -import * as path from 'path'; -import * as fs from 'fs'; -import * as glob from 'glob'; -import * as webpack from 'webpack'; -const webpackDevMiddleware = require('webpack-dev-middleware'); - -import { Pattern } from './glob-copy-webpack-plugin'; -import { WebpackTestConfig, WebpackTestOptions } from '../models/webpack-test-config'; -import { KarmaWebpackThrowError } from './karma-webpack-throw-error'; - -/** - * Enumerate needed (but not require/imported) dependencies from this file - * to let the dependency validator know they are used. - * - * require('source-map-support') - * require('karma-source-map-support') - */ - - -const getAppFromConfig = require('../utilities/app-utils').getAppFromConfig; - -let blocked: any[] = []; -let isBlocked = false; - -function isDirectory(path: string) { - try { - return fs.statSync(path).isDirectory(); - } catch (_) { - return false; - } -} - -// Add files to the Karma files array. -function addKarmaFiles(files: any[], newFiles: any[], prepend = false) { - const defaults = { - included: true, - served: true, - watched: true - }; - - const processedFiles = newFiles - // Remove globs that do not match any files, otherwise Karma will show a warning for these. - .filter(file => glob.sync(file.pattern, { nodir: true }).length != 0) - // Fill in pattern properties with defaults. - .map(file => ({ ...defaults, ...file })); - - // It's important to not replace the array, because - // karma already has a reference to the existing array. - if (prepend) { - files.unshift(...processedFiles); - } else { - files.push(...processedFiles); - } -} - -const init: any = (config: any, emitter: any, customFileHandlers: any) => { - const appConfig = getAppFromConfig(config.angularCli.app); - const appRoot = path.join(config.basePath, appConfig.root); - const testConfig: WebpackTestOptions = Object.assign({ - environment: 'dev', - codeCoverage: false, - sourcemaps: true, - progress: true, - }, config.angularCli); - - if (testConfig.sourcemaps) { - // Add a reporter that fixes sourcemap urls. - config.reporters.unshift('@angular/cli'); - - // Code taken from https://github.com/tschaub/karma-source-map-support. - // We can't use it directly because we need to add it conditionally in this file, and karma - // frameworks cannot be added dynamically. - const smsPath = path.dirname(require.resolve('source-map-support')); - const ksmsPath = path.dirname(require.resolve('karma-source-map-support')); - - addKarmaFiles(config.files, [ - { pattern: path.join(smsPath, 'browser-source-map-support.js'), watched: false }, - { pattern: path.join(ksmsPath, 'client.js'), watched: false } - ], true); - } - - // Add assets. This logic is mimics the one present in GlobCopyWebpackPlugin. - if (appConfig.assets) { - config.proxies = config.proxies || {}; - appConfig.assets.forEach((pattern: Pattern) => { - // Convert all string patterns to Pattern type. - pattern = typeof pattern === 'string' ? { glob: pattern } : pattern; - // Add defaults. - // Input is always resolved relative to the appRoot. - pattern.input = path.resolve(appRoot, pattern.input || ''); - pattern.output = pattern.output || ''; - pattern.glob = pattern.glob || ''; - - // Build karma file pattern. - const assetPath = path.join(pattern.input, pattern.glob); - const filePattern = isDirectory(assetPath) ? assetPath + '/**' : assetPath; - addKarmaFiles(config.files, [{ pattern: filePattern, included: false }]); - - // The `files` entry serves the file from `/base/{asset.input}/{asset.glob}`. - // We need to add a URL rewrite that exposes the asset as `/{asset.output}/{asset.glob}`. - let relativePath: string, proxyPath: string; - if (fs.existsSync(assetPath)) { - relativePath = path.relative(config.basePath, assetPath); - proxyPath = path.join(pattern.output, pattern.glob); - } else { - // For globs (paths that don't exist), proxy pattern.output to pattern.input. - relativePath = path.relative(config.basePath, pattern.input); - proxyPath = path.join(pattern.output); - - } - // Proxy paths must have only forward slashes. - proxyPath = proxyPath.replace(/\\/g, '/'); - config.proxies['/' + proxyPath] = '/base/' + relativePath; - }); - } - - // Add webpack config. - const webpackConfig = new WebpackTestConfig(testConfig, appConfig).buildConfig(); - const webpackMiddlewareConfig = { - noInfo: true, // Hide webpack output because its noisy. - watchOptions: { poll: testConfig.poll }, - publicPath: '/_karma_webpack_/', - stats: { // Also prevent chunk and module display output, cleaner look. Only emit errors. - assets: false, - colors: true, - version: false, - hash: false, - timings: false, - chunks: false, - chunkModules: false - } - }; - - // If Karma is being ran in single run mode, throw errors. - if (config.singleRun) { - webpackConfig.plugins.push(new KarmaWebpackThrowError()); - } - - // Use existing config if any. - config.webpack = Object.assign(webpackConfig, config.webpack); - config.webpackMiddleware = Object.assign(webpackMiddlewareConfig, config.webpackMiddleware); - - // Remove the @angular/cli test file if present, for backwards compatibility. - const testFilePath = path.join(appRoot, appConfig.test); - config.files.forEach((file: any, index: number) => { - if (path.normalize(file.pattern) === testFilePath) { - config.files.splice(index, 1); - } - }); - - // When using code-coverage, auto-add coverage-istanbul. - config.reporters = config.reporters || []; - if (testConfig.codeCoverage && config.reporters.indexOf('coverage-istanbul') === -1) { - config.reporters.push('coverage-istanbul'); - } - - // Our custom context and debug files list the webpack bundles directly instead of using - // the karma files array. - config.customContextFile = `${__dirname}/karma-context.html`; - config.customDebugFile = `${__dirname}/karma-debug.html`; - - // Add the request blocker. - config.beforeMiddleware = config.beforeMiddleware || []; - config.beforeMiddleware.push('angularCliBlocker'); - - // Delete global styles entry, we don't want to load them. - delete webpackConfig.entry.styles; - - // The webpack tier owns the watch behavior so we want to force it in the config. - webpackConfig.watch = true; - // Files need to be served from a custom path for Karma. - webpackConfig.output.path = '/_karma_webpack_/'; - webpackConfig.output.publicPath = '/_karma_webpack_/'; - - let compiler: any; - try { - compiler = webpack(webpackConfig); - } catch (e) { - console.error(e.stack || e); - if (e.details) { - console.error(e.details); - } - throw e; - } - - ['invalid', 'watch-run', 'run'].forEach(function (name) { - compiler.plugin(name, function (_: any, callback: () => void) { - isBlocked = true; - - if (typeof callback === 'function') { - callback(); - } - }); - }); - - compiler.plugin('done', (stats: any) => { - // Don't refresh karma when there are webpack errors. - if (stats.compilation.errors.length === 0) { - emitter.refreshFiles(); - isBlocked = false; - blocked.forEach((cb) => cb()); - blocked = []; - } - }); - - const middleware = new webpackDevMiddleware(compiler, webpackMiddlewareConfig); - - // Forward requests to webpack server. - customFileHandlers.push({ - urlRegex: /^\/_karma_webpack_\/.*/, - handler: function handler(req: any, res: any) { - middleware(req, res, function () { - // Ensure script and style bundles are served. - // They are mentioned in the custom karma context page and we don't want them to 404. - const alwaysServe = [ - '/_karma_webpack_/inline.bundle.js', - '/_karma_webpack_/polyfills.bundle.js', - '/_karma_webpack_/scripts.bundle.js', - '/_karma_webpack_/vendor.bundle.js', - ]; - if (alwaysServe.indexOf(req.url) != -1) { - res.statusCode = 200; - res.end(); - } else { - res.statusCode = 404; - res.end('Not found'); - } - }); - } - }); - - emitter.on('exit', (done: any) => { - middleware.close(); - done(); - }); -}; - -init.$inject = ['config', 'emitter', 'customFileHandlers']; - -// Dummy preprocessor, just to keep karma from showing a warning. -const preprocessor: any = () => (content: any, _file: string, done: any) => done(null, content); -preprocessor.$inject = []; - -// Block requests until the Webpack compilation is done. -function requestBlocker() { - return function (_request: any, _response: any, next: () => void) { - if (isBlocked) { - blocked.push(next); - } else { - next(); - } - }; -} - -// Strip the server address and webpack scheme (webpack://) from error log. -const initSourcemapReporter: any = function (baseReporterDecorator: any) { - baseReporterDecorator(this); - const urlRegexp = /\(http:\/\/localhost:\d+\/_karma_webpack_\/webpack:\//gi; - - this.onSpecComplete = function (_browser: any, result: any) { - if (!result.success && result.log.length > 0) { - result.log.forEach((log: string, idx: number) => { - result.log[idx] = log.replace(urlRegexp, ''); - }); - } - }; -}; - -initSourcemapReporter.$inject = ['baseReporterDecorator']; - -module.exports = Object.assign({ - 'framework:@angular/cli': ['factory', init], - 'preprocessor:@angular/cli': ['factory', preprocessor], - 'reporter:@angular/cli': ['type', initSourcemapReporter], - 'middleware:angularCliBlocker': ['factory', requestBlocker] -}); diff --git a/packages/@angular/cli/plugins/named-lazy-chunks-webpack-plugin.ts b/packages/@angular/cli/plugins/named-lazy-chunks-webpack-plugin.ts deleted file mode 100644 index 13cb9e4b0c1c..000000000000 --- a/packages/@angular/cli/plugins/named-lazy-chunks-webpack-plugin.ts +++ /dev/null @@ -1,51 +0,0 @@ -import * as webpack from 'webpack'; -import { basename } from 'path'; -const AsyncDependenciesBlock = require('webpack/lib/AsyncDependenciesBlock'); -const ContextElementDependency = require('webpack/lib/dependencies/ContextElementDependency'); -const ImportDependency = require('webpack/lib/dependencies/ImportDependency'); - -// This just extends webpack.NamedChunksPlugin to prevent name collisions. -export class NamedLazyChunksWebpackPlugin extends webpack.NamedChunksPlugin { - constructor() { - // Append a dot and number if the name already exists. - const nameMap = new Map(); - function getUniqueName(baseName: string, request: string) { - let name = baseName; - let num = 0; - while (nameMap.has(name) && nameMap.get(name) !== request) { - name = `${baseName}.${num++}`; - } - nameMap.set(name, request); - return name; - } - - const nameResolver = (chunk: any) => { - // Entry chunks have a name already, use it. - if (chunk.name) { - return chunk.name; - } - - // Try to figure out if it's a lazy loaded route or import(). - if (chunk.blocks - && chunk.blocks.length > 0 - && chunk.blocks[0] instanceof AsyncDependenciesBlock - && chunk.blocks[0].dependencies.length === 1 - && (chunk.blocks[0].dependencies[0] instanceof ContextElementDependency - || chunk.blocks[0].dependencies[0] instanceof ImportDependency) - ) { - // Create chunkname from file request, stripping ngfactory and extension. - const request = chunk.blocks[0].dependencies[0].request; - const chunkName = basename(request).replace(/(\.ngfactory)?\.(js|ts)$/, ''); - if (!chunkName || chunkName === '') { - // Bail out if something went wrong with the name. - return null; - } - return getUniqueName(chunkName, request); - } - - return null; - }; - - super(nameResolver); - } -} diff --git a/packages/@angular/cli/plugins/static-asset.ts b/packages/@angular/cli/plugins/static-asset.ts deleted file mode 100644 index 39a0fc1fe3bb..000000000000 --- a/packages/@angular/cli/plugins/static-asset.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class StaticAssetPlugin { - - constructor(private name: string, private contents: string) {} - - apply(compiler: any): void { - compiler.plugin('emit', (compilation: any, cb: Function) => { - compilation.assets[this.name] = { - size: () => this.contents.length, - source: () => this.contents, - }; - cb(); - }); - } -} diff --git a/packages/@angular/cli/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/@angular/cli/plugins/suppress-entry-chunks-webpack-plugin.ts deleted file mode 100644 index 497e73b33320..000000000000 --- a/packages/@angular/cli/plugins/suppress-entry-chunks-webpack-plugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Remove .js files from entry points consisting entirely of .css|scss|sass|less|styl. -// To be used together with ExtractTextPlugin. - -export class SuppressExtractedTextChunksWebpackPlugin { - constructor() { } - - apply(compiler: any): void { - compiler.plugin('compilation', function (compilation: any) { - // find which chunks have css only entry points - const cssOnlyChunks: string[] = []; - const entryPoints = compilation.options.entry; - // determine which entry points are composed entirely of css files - for (let entryPoint of Object.keys(entryPoints)) { - let entryFiles: string[]|string = entryPoints[entryPoint]; - // when type of entryFiles is not array, make it as an array - entryFiles = entryFiles instanceof Array ? entryFiles : [entryFiles]; - if (entryFiles.every((el: string) => - el.match(/\.(css|scss|sass|less|styl)$/) !== null)) { - cssOnlyChunks.push(entryPoint); - } - } - // Remove the js file for supressed chunks - compilation.plugin('after-seal', (callback: any) => { - compilation.chunks - .filter((chunk: any) => cssOnlyChunks.indexOf(chunk.name) !== -1) - .forEach((chunk: any) => { - let newFiles: string[] = []; - chunk.files.forEach((file: string) => { - if (file.match(/\.js(\.map)?$/)) { - // remove js files - delete compilation.assets[file]; - } else { - newFiles.push(file); - } - }); - chunk.files = newFiles; - }); - callback(); - }); - // Remove scripts tags with a css file as source, because HtmlWebpackPlugin will use - // a css file as a script for chunks without js files. - compilation.plugin('html-webpack-plugin-alter-asset-tags', - (htmlPluginData: any, callback: any) => { - const filterFn = (tag: any) => - !(tag.tagName === 'script' && tag.attributes.src.match(/\.css$/)); - htmlPluginData.head = htmlPluginData.head.filter(filterFn); - htmlPluginData.body = htmlPluginData.body.filter(filterFn); - callback(null, htmlPluginData); - }); - }); - } -} diff --git a/packages/@angular/cli/plugins/webpack.js b/packages/@angular/cli/plugins/webpack.js deleted file mode 100644 index 6f15043e2c4b..000000000000 --- a/packages/@angular/cli/plugins/webpack.js +++ /dev/null @@ -1,14 +0,0 @@ -// Exports the webpack plugins we use internally. - -module.exports = { - BaseHrefWebpackPlugin: - require('../lib/base-href-webpack/base-href-webpack-plugin').BaseHrefWebpackPlugin, - GlobCopyWebpackPlugin: require('../plugins/glob-copy-webpack-plugin').GlobCopyWebpackPlugin, - SuppressExtractedTextChunksWebpackPlugin: - require('../plugins/suppress-entry-chunks-webpack-plugin') - .SuppressExtractedTextChunksWebpackPlugin, - NamedLazyChunksWebpackPlugin: - require('../plugins/named-lazy-chunks-webpack-plugin').NamedLazyChunksWebpackPlugin, - InsertConcatAssetsWebpackPlugin: - require('../plugins/insert-concat-assets-webpack-plugin').InsertConcatAssetsWebpackPlugin -}; diff --git a/packages/@angular/cli/tasks/build.ts b/packages/@angular/cli/tasks/build.ts deleted file mode 100644 index 1011d5574d7a..000000000000 --- a/packages/@angular/cli/tasks/build.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as fs from 'fs-extra'; -import * as path from 'path'; -import * as webpack from 'webpack'; - -import { getAppFromConfig } from '../utilities/app-utils'; -import { BuildTaskOptions } from '../commands/build'; -import { NgCliWebpackConfig } from '../models/webpack-config'; -import { getWebpackStatsConfig } from '../models/webpack-configs/utils'; -import { CliConfig } from '../models/config'; -import { statsToString, statsWarningsToString, statsErrorsToString } from '../utilities/stats'; - -const Task = require('../ember-cli/lib/models/task'); -const SilentError = require('silent-error'); - - -export default Task.extend({ - run: function (runTaskOptions: BuildTaskOptions) { - const config = CliConfig.fromProject().config; - - const app = getAppFromConfig(runTaskOptions.app); - - const outputPath = runTaskOptions.outputPath || app.outDir; - if (this.project.root === path.resolve(outputPath)) { - throw new SilentError('Output path MUST not be project root directory!'); - } - if (config.project && config.project.ejected) { - throw new SilentError('An ejected project cannot use the build command anymore.'); - } - if (runTaskOptions.deleteOutputPath) { - fs.removeSync(path.resolve(this.project.root, outputPath)); - } - - const webpackConfig = new NgCliWebpackConfig(runTaskOptions, app).buildConfig(); - const webpackCompiler = webpack(webpackConfig); - const statsConfig = getWebpackStatsConfig(runTaskOptions.verbose); - - return new Promise((resolve, reject) => { - const callback: webpack.compiler.CompilerCallback = (err, stats) => { - if (err) { - return reject(err); - } - - const json = stats.toJson('verbose'); - if (runTaskOptions.verbose) { - this.ui.writeLine(stats.toString(statsConfig)); - } else { - this.ui.writeLine(statsToString(json, statsConfig)); - } - - if (stats.hasWarnings()) { - this.ui.writeLine(statsWarningsToString(json, statsConfig)); - } - if (stats.hasErrors()) { - this.ui.writeError(statsErrorsToString(json, statsConfig)); - } - - if (runTaskOptions.watch) { - return; - } else if (runTaskOptions.statsJson) { - fs.writeFileSync( - path.resolve(this.project.root, outputPath, 'stats.json'), - JSON.stringify(stats.toJson(), null, 2) - ); - } - - if (stats.hasErrors()) { - reject(); - } else { - resolve(); - } - }; - - if (runTaskOptions.watch) { - webpackCompiler.watch({ poll: runTaskOptions.poll }, callback); - } else { - webpackCompiler.run(callback); - } - }) - .catch((err: Error) => { - if (err) { - this.ui.writeError('\nAn error occured during the build:\n' + ((err && err.stack) || err)); - } - throw err; - }); - } -}); diff --git a/packages/@angular/cli/tasks/doc.ts b/packages/@angular/cli/tasks/doc.ts deleted file mode 100644 index 1ce1346e84fa..000000000000 --- a/packages/@angular/cli/tasks/doc.ts +++ /dev/null @@ -1,11 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -const opn = require('opn'); - -export const DocTask: any = Task.extend({ - run: function(keyword: string, search: boolean) { - const searchUrl = search ? `https://www.google.com/search?q=site%3Aangular.io+${keyword}` : - `https://angular.io/api?query=${keyword}`; - - return opn(searchUrl, { wait: false }); - } -}); diff --git a/packages/@angular/cli/tasks/e2e.ts b/packages/@angular/cli/tasks/e2e.ts deleted file mode 100644 index 40228aafeb83..000000000000 --- a/packages/@angular/cli/tasks/e2e.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as url from 'url'; -import { stripIndents } from 'common-tags'; - -import { E2eTaskOptions } from '../commands/e2e'; -import { CliConfig } from '../models/config'; -import { requireProjectModule } from '../utilities/require-project-module'; -import { getAppFromConfig } from '../utilities/app-utils'; - -const Task = require('../ember-cli/lib/models/task'); -const SilentError = require('silent-error'); - - -export const E2eTask = Task.extend({ - run: function (e2eTaskOptions: E2eTaskOptions) { - const projectConfig = CliConfig.fromProject().config; - const projectRoot = this.project.root; - const protractorLauncher = requireProjectModule(projectRoot, 'protractor/built/launcher'); - const appConfig = getAppFromConfig(e2eTaskOptions.app); - - if (projectConfig.project && projectConfig.project.ejected) { - throw new SilentError('An ejected project cannot use the build command anymore.'); - } - if (appConfig.platform === 'server') { - throw new SilentError('ng test for platform server applications is coming soon!'); - } - - return new Promise(function () { - let promise = Promise.resolve(); - let additionalProtractorConfig: any = { - elementExplorer: e2eTaskOptions.elementExplorer - }; - - // use serve url as override for protractors baseUrl - if (e2eTaskOptions.serve && e2eTaskOptions.publicHost) { - let publicHost = e2eTaskOptions.publicHost; - if (!/^\w+:\/\//.test(publicHost)) { - publicHost = `${e2eTaskOptions.ssl ? 'https' : 'http'}://${publicHost}`; - } - const clientUrl = url.parse(publicHost); - e2eTaskOptions.publicHost = clientUrl.host; - additionalProtractorConfig.baseUrl = url.format(clientUrl); - } else if (e2eTaskOptions.serve) { - additionalProtractorConfig.baseUrl = url.format({ - protocol: e2eTaskOptions.ssl ? 'https' : 'http', - hostname: e2eTaskOptions.host, - port: e2eTaskOptions.port.toString() - }); - } else if (e2eTaskOptions.baseHref) { - additionalProtractorConfig.baseUrl = e2eTaskOptions.baseHref; - } else if (e2eTaskOptions.port) { - additionalProtractorConfig.baseUrl = url.format({ - protocol: e2eTaskOptions.ssl ? 'https' : 'http', - hostname: e2eTaskOptions.host, - port: e2eTaskOptions.port.toString() - }); - } - - if (e2eTaskOptions.specs.length !== 0) { - additionalProtractorConfig['specs'] = e2eTaskOptions.specs; - } - - if (e2eTaskOptions.webdriverUpdate) { - // The webdriver-manager update command can only be accessed via a deep import. - const webdriverDeepImport = 'webdriver-manager/built/lib/cmds/update'; - let webdriverUpdate: any; - - try { - // When using npm, webdriver is within protractor/node_modules. - webdriverUpdate = requireProjectModule(projectRoot, - `protractor/node_modules/${webdriverDeepImport}`); - } catch (e) { - try { - // When using yarn, webdriver is found as a root module. - webdriverUpdate = requireProjectModule(projectRoot, webdriverDeepImport); - } catch (e) { - throw new SilentError(stripIndents` - Cannot automatically find webdriver-manager to update. - Update webdriver-manager manually and run 'ng e2e --no-webdriver-update' instead. - `); - } - } - // run `webdriver-manager update --standalone false --gecko false --quiet` - // if you change this, update the command comment in prev line, and in `eject` task - promise = promise.then(() => webdriverUpdate.program.run({ - standalone: false, - gecko: false, - quiet: true - })); - } - - // Don't call resolve(), protractor will manage exiting the process itself - return promise.then(() => - protractorLauncher.init(e2eTaskOptions.config, additionalProtractorConfig)); - }); - } -}); diff --git a/packages/@angular/cli/tasks/eject.ts b/packages/@angular/cli/tasks/eject.ts deleted file mode 100644 index baf67aa6661d..000000000000 --- a/packages/@angular/cli/tasks/eject.ts +++ /dev/null @@ -1,583 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; -import * as webpack from 'webpack'; - -import { getAppFromConfig } from '../utilities/app-utils'; -import { EjectTaskOptions } from '../commands/eject'; -import { NgCliWebpackConfig } from '../models/webpack-config'; -import { CliConfig } from '../models/config'; -import { AotPlugin } from '@ngtools/webpack'; -import { yellow } from 'chalk'; -import { LicenseWebpackPlugin } from 'license-webpack-plugin'; - -import denodeify = require('denodeify'); -import {oneLine, stripIndent} from 'common-tags'; - -const exists = (p: string) => Promise.resolve(fs.existsSync(p)); -const writeFile = (denodeify(fs.writeFile) as (...args: any[]) => Promise); -const angularCliPlugins = require('../plugins/webpack'); - - -const ExtractTextPlugin = require('extract-text-webpack-plugin'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const SilentError = require('silent-error'); -const CircularDependencyPlugin = require('circular-dependency-plugin'); -const ConcatPlugin = require('webpack-concat-plugin'); -const Task = require('../ember-cli/lib/models/task'); - -const ProgressPlugin = require('webpack/lib/ProgressPlugin'); - - -export const pluginArgs = Symbol('plugin-args'); -export const postcssArgs = Symbol('postcss-args'); - -const pree2eNpmScript = `webdriver-manager update --standalone false --gecko false --quiet`; - - -class JsonWebpackSerializer { - public imports: {[name: string]: string[]} = {}; - public variableImports: {[name: string]: string} = { - 'fs': 'fs', - 'path': 'path', - }; - public variables: {[name: string]: string} = { - 'nodeModules': `path.join(process.cwd(), 'node_modules')`, - 'realNodeModules': `fs.realpathSync(nodeModules)`, - 'genDirNodeModules': - `path.join(process.cwd(), '${this._appRoot}', '$$_gendir', 'node_modules')`, - }; - private _postcssProcessed = false; - - - constructor(private _root: string, private _dist: string, private _appRoot: string) {} - - private _escape(str: string) { - return '\uFF01' + str + '\uFF01'; - } - - private _serializeRegExp(re: RegExp) { - return this._escape(re.toString()); - } - - private _serializeFunction(fn: Function) { - return this._escape(fn.toString()); - } - - private _relativePath(of: string, to: string) { - return this._escape(`path.join(${of}, ${JSON.stringify(to)})`); - } - - private _addImport(module: string, importName: string) { - if (!this.imports[module]) { - this.imports[module] = []; - } - if (this.imports[module].indexOf(importName) == -1) { - this.imports[module].push(importName); - } - } - - private _globCopyWebpackPluginSerialize(value: any): any { - let patterns = value.options.patterns; - let globOptions = value.options.globOptions; - return { - patterns, - globOptions: this._globReplacer(globOptions) - }; - } - - private _insertConcatAssetsWebpackPluginSerialize(value: any): any { - return value.entryNames; - } - - private _commonsChunkPluginSerialize(value: any): any { - let minChunks = value.minChunks; - switch (typeof minChunks) { - case 'function': - minChunks = this._serializeFunction(value.minChunks); - break; - } - - return { - name: value.chunkNames, - filename: value.filenameTemplate, - minChunks, - chunks: value.selectedChunks, - async: value.async, - minSize: value.minSize - }; - } - - private _extractTextPluginSerialize(value: any): any { - return { - filename: value.filename, - disable: value.options.disable - }; - } - - private _aotPluginSerialize(value: AotPlugin): any { - const tsConfigPath = path.relative(this._root, value.options.tsConfigPath); - const basePath = path.dirname(tsConfigPath); - return Object.assign({}, value.options, { - tsConfigPath, - mainPath: path.relative(value.basePath, value.options.mainPath), - hostReplacementPaths: Object.keys(value.options.hostReplacementPaths) - .reduce((acc: any, key: string) => { - const replacementPath = value.options.hostReplacementPaths[key]; - key = path.relative(basePath, key); - acc[key] = path.relative(basePath, replacementPath); - return acc; - }, {}), - exclude: Array.isArray(value.options.exclude) - ? value.options.exclude.map((p: any) => { - return p.startsWith('/') ? path.relative(value.basePath, p) : p; - }) - : value.options.exclude - }); - } - - private _htmlWebpackPlugin(value: any) { - const chunksSortMode = value.options.chunksSortMode; - this.variables['entryPoints'] = JSON.stringify(chunksSortMode.entryPoints); - return Object.assign({}, value.options, { - template: './' + path.relative(this._root, value.options.template), - filename: './' + path.relative(this._dist, value.options.filename), - chunksSortMode: this._serializeFunction(chunksSortMode) - }); - } - - _environmentPlugin(plugin: any) { - return plugin.defaultValues; - } - - private _licenseWebpackPlugin(plugin: any) { - return plugin.options; - } - - private _concatPlugin(plugin: any) { - return plugin.settings; - } - - private _pluginsReplacer(plugins: any[]) { - return plugins.map(plugin => { - let args = plugin.options || undefined; - - switch (plugin.constructor) { - case ProgressPlugin: - this.variableImports['webpack/lib/ProgressPlugin'] = 'ProgressPlugin'; - break; - case webpack.NoEmitOnErrorsPlugin: - this._addImport('webpack', 'NoEmitOnErrorsPlugin'); - break; - case webpack.NamedModulesPlugin: - this._addImport('webpack', 'NamedModulesPlugin'); - break; - case (webpack).HashedModuleIdsPlugin: - this._addImport('webpack', 'HashedModuleIdsPlugin'); - break; - case webpack.SourceMapDevToolPlugin: - this._addImport('webpack', 'SourceMapDevToolPlugin'); - break; - case webpack.optimize.UglifyJsPlugin: - this._addImport('webpack.optimize', 'UglifyJsPlugin'); - break; - case (webpack.optimize as any).ModuleConcatenationPlugin: - this._addImport('webpack.optimize', 'ModuleConcatenationPlugin'); - break; - case angularCliPlugins.BaseHrefWebpackPlugin: - case angularCliPlugins.NamedLazyChunksWebpackPlugin: - case angularCliPlugins.SuppressExtractedTextChunksWebpackPlugin: - this._addImport('@angular/cli/plugins/webpack', plugin.constructor.name); - break; - case angularCliPlugins.GlobCopyWebpackPlugin: - args = this._globCopyWebpackPluginSerialize(plugin); - this._addImport('@angular/cli/plugins/webpack', 'GlobCopyWebpackPlugin'); - break; - case angularCliPlugins.InsertConcatAssetsWebpackPlugin: - args = this._insertConcatAssetsWebpackPluginSerialize(plugin); - this._addImport('@angular/cli/plugins/webpack', 'InsertConcatAssetsWebpackPlugin'); - break; - case webpack.optimize.CommonsChunkPlugin: - args = this._commonsChunkPluginSerialize(plugin); - this._addImport('webpack.optimize', 'CommonsChunkPlugin'); - break; - case ExtractTextPlugin: - args = this._extractTextPluginSerialize(plugin); - this.variableImports['extract-text-webpack-plugin'] = 'ExtractTextPlugin'; - break; - case CircularDependencyPlugin: - this.variableImports['circular-dependency-plugin'] = 'CircularDependencyPlugin'; - break; - case AotPlugin: - args = this._aotPluginSerialize(plugin); - this._addImport('@ngtools/webpack', 'AotPlugin'); - break; - case HtmlWebpackPlugin: - args = this._htmlWebpackPlugin(plugin); - this.variableImports['html-webpack-plugin'] = 'HtmlWebpackPlugin'; - break; - case webpack.EnvironmentPlugin: - args = this._environmentPlugin(plugin); - this._addImport('webpack', 'EnvironmentPlugin'); - break; - case LicenseWebpackPlugin: - args = this._licenseWebpackPlugin(plugin); - this._addImport('license-webpack-plugin', 'LicenseWebpackPlugin'); - break; - case ConcatPlugin: - args = this._concatPlugin(plugin); - this.variableImports['webpack-concat-plugin'] = 'ConcatPlugin'; - break; - default: - if (plugin.constructor.name == 'AngularServiceWorkerPlugin') { - this._addImport('@angular/service-worker/build/webpack', plugin.constructor.name); - } - break; - } - - const argsSerialized = JSON.stringify(args, (k, v) => this._replacer(k, v), 2) || ''; - return `\uFF02${plugin.constructor.name}(${argsSerialized})\uFF02`; - }); - } - - private _resolveReplacer(value: any) { - return Object.assign({}, value, { - modules: value.modules.map((x: string) => './' + path.relative(this._root, x)) - }); - } - - private _outputReplacer(value: any) { - return Object.assign({}, value, { - path: this._relativePath('process.cwd()', path.relative(this._root, value.path)) - }); - } - - private _path(l: string) { - return l.split('!').map(x => { - return path.isAbsolute(x) ? './' + path.relative(this._root, x) : x; - }).join('!'); - } - - private _entryReplacer(value: any) { - const newValue = Object.assign({}, value); - for (const key of Object.keys(newValue)) { - newValue[key] = newValue[key].map((l: string) => this._path(l)); - } - return newValue; - } - - private _loaderReplacer(loader: any) { - if (typeof loader == 'string') { - if (loader.match(/\/node_modules\/extract-text-webpack-plugin\//)) { - return 'extract-text-webpack-plugin'; - } else if (loader.match(/@ngtools\/webpack\/src\/index.ts/)) { - // return '@ngtools/webpack'; - } - } else { - if (loader.loader) { - loader.loader = this._loaderReplacer(loader.loader); - } - if (loader.loader === 'postcss-loader' && !this._postcssProcessed) { - const args: any = loader.options.plugins[postcssArgs]; - - Object.keys(args.variableImports) - .forEach(key => this.variableImports[key] = args.variableImports[key]); - Object.keys(args.variables) - .forEach(key => this.variables[key] = JSON.stringify(args.variables[key])); - - this.variables['postcssPlugins'] = loader.options.plugins; - loader.options.plugins = this._escape('postcssPlugins'); - - this._postcssProcessed = true; - } - } - return loader; - } - - private _ruleReplacer(value: any) { - const replaceExcludeInclude = (v: any) => { - if (typeof v == 'object') { - if (v.constructor == RegExp) { - return this._serializeRegExp(v); - } - return v; - } else if (typeof v == 'string') { - if (v === path.join(this._root, 'node_modules')) { - return this._serializeRegExp(/(\\|\/)node_modules(\\|\/)/); - } - return this._relativePath('process.cwd()', path.relative(this._root, v)); - } else { - return v; - } - }; - - if (value[pluginArgs]) { - return { - include: Array.isArray(value.include) - ? value.include.map((x: any) => replaceExcludeInclude(x)) - : replaceExcludeInclude(value.include), - test: this._serializeRegExp(value.test), - loaders: this._escape( - `ExtractTextPlugin.extract(${JSON.stringify(value[pluginArgs], null, 2)})`) - }; - } - - if (value.loaders) { - value.loaders = value.loaders.map((loader: any) => this._loaderReplacer(loader)); - } - if (value.loader) { - value.loader = this._loaderReplacer(value.loader); - } - if (value.use) { - if (Array.isArray(value.use)) { - value.use = value.use.map((loader: any) => this._loaderReplacer(loader)); - } else { - value.use = this._loaderReplacer(value.loader); - } - } - - if (value.exclude) { - value.exclude = Array.isArray(value.exclude) - ? value.exclude.map((x: any) => replaceExcludeInclude(x)) - : replaceExcludeInclude(value.exclude); - } - if (value.include) { - value.include = Array.isArray(value.include) - ? value.include.map((x: any) => replaceExcludeInclude(x)) - : replaceExcludeInclude(value.include); - } - - return value; - } - - private _moduleReplacer(value: any) { - return Object.assign({}, value, { - rules: value.rules && value.rules.map((x: any) => this._ruleReplacer(x)) - }); - } - - private _globReplacer(value: any) { - return Object.assign({}, value, { - cwd: this._relativePath('process.cwd()', path.relative(this._root, value.cwd)) - }); - } - - private _replacer(_key: string, value: any) { - if (value === undefined) { - return value; - } - if (value === null) { - return null; - } - if (value.constructor === RegExp) { - return this._serializeRegExp(value); - } - - return value; - } - - serialize(config: any): string { - // config = Object.assign({}, config); - config['plugins'] = this._pluginsReplacer(config['plugins']); - // Routes using PathLocationStrategy break without this. - config['devServer'] = { - 'historyApiFallback': true - }; - config['resolve'] = this._resolveReplacer(config['resolve']); - config['resolveLoader'] = this._resolveReplacer(config['resolveLoader']); - config['entry'] = this._entryReplacer(config['entry']); - config['output'] = this._outputReplacer(config['output']); - config['module'] = this._moduleReplacer(config['module']); - config['context'] = undefined; - - return JSON.stringify(config, (k, v) => this._replacer(k, v), 2) - .replace(/"\uFF01(.*?)\uFF01"/g, (_, v) => { - return JSON.parse(`"${v}"`); - }) - .replace(/(\s*)(.*?)"\uFF02(.*?)\uFF02"(,?).*/g, (_, indent, key, value, comma) => { - const ctor = JSON.parse(`"${value}"`).split(/\n+/g).join(indent); - return `${indent}${key}new ${ctor}${comma}`; - }) - .replace(/"\uFF01(.*?)\uFF01"/g, (_, v) => { - return JSON.parse(`"${v}"`); - }); - } - - generateVariables(): string { - let variableOutput = ''; - Object.keys(this.variableImports) - .forEach((key: string) => { - const [module, name] = key.split(/\./); - variableOutput += `const ${this.variableImports[key]} = require` + `('${module}')`; - if (name) { - variableOutput += '.' + name; - } - variableOutput += ';\n'; - }); - variableOutput += '\n'; - - Object.keys(this.imports) - .forEach((key: string) => { - const [module, name] = key.split(/\./); - variableOutput += `const { ${this.imports[key].join(', ')} } = require` + `('${module}')`; - if (name) { - variableOutput += '.' + name; - } - variableOutput += ';\n'; - }); - variableOutput += '\n'; - - Object.keys(this.variables) - .forEach((key: string) => { - variableOutput += `const ${key} = ${this.variables[key]};\n`; - }); - variableOutput += '\n\n'; - - return variableOutput; - } -} - - -export default Task.extend({ - run: function (runTaskOptions: EjectTaskOptions) { - const project = this.project; - const cliConfig = CliConfig.fromProject(); - const config = cliConfig.config; - const appConfig = getAppFromConfig(runTaskOptions.app); - - const tsConfigPath = path.join(process.cwd(), appConfig.root, appConfig.tsconfig); - const outputPath = runTaskOptions.outputPath || appConfig.outDir; - const force = runTaskOptions.force; - - if (project.root === path.resolve(outputPath)) { - throw new SilentError ('Output path MUST not be project root directory!'); - } - if (appConfig.platform === 'server') { - throw new SilentError('ng eject for platform server applications is coming soon!'); - } - - const webpackConfig = new NgCliWebpackConfig(runTaskOptions, appConfig).buildConfig(); - const serializer = new JsonWebpackSerializer(process.cwd(), outputPath, appConfig.root); - const output = serializer.serialize(webpackConfig); - const webpackConfigStr = `${serializer.generateVariables()}\n\nmodule.exports = ${output};\n`; - - return Promise.resolve() - .then(() => exists('webpack.config.js')) - .then(webpackConfigExists => { - if (webpackConfigExists && !force) { - throw new SilentError('The webpack.config.js file already exists.'); - } - }) - // Read the package.json and update it to include npm scripts. We do this first so that if - // an error already exists - .then(() => ts.sys.readFile('package.json')) - .then((packageJson: string) => JSON.parse(packageJson)) - .then((packageJson: any) => { - const scripts = packageJson['scripts']; - if (scripts['build'] && scripts['build'] !== 'ng build' && !force) { - throw new SilentError(oneLine` - Your package.json scripts must not contain a build script as it will be overwritten. - `); - } - if (scripts['start'] && scripts['start'] !== 'ng serve' && !force) { - throw new SilentError(oneLine` - Your package.json scripts must not contain a start script as it will be overwritten. - `); - } - if (scripts['pree2e'] && scripts['pree2e'] !== pree2eNpmScript && !force) { - throw new SilentError(oneLine` - Your package.json scripts must not contain a pree2e script as it will be - overwritten. - `); - } - if (scripts['e2e'] && scripts['e2e'] !== 'ng e2e' && !force) { - throw new SilentError(oneLine` - Your package.json scripts must not contain a e2e script as it will be overwritten. - `); - } - if (scripts['test'] && scripts['test'] !== 'ng test' && !force) { - throw new SilentError(oneLine` - Your package.json scripts must not contain a test script as it will be overwritten. - `); - } - - packageJson['scripts']['build'] = 'webpack'; - packageJson['scripts']['start'] = 'webpack-dev-server --port=4200'; - packageJson['scripts']['test'] = 'karma start ./karma.conf.js'; - packageJson['scripts']['pree2e'] = pree2eNpmScript; - packageJson['scripts']['e2e'] = 'protractor ./protractor.conf.js'; - - // Add new dependencies based on our dependencies. - const ourPackageJson = require('../package.json'); - if (!packageJson['devDependencies']) { - packageJson['devDependencies'] = {}; - } - packageJson['devDependencies']['webpack-dev-server'] - = ourPackageJson['dependencies']['webpack-dev-server']; - - // Update all loaders from webpack, plus postcss plugins. - [ - 'webpack', - 'autoprefixer', - 'css-loader', - 'cssnano', - 'exports-loader', - 'file-loader', - 'html-webpack-plugin', - 'json-loader', - 'karma-sourcemap-loader', - 'less-loader', - 'postcss-loader', - 'postcss-url', - 'raw-loader', - 'sass-loader', - 'source-map-loader', - 'istanbul-instrumenter-loader', - 'style-loader', - 'stylus-loader', - 'url-loader', - 'circular-dependency-plugin', - 'webpack-concat-plugin', - ].forEach((packageName: string) => { - packageJson['devDependencies'][packageName] = ourPackageJson['dependencies'][packageName]; - }); - - return writeFile('package.json', JSON.stringify(packageJson, null, 2) + '\n'); - }) - .then(() => JSON.parse(ts.sys.readFile(tsConfigPath))) - .then((tsConfigJson: any) => { - if (!tsConfigJson.exclude || force) { - // Make sure we now include tests. Do not touch otherwise. - tsConfigJson.exclude = [ - 'test.ts', - '**/*.spec.ts' - ]; - } - return writeFile(tsConfigPath, JSON.stringify(tsConfigJson, null, 2) + '\n'); - }) - // Output the webpack.config.js. - .then(() => writeFile('webpack.config.js', webpackConfigStr)) - .then(() => { - // Update the CLI Config. - config.project.ejected = true; - cliConfig.save(); - }) - .then(() => { - console.log(yellow(stripIndent` - ========================================================================================== - Ejection was successful. - - To run your builds, you now need to do the following commands: - - "npm run build" to build. - - "npm test" to run unit tests. - - "npm start" to serve the app using webpack-dev-server. - - "npm run e2e" to run protractor. - - Running the equivalent CLI commands will result in an error. - - ========================================================================================== - Some packages were added. Please run "npm install". - `)); - }); - } -}); diff --git a/packages/@angular/cli/tasks/extract-i18n.ts b/packages/@angular/cli/tasks/extract-i18n.ts deleted file mode 100644 index 8d2a31dfaaba..000000000000 --- a/packages/@angular/cli/tasks/extract-i18n.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as webpack from 'webpack'; -import { XI18nWebpackConfig } from '../models/webpack-xi18n-config'; -import { getAppFromConfig } from '../utilities/app-utils'; - -const Task = require('../ember-cli/lib/models/task'); -const MemoryFS = require('memory-fs'); - - -export const Extracti18nTask = Task.extend({ - run: function (runTaskOptions: any) { - const appConfig = getAppFromConfig(runTaskOptions.app); - - const config = new XI18nWebpackConfig({ - genDir: runTaskOptions.outputPath || appConfig.root, - buildDir: '.tmp', - i18nFormat: runTaskOptions.i18nFormat, - locale: runTaskOptions.locale, - outFile: runTaskOptions.outFile, - verbose: runTaskOptions.verbose, - progress: runTaskOptions.progress, - app: runTaskOptions.app, - }, appConfig).buildConfig(); - - const webpackCompiler = webpack(config); - webpackCompiler.outputFileSystem = new MemoryFS(); - - return new Promise((resolve, reject) => { - const callback: webpack.compiler.CompilerCallback = (err, stats) => { - if (err) { - return reject(err); - } - - if (stats.hasErrors()) { - reject(); - } else { - resolve(); - } - }; - - webpackCompiler.run(callback); - }) - .catch((err: Error) => { - if (err) { - this.ui.writeError('\nAn error occured during the i18n extraction:\n' - + ((err && err.stack) || err)); - } - }); - } -}); diff --git a/packages/@angular/cli/tasks/git-init.ts b/packages/@angular/cli/tasks/git-init.ts deleted file mode 100644 index 35236976423c..000000000000 --- a/packages/@angular/cli/tasks/git-init.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { oneLine } from 'common-tags'; -import denodeify = require('denodeify'); - -const exec: any = denodeify(require('child_process').exec); -const path = require('path'); -const pkg = require('../package.json'); -const fs = require('fs'); -const template = require('lodash/template'); -const Task = require('../ember-cli/lib/models/task'); - -const gitEnvironmentVariables = { - GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || 'Angular CLI', - GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'angular-cli@angular.io', - get GIT_COMMITTER_NAME() { - return this.GIT_AUTHOR_NAME; - }, - get GIT_COMMITTER_EMAIL() { - return this.GIT_AUTHOR_EMAIL; - } -}; - -module.exports = Task.extend({ - run: function (commandOptions: any) { - const chalk = require('chalk'); - const ui = this.ui; - - if (commandOptions.skipGit) { - return Promise.resolve(); - } - - return exec('git --version') - .then(function () { - // check if we're inside a git repo - return exec('git rev-parse --is-inside-work-tree') - .then(function () { - return true; - }) - .catch(function() { - return false; - }); - }) - .then(function (insideGitRepo: boolean) { - if (insideGitRepo) { - ui.writeLine(oneLine` - Directory is already under version control. - Skipping initialization of git.`); - return; - } - return exec('git init') - .then(function () { - return exec('git add .'); - }) - .then(function () { - if (!commandOptions.skipCommit) { - const commitTemplate = fs.readFileSync( - path.join(__dirname, '../utilities/INITIAL_COMMIT_MESSAGE.txt')); - const commitMessage = template(commitTemplate)(pkg); - return exec( - 'git commit -m "' + commitMessage + '"', { env: gitEnvironmentVariables }); - } - }) - .then(function () { - ui.writeLine(chalk.green('Successfully initialized git.')); - }); - }) - .catch(function (/*error*/) { - // if git is not found or an error was thrown during the `git` - // init process just swallow any errors here - }); - } -}); - -module.exports.overrideCore = true; diff --git a/packages/@angular/cli/tasks/init.ts b/packages/@angular/cli/tasks/init.ts deleted file mode 100644 index dd36103417c0..000000000000 --- a/packages/@angular/cli/tasks/init.ts +++ /dev/null @@ -1,112 +0,0 @@ -import * as chalk from 'chalk'; -import LinkCli from '../tasks/link-cli'; -import NpmInstall from '../tasks/npm-install'; -import { validateProjectName } from '../utilities/validate-project-name'; -import {checkYarnOrCNPM} from '../utilities/check-package-manager'; -import {CliConfig} from '../models/config'; - -const Task = require('../ember-cli/lib/models/task'); -const SilentError = require('silent-error'); -const GitInit = require('../tasks/git-init'); -const packageJson = require('../package.json'); - - -export default Task.extend({ - - run: function (commandOptions: any, rawArgs: string[]) { - if (commandOptions.dryRun) { - commandOptions.skipInstall = true; - } - - // needs an explicit check in case it's just 'undefined' - // due to passing of options from 'new' and 'addon' - let gitInit: any; - if (commandOptions.skipGit === false) { - gitInit = new GitInit({ - ui: this.ui, - project: this.project - }); - } - - const packageManager = CliConfig.fromGlobal().get('packageManager'); - - let npmInstall: any; - if (!commandOptions.skipInstall) { - npmInstall = new NpmInstall({ - ui: this.ui, - project: this.project, - packageManager - }); - } - - let linkCli: any; - if (commandOptions.linkCli) { - linkCli = new LinkCli({ - ui: this.ui, - project: this.project, - packageManager - }); - } - - const project = this.project; - const packageName = commandOptions.name !== '.' && commandOptions.name || project.name(); - - if (commandOptions.style === undefined) { - commandOptions.style = CliConfig.fromGlobal().get('defaults.styleExt'); - } - - if (!packageName) { - const message = 'The `ng ' + this.name + '` command requires a ' + - 'package.json in current folder with name attribute or a specified name via arguments. ' + - 'For more details, use `ng help`.'; - - return Promise.reject(new SilentError(message)); - } - - validateProjectName(packageName); - - const SchematicRunTask = require('../tasks/schematic-run').default; - const schematicRunTask = new SchematicRunTask({ - ui: this.ui, - project: this.project - }); - - const cwd = this.project.root; - const schematicName = CliConfig.fromGlobal().get('defaults.schematics.newApp'); - commandOptions.version = packageJson.version; - - const runOptions = { - taskOptions: commandOptions, - workingDir: cwd, - collectionName: commandOptions.collectionName, - schematicName - }; - - return schematicRunTask.run(runOptions) - .then(function () { - if (!commandOptions.dryRun) { - process.chdir(commandOptions.directory); - } - }) - .then(function () { - if (!commandOptions.skipInstall) { - return checkYarnOrCNPM().then(() => npmInstall.run()); - } - }) - .then(function () { - if (!commandOptions.dryRun && commandOptions.skipGit === false) { - return gitInit.run(commandOptions, rawArgs); - } - }) - .then(function () { - if (!commandOptions.dryRun && commandOptions.linkCli) { - return linkCli.run(); - } - }) - .then(() => { - if (!commandOptions.dryRun) { - this.ui.writeLine(chalk.green(`Project '${packageName}' successfully created.`)); - } - }); - } -}); diff --git a/packages/@angular/cli/tasks/link-cli.ts b/packages/@angular/cli/tasks/link-cli.ts deleted file mode 100644 index 55a0d116782b..000000000000 --- a/packages/@angular/cli/tasks/link-cli.ts +++ /dev/null @@ -1,26 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import {exec} from 'child_process'; - -export default Task.extend({ - run: function() { - const ui = this.ui; - - let packageManager = this.packageManager; - if (packageManager === 'default') { - packageManager = 'npm'; - } - - return new Promise(function(resolve, reject) { - exec(`${packageManager} link @angular/cli`, (err) => { - if (err) { - ui.writeLine(chalk.red(`Couldn't do '${packageManager} link @angular/cli'.`)); - reject(); - } else { - ui.writeLine(chalk.green('Successfully linked to @angular/cli.')); - resolve(); - } - }); - }); - } -}); diff --git a/packages/@angular/cli/tasks/lint.ts b/packages/@angular/cli/tasks/lint.ts deleted file mode 100644 index 83b888f178fa..000000000000 --- a/packages/@angular/cli/tasks/lint.ts +++ /dev/null @@ -1,174 +0,0 @@ -import * as chalk from 'chalk'; -import * as fs from 'fs'; -import * as glob from 'glob'; -import * as path from 'path'; -import * as ts from 'typescript'; -import { requireProjectModule } from '../utilities/require-project-module'; - -const SilentError = require('silent-error'); -const Task = require('../ember-cli/lib/models/task'); - -export interface CliLintConfig { - files?: (string | string[]); - project?: string; - tslintConfig?: string; - exclude?: (string | string[]); -} - -export class LintTaskOptions { - fix: boolean; - force: boolean; - format? = 'prose'; - silent? = false; - typeCheck? = false; - configs: Array; -} - -export default Task.extend({ - run: function (options: LintTaskOptions) { - options = { ...new LintTaskOptions(), ...options }; - const ui = this.ui; - const projectRoot = this.project.root; - const lintConfigs = options.configs || []; - - if (lintConfigs.length === 0) { - if (!options.silent) { - ui.writeLine(chalk.yellow('No lint configuration(s) found.')); - } - return Promise.resolve(0); - } - - const tslint = requireProjectModule(projectRoot, 'tslint'); - const Linter = tslint.Linter; - const Configuration = tslint.Configuration; - - const result = lintConfigs - .map((config) => { - let program: ts.Program; - if (config.project) { - program = Linter.createProgram(config.project); - } else if (options.typeCheck) { - if (!options.silent) { - ui.writeLine(chalk.yellow('A "project" must be specified to enable type checking.')); - } - } - const files = getFilesToLint(program, config, Linter); - const lintOptions = { - fix: options.fix, - formatter: options.format - }; - const lintProgram = options.typeCheck ? program : undefined; - const linter = new Linter(lintOptions, lintProgram); - - let lastDirectory: string; - let configLoad: any; - files.forEach((file) => { - const fileContents = getFileContents(file, program); - if (!fileContents) { - return; - } - - // Only check for a new tslint config if path changes - const currentDirectory = path.dirname(file); - if (currentDirectory !== lastDirectory) { - configLoad = Configuration.findConfiguration(config.tslintConfig, file); - lastDirectory = currentDirectory; - } - - linter.lint(file, fileContents, configLoad.results); - }); - - return linter.getResult(); - }) - .reduce((total, current) => { - const failures = current.failures - .filter((cf: any) => !total.failures.some((ef: any) => ef.equals(cf))); - total.failures = total.failures.concat(...failures); - - if (current.fixes) { - total.fixes = (total.fixes || []).concat(...current.fixes); - } - return total; - }, { - failures: [], - fixes: undefined - }); - - if (!options.silent) { - const Formatter = tslint.findFormatter(options.format); - if (!Formatter) { - throw new SilentError(chalk.red(`Invalid lint format "${options.format}".`)); - } - const formatter = new Formatter(); - - const output = formatter.format(result.failures, result.fixes); - if (output) { - ui.writeLine(output); - } - } - - // print formatter output directly for non human-readable formats - if (['prose', 'verbose', 'stylish'].indexOf(options.format) == -1) { - return (result.failures.length == 0 || options.force) - ? Promise.resolve(0) : Promise.resolve(2); - } - - if (result.failures.length > 0) { - if (!options.silent) { - ui.writeLine(chalk.red('Lint errors found in the listed files.')); - } - return options.force ? Promise.resolve(0) : Promise.resolve(2); - } - - if (!options.silent) { - ui.writeLine(chalk.green('All files pass linting.')); - } - return Promise.resolve(0); - } -}); - -function getFilesToLint(program: ts.Program, lintConfig: CliLintConfig, Linter: any): string[] { - let files: string[] = []; - - if (lintConfig.files) { - files = Array.isArray(lintConfig.files) ? lintConfig.files : [lintConfig.files]; - } else if (program) { - files = Linter.getFileNames(program); - } - - let globOptions = {}; - - if (lintConfig.exclude) { - const excludePatterns = Array.isArray(lintConfig.exclude) - ? lintConfig.exclude - : [lintConfig.exclude]; - - globOptions = { ignore: excludePatterns, nodir: true }; - } - - files = files - .map((file: string) => glob.sync(file, globOptions)) - .reduce((a: string[], b: string[]) => a.concat(b), []); - - return files; -} - -function getFileContents(file: string, program?: ts.Program): string { - let contents: string; - - if (program) { - const sourceFile = program.getSourceFile(file); - if (sourceFile) { - contents = sourceFile.getFullText(); - } - } else { - // NOTE: The tslint CLI checks for and excludes MPEG transport streams; this does not. - try { - contents = fs.readFileSync(file, 'utf8'); - } catch (e) { - throw new SilentError(`Could not read file "${file}".`); - } - } - - return contents; -} diff --git a/packages/@angular/cli/tasks/npm-install.ts b/packages/@angular/cli/tasks/npm-install.ts deleted file mode 100644 index b5adf4579d35..000000000000 --- a/packages/@angular/cli/tasks/npm-install.ts +++ /dev/null @@ -1,35 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import { exec } from 'child_process'; - - -export default Task.extend({ - run: function () { - const ui = this.ui; - let packageManager = this.packageManager; - if (packageManager === 'default') { - packageManager = 'npm'; - } - - ui.writeLine(chalk.green(`Installing packages for tooling via ${packageManager}.`)); - let installCommand = `${packageManager} install`; - if (packageManager === 'npm') { - installCommand = `${packageManager} --quiet install`; - } - - return new Promise((resolve, reject) => { - exec(installCommand, - (err: NodeJS.ErrnoException, _stdout: string, stderr: string) => { - if (err) { - ui.writeLine(stderr); - const message = 'Package install failed, see above.'; - ui.writeLine(chalk.red(message)); - reject(message); - } else { - ui.writeLine(chalk.green(`Installed packages for tooling via ${packageManager}.`)); - resolve(); - } - }); - }); - } -}); diff --git a/packages/@angular/cli/tasks/schematic-get-options.ts b/packages/@angular/cli/tasks/schematic-get-options.ts deleted file mode 100644 index 47ecf721d132..000000000000 --- a/packages/@angular/cli/tasks/schematic-get-options.ts +++ /dev/null @@ -1,59 +0,0 @@ -const Task = require('../ember-cli/lib/models/task'); -const stringUtils = require('ember-cli-string-utils'); -import { CliConfig } from '../models/config'; -import { getCollection, getSchematic } from '../utilities/schematics'; - -export interface SchematicGetOptions { - collectionName: string; - schematicName: string; -} - -export interface SchematicAvailableOptions { - name: string; - description: string; - aliases: string[]; - type: any; -} - -export default Task.extend({ - run: function (options: SchematicGetOptions): Promise { - const collectionName = options.collectionName || - CliConfig.getValue('defaults.schematics.collection'); - - const collection = getCollection(collectionName); - - const schematic = getSchematic(collection, options.schematicName); - - const properties = schematic.description.schemaJson.properties; - const keys = Object.keys(properties); - const availableOptions = keys - .map(key => ({...properties[key], ...{name: stringUtils.dasherize(key)}})) - .map(opt => { - let type; - switch (opt.type) { - case 'string': - type = String; - break; - case 'boolean': - type = Boolean; - break; - } - let aliases: string[] = []; - if (opt.alias) { - aliases = [...aliases, opt.alias]; - } - if (opt.aliases) { - aliases = [...aliases, ...opt.aliases]; - } - - return { - ...opt, - aliases, - type, - default: undefined // do not carry over schematics defaults - }; - }); - - return Promise.resolve(availableOptions); - } -}); diff --git a/packages/@angular/cli/tasks/schematic-run.ts b/packages/@angular/cli/tasks/schematic-run.ts deleted file mode 100644 index 5c40c4de0154..000000000000 --- a/packages/@angular/cli/tasks/schematic-run.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - DryRunEvent, - DryRunSink, - FileSystemSink, - FileSystemTree, - Schematic, - Tree -} from '@angular-devkit/schematics'; -import { FileSystemHost } from '@angular-devkit/schematics/tools'; -import { Observable } from 'rxjs/Observable'; -import * as path from 'path'; -import { green, red, yellow } from 'chalk'; -import { CliConfig } from '../models/config'; -import 'rxjs/add/operator/concatMap'; -import 'rxjs/add/operator/map'; -import { getCollection, getSchematic } from '../utilities/schematics'; -import { getAppFromConfig } from '../utilities/app-utils'; - - -const Task = require('../ember-cli/lib/models/task'); - -export interface SchematicRunOptions { - taskOptions: SchematicOptions; - workingDir: string; - collectionName: string; - schematicName: string; -} - -export interface SchematicOptions { - dryRun: boolean; - force: boolean; - [key: string]: any; -} - -export interface SchematicOutput { - modifiedFiles: string[]; -} - -interface OutputLogging { - color: (msg: string) => string; - keyword: string; - message: string; -} - -export default Task.extend({ - run: function (options: SchematicRunOptions): Promise { - const { taskOptions, workingDir, collectionName, schematicName } = options; - - const ui = this.ui; - - const collection = getCollection(collectionName); - const schematic = getSchematic(collection, schematicName); - - let modifiedFiles: string[] = []; - - let appConfig; - try { - appConfig = getAppFromConfig(taskOptions.app); - } catch (err) {} - - const projectRoot = !!this.project ? this.project.root : workingDir; - - const preppedOptions = prepOptions(schematic, taskOptions); - const opts = { ...taskOptions, ...preppedOptions }; - - const host = Observable.of(new FileSystemTree(new FileSystemHost(workingDir))); - - const dryRunSink = new DryRunSink(workingDir, opts.force); - const fsSink = new FileSystemSink(workingDir, opts.force); - - let error = false; - const loggingQueue: OutputLogging[] = []; - - dryRunSink.reporter.subscribe((event: DryRunEvent) => { - const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path; - switch (event.kind) { - case 'error': - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.'; - ui.writeLine(`error! ${eventPath} ${desc}.`); - error = true; - break; - case 'update': - loggingQueue.push({ - color: yellow, - keyword: 'update', - message: `${eventPath} (${event.content.length} bytes)` - }); - modifiedFiles = [...modifiedFiles, event.path]; - break; - case 'create': - loggingQueue.push({ - color: green, - keyword: 'create', - message: `${eventPath} (${event.content.length} bytes)` - }); - modifiedFiles = [...modifiedFiles, event.path]; - break; - case 'delete': - loggingQueue.push({ - color: red, - keyword: 'remove', - message: `${eventPath}` - }); - break; - case 'rename': - const eventToPath = event.to.startsWith('/') ? event.to.substr(1) : event.to; - loggingQueue.push({ - color: yellow, - keyword: 'rename', - message: `${eventPath} => ${eventToPath}` - }); - break; - } - }); - - return new Promise((resolve, reject) => { - schematic.call(opts, host) - .map((tree: Tree) => Tree.optimize(tree)) - .concatMap((tree: Tree) => { - return dryRunSink.commit(tree).ignoreElements().concat(Observable.of(tree)); - }) - .concatMap((tree: Tree) => { - if (!error) { - // Output the logging queue. - loggingQueue.forEach(log => ui.writeLine(` ${log.color(log.keyword)} ${log.message}`)); - } - - if (opts.dryRun || error) { - return Observable.of(tree); - } - return fsSink.commit(tree).ignoreElements().concat(Observable.of(tree)); - }) - .subscribe({ - error(err) { - ui.writeLine(red(`Error: ${err.message}`)); - reject(err.message); - }, - complete() { - if (opts.dryRun) { - ui.writeLine(yellow(`\nNOTE: Run with "dry run" no changes were made.`)); - } - resolve({modifiedFiles}); - } - }); - }) - .then((output: SchematicOutput) => { - const modifiedFiles = output.modifiedFiles; - const lintFix = taskOptions.lintFix !== undefined ? - taskOptions.lintFix : CliConfig.getValue('defaults.lintFix'); - - if (lintFix && modifiedFiles) { - const LintTask = require('./lint').default; - const lintTask = new LintTask({ - ui: this.ui, - project: this.project - }); - - return lintTask.run({ - fix: true, - force: true, - silent: true, - configs: [{ - files: modifiedFiles - .filter((file: string) => /.ts$/.test(file)) - .map((file: string) => path.join(projectRoot, file)) - }] - }); - } - }); - } -}); - -function prepOptions(schematic: Schematic<{}, {}>, options: SchematicOptions): SchematicOptions { - - const properties = (schematic.description).schemaJson.properties; - const keys = Object.keys(properties); - - if (schematic.description.name === 'component') { - options.prefix = (options.prefix === 'false' || options.prefix === '') - ? '' : options.prefix; - } - - let preppedOptions = { - ...options, - ...readDefaults(schematic.description.name, keys, options) - }; - preppedOptions = { - ...preppedOptions, - ...normalizeOptions(schematic.description.name, keys, options) - }; - - return preppedOptions; -} - -function readDefaults(schematicName: string, optionKeys: string[], options: any): any { - return optionKeys.reduce((acc: any, key) => { - acc[key] = options[key] !== undefined ? options[key] : readDefault(schematicName, key); - return acc; - }, {}); -} - -const viewEncapsulationMap: any = { - 'emulated': 'Emulated', - 'native': 'Native', - 'none': 'None' -}; - -const changeDetectionMap: any = { - 'default': 'Default', - 'onpush': 'OnPush' -}; - -function normalizeOptions(schematicName: string, optionKeys: string[], options: any): any { - return optionKeys.reduce((acc: any, key) => { - - if (schematicName === 'application' || schematicName === 'component') { - if (key === 'viewEncapsulation' && options[key]) { - acc[key] = viewEncapsulationMap[options[key].toLowerCase()]; - } else if (key === 'changeDetection' && options[key]) { - acc[key] = changeDetectionMap[options[key].toLowerCase()]; - } - } - return acc; - }, {}); -} - -function readDefault(schematicName: String, key: string) { - const jsonPath = `defaults.${schematicName}.${key}`; - return CliConfig.getValue(jsonPath); -} diff --git a/packages/@angular/cli/tasks/serve.ts b/packages/@angular/cli/tasks/serve.ts deleted file mode 100644 index 232cc0dd37a0..000000000000 --- a/packages/@angular/cli/tasks/serve.ts +++ /dev/null @@ -1,292 +0,0 @@ -import * as fs from 'fs-extra'; -import * as path from 'path'; -import * as chalk from 'chalk'; -import * as webpack from 'webpack'; -import * as url from 'url'; -import { oneLine, stripIndents } from 'common-tags'; -import { getWebpackStatsConfig } from '../models/webpack-configs/utils'; -import { NgCliWebpackConfig } from '../models/webpack-config'; -import { ServeTaskOptions } from '../commands/serve'; -import { CliConfig } from '../models/config'; -import { getAppFromConfig } from '../utilities/app-utils'; -import { statsToString, statsWarningsToString, statsErrorsToString } from '../utilities/stats'; - -const WebpackDevServer = require('webpack-dev-server'); -const Task = require('../ember-cli/lib/models/task'); -const SilentError = require('silent-error'); -const opn = require('opn'); -const yellow = require('chalk').yellow; - -function findDefaultServePath(baseHref: string, deployUrl: string): string | null { - if (!baseHref && !deployUrl) { - return ''; - } - - if (/^(\w+:)?\/\//.test(baseHref) || /^(\w+:)?\/\//.test(deployUrl)) { - // If baseHref or deployUrl is absolute, unsupported by ng serve - return null; - } - - // normalize baseHref - // for ng serve the starting base is always `/` so a relative - // and root relative value are identical - const baseHrefParts = (baseHref || '') - .split('/') - .filter(part => part !== ''); - if (baseHref && !baseHref.endsWith('/')) { - baseHrefParts.pop(); - } - const normalizedBaseHref = baseHrefParts.length === 0 ? '/' : `/${baseHrefParts.join('/')}/`; - - if (deployUrl && deployUrl[0] === '/') { - if (baseHref && baseHref[0] === '/' && normalizedBaseHref !== deployUrl) { - // If baseHref and deployUrl are root relative and not equivalent, unsupported by ng serve - return null; - } - return deployUrl; - } - - // Join together baseHref and deployUrl - return `${normalizedBaseHref}${deployUrl || ''}`; -} - -export default Task.extend({ - run: function (serveTaskOptions: ServeTaskOptions, rebuildDoneCb: any) { - const ui = this.ui; - - let webpackCompiler: any; - const projectConfig = CliConfig.fromProject().config; - const appConfig = getAppFromConfig(serveTaskOptions.app); - - const outputPath = serveTaskOptions.outputPath || appConfig.outDir; - if (this.project.root === path.resolve(outputPath)) { - throw new SilentError('Output path MUST not be project root directory!'); - } - if (projectConfig.project && projectConfig.project.ejected) { - throw new SilentError('An ejected project cannot use the build command anymore.'); - } - if (appConfig.platform === 'server') { - throw new SilentError('ng serve for platform server applications is coming soon!'); - } - if (serveTaskOptions.deleteOutputPath) { - fs.removeSync(path.resolve(this.project.root, outputPath)); - } - - const serveDefaults = { - // default deployUrl to '' on serve to prevent the default from .angular-cli.json - deployUrl: '' - }; - - serveTaskOptions = Object.assign({}, serveDefaults, serveTaskOptions); - - let webpackConfig = new NgCliWebpackConfig(serveTaskOptions, appConfig).buildConfig(); - - const serverAddress = url.format({ - protocol: serveTaskOptions.ssl ? 'https' : 'http', - hostname: serveTaskOptions.host === '0.0.0.0' ? 'localhost' : serveTaskOptions.host, - port: serveTaskOptions.port.toString() - }); - - if (serveTaskOptions.disableHostCheck) { - ui.writeLine(oneLine` - ${yellow('WARNING')} Running a server with --disable-host-check is a security risk. - See https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a - for more information. - `); - } - - let clientAddress = serverAddress; - if (serveTaskOptions.publicHost) { - let publicHost = serveTaskOptions.publicHost; - if (!/^\w+:\/\//.test(publicHost)) { - publicHost = `${serveTaskOptions.ssl ? 'https' : 'http'}://${publicHost}`; - } - const clientUrl = url.parse(publicHost); - serveTaskOptions.publicHost = clientUrl.host; - clientAddress = url.format(clientUrl); - } - - if (serveTaskOptions.liveReload) { - // This allows for live reload of page when changes are made to repo. - // https://webpack.github.io/docs/webpack-dev-server.html#inline-mode - let entryPoints = [ - `webpack-dev-server/client?${clientAddress}` - ]; - if (serveTaskOptions.hmr) { - const webpackHmrLink = 'https://webpack.github.io/docs/hot-module-replacement.html'; - - ui.writeLine(oneLine` - ${yellow('NOTICE')} Hot Module Replacement (HMR) is enabled for the dev server. - `); - - const showWarning = CliConfig.fromGlobal().get('warnings.hmrWarning'); - if (showWarning) { - ui.writeLine(' The project will still live reload when HMR is enabled,'); - ui.writeLine(' but to take advantage of HMR additional application code is required'); - ui.writeLine(' (not included in an Angular CLI project by default).'); - ui.writeLine(` See ${chalk.blue(webpackHmrLink)}`); - ui.writeLine(' for information on working with HMR for Webpack.'); - ui.writeLine(oneLine` - ${yellow('To disable this warning use "ng set --global warnings.hmrWarning=false"')} - `); - } - entryPoints.push('webpack/hot/dev-server'); - webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); - if (serveTaskOptions.extractCss) { - ui.writeLine(oneLine` - ${yellow('NOTICE')} (HMR) does not allow for CSS hot reload when used - together with '--extract-css'. - `); - } - } - if (!webpackConfig.entry.main) { webpackConfig.entry.main = []; } - webpackConfig.entry.main.unshift(...entryPoints); - } else if (serveTaskOptions.hmr) { - ui.writeLine(yellow('Live reload is disabled. HMR option ignored.')); - } - - if (!serveTaskOptions.watch) { - // There's no option to turn off file watching in webpack-dev-server, but - // we can override the file watcher instead. - webpackConfig.plugins.unshift({ - apply: (compiler: any) => { - compiler.plugin('after-environment', () => { - compiler.watchFileSystem = { watch: () => { } }; - }); - } - }); - } - - webpackCompiler = webpack(webpackConfig); - - if (rebuildDoneCb) { - webpackCompiler.plugin('done', rebuildDoneCb); - } - - const statsConfig = getWebpackStatsConfig(serveTaskOptions.verbose); - - let proxyConfig = {}; - if (serveTaskOptions.proxyConfig) { - const proxyPath = path.resolve(this.project.root, serveTaskOptions.proxyConfig); - if (fs.existsSync(proxyPath)) { - proxyConfig = require(proxyPath); - } else { - const message = 'Proxy config file ' + proxyPath + ' does not exist.'; - return Promise.reject(new SilentError(message)); - } - } - - let sslKey: string = null; - let sslCert: string = null; - if (serveTaskOptions.ssl) { - const keyPath = path.resolve(this.project.root, serveTaskOptions.sslKey); - if (fs.existsSync(keyPath)) { - sslKey = fs.readFileSync(keyPath, 'utf-8'); - } - const certPath = path.resolve(this.project.root, serveTaskOptions.sslCert); - if (fs.existsSync(certPath)) { - sslCert = fs.readFileSync(certPath, 'utf-8'); - } - } - - let servePath = serveTaskOptions.servePath; - if (!servePath && servePath !== '') { - const defaultServePath = - findDefaultServePath(serveTaskOptions.baseHref, serveTaskOptions.deployUrl); - if (defaultServePath == null) { - ui.writeLine(oneLine` - ${chalk.yellow('WARNING')} --deploy-url and/or --base-href contain - unsupported values for ng serve. Default serve path of '/' used. - Use --serve-path to override. - `); - } - servePath = defaultServePath || ''; - } - if (servePath.endsWith('/')) { - servePath = servePath.substr(0, servePath.length - 1); - } - if (!servePath.startsWith('/')) { - servePath = `/${servePath}`; - } - const webpackDevServerConfiguration: IWebpackDevServerConfigurationOptions = { - headers: { 'Access-Control-Allow-Origin': '*' }, - historyApiFallback: { - index: `${servePath}/${appConfig.index}`, - disableDotRule: true, - htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'] - }, - stats: serveTaskOptions.verbose ? statsConfig : 'none', - inline: true, - proxy: proxyConfig, - compress: serveTaskOptions.target === 'production', - watchOptions: { - poll: serveTaskOptions.poll - }, - https: serveTaskOptions.ssl, - overlay: { - errors: serveTaskOptions.target === 'development', - warnings: false - }, - contentBase: false, - public: serveTaskOptions.publicHost, - disableHostCheck: serveTaskOptions.disableHostCheck, - publicPath: servePath - }; - - if (sslKey != null && sslCert != null) { - webpackDevServerConfiguration.key = sslKey; - webpackDevServerConfiguration.cert = sslCert; - } - - webpackDevServerConfiguration.hot = serveTaskOptions.hmr; - - if (serveTaskOptions.target === 'production') { - ui.writeLine(chalk.red(stripIndents` - **************************************************************************************** - This is a simple server for use in testing or debugging Angular applications locally. - It hasn't been reviewed for security issues. - - DON'T USE IT FOR PRODUCTION USE! - **************************************************************************************** - `)); - } - - ui.writeLine(chalk.green(oneLine` - ** - NG Live Development Server is listening on ${serveTaskOptions.host}:${serveTaskOptions.port}, - open your browser on ${serverAddress}${servePath} - ** - `)); - - const server = new WebpackDevServer(webpackCompiler, webpackDevServerConfiguration); - if (!serveTaskOptions.verbose) { - webpackCompiler.plugin('done', (stats: any) => { - const json = stats.toJson('verbose'); - this.ui.writeLine(statsToString(json, statsConfig)); - if (stats.hasWarnings()) { - this.ui.writeLine(statsWarningsToString(json, statsConfig)); - } - if (stats.hasErrors()) { - this.ui.writeError(statsErrorsToString(json, statsConfig)); - } - }); - } - - return new Promise((_resolve, reject) => { - server.listen(serveTaskOptions.port, serveTaskOptions.host, (err: any, _stats: any) => { - if (err) { - return reject(err); - } - if (serveTaskOptions.open) { - opn(serverAddress); - } - }); - }) - .catch((err: Error) => { - if (err) { - this.ui.writeError('\nAn error occured during the build:\n' + ((err && err.stack) || err)); - } - throw err; - }); - } -}); diff --git a/packages/@angular/cli/tasks/test.ts b/packages/@angular/cli/tasks/test.ts deleted file mode 100644 index a41ec55ac4f5..000000000000 --- a/packages/@angular/cli/tasks/test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as path from 'path'; - -import { TestOptions } from '../commands/test'; -import { CliConfig } from '../models/config'; -import { requireProjectModule } from '../utilities/require-project-module'; -import { getAppFromConfig } from '../utilities/app-utils'; - -const Task = require('../ember-cli/lib/models/task'); -const SilentError = require('silent-error'); - - -export default Task.extend({ - run: function (options: TestOptions) { - const projectConfig = CliConfig.fromProject().config; - const projectRoot = this.project.root; - const appConfig = getAppFromConfig(options.app); - - if (projectConfig.project && projectConfig.project.ejected) { - throw new SilentError('An ejected project cannot use the build command anymore.'); - } - if (appConfig.platform === 'server') { - throw new SilentError('ng test for platform server applications is coming soon!'); - } - - return new Promise((resolve) => { - const karma = requireProjectModule(projectRoot, 'karma'); - const karmaConfig = path.join(projectRoot, options.config || - CliConfig.getValue('test.karma.config')); - - let karmaOptions: any = Object.assign({}, options); - - // Convert browsers from a string to an array - if (options.browsers) { - karmaOptions.browsers = options.browsers.split(','); - } - - karmaOptions.angularCli = { - codeCoverage: options.codeCoverage, - sourcemaps: options.sourcemaps, - progress: options.progress, - poll: options.poll, - environment: options.environment, - app: options.app - }; - - // Assign additional karmaConfig options to the local ngapp config - karmaOptions.configFile = karmaConfig; - - // :shipit: - const karmaServer = new karma.Server(karmaOptions, resolve); - karmaServer.start(); - }); - } -}); diff --git a/packages/@angular/cli/tsconfig.json b/packages/@angular/cli/tsconfig.json deleted file mode 100644 index ed8cad33e491..000000000000 --- a/packages/@angular/cli/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - - "compilerOptions": { - "outDir": "../../../dist/@angular/cli", - "rootDir": ".", - "baseUrl": "", - "paths": { - "@ngtools/json-schema": [ "../../../dist/@ngtools/json-schema/src" ], - "@ngtools/webpack": [ "../../../dist/@ngtools/webpack/src" ] - } - } -} diff --git a/packages/@angular/cli/upgrade/version.ts b/packages/@angular/cli/upgrade/version.ts deleted file mode 100644 index a25a838120e0..000000000000 --- a/packages/@angular/cli/upgrade/version.ts +++ /dev/null @@ -1,162 +0,0 @@ -import {SemVer} from 'semver'; -import {bold, red, yellow} from 'chalk'; -import {stripIndents} from 'common-tags'; -import {readFileSync, existsSync} from 'fs'; -import * as path from 'path'; - -import {CliConfig} from '../models/config'; -import {findUp} from '../utilities/find-up'; - -const resolve = require('resolve'); - - -function _hasOldCliBuildFile() { - return existsSync(findUp('angular-cli-build.js', process.cwd())) - || existsSync(findUp('angular-cli-build.ts', process.cwd())) - || existsSync(findUp('ember-cli-build.js', process.cwd())) - || existsSync(findUp('angular-cli-build.js', __dirname)) - || existsSync(findUp('angular-cli-build.ts', __dirname)) - || existsSync(findUp('ember-cli-build.js', __dirname)); -} - - -export class Version { - private _semver: SemVer = null; - constructor(private _version: string = null) { - this._semver = _version && new SemVer(_version); - } - - isAlpha() { return this.qualifier == 'alpha'; } - isBeta() { return this.qualifier == 'beta'; } - isReleaseCandidate() { return this.qualifier == 'rc'; } - isKnown() { return this._version !== null; } - - isLocal() { return this.isKnown() && path.isAbsolute(this._version); } - isGreaterThanOrEqualTo(other: SemVer) { - return this._semver.compare(other) >= 0; - } - - get major() { return this._semver ? this._semver.major : 0; } - get minor() { return this._semver ? this._semver.minor : 0; } - get patch() { return this._semver ? this._semver.patch : 0; } - get qualifier() { return this._semver ? this._semver.prerelease[0] : ''; } - get extra() { return this._semver ? this._semver.prerelease[1] : ''; } - - toString() { return this._version; } - - static fromProject(): Version { - let packageJson: any = null; - - try { - const angularCliPath = resolve.sync('@angular/cli', { - basedir: process.cwd(), - packageFilter: (pkg: any, _pkgFile: string) => { - packageJson = pkg; - } - }); - if (angularCliPath && packageJson) { - try { - return new Version(packageJson.version); - } catch (e) { - return new Version(null); - } - } - } catch (e) { - // Fallback to reading config. - } - - - const configPath = CliConfig.configFilePath(); - - if (configPath === null) { - return new Version(null); - } - - const configJson = readFileSync(configPath, 'utf8'); - - try { - const json = JSON.parse(configJson); - return new Version(json.project); - } catch (e) { - return new Version(null); - } - } - - static assertAngularVersionIs2_3_1OrHigher(projectRoot: string) { - const angularCorePath = path.join(projectRoot, 'node_modules/@angular/core'); - const pkgJson = existsSync(angularCorePath) - ? JSON.parse(readFileSync(path.join(angularCorePath, 'package.json'), 'utf8')) - : null; - - // Just check @angular/core. - if (pkgJson && pkgJson['version']) { - const v = new Version(pkgJson['version']); - if (v.isLocal()) { - console.warn(yellow('Using a local version of angular. Proceeding with care...')); - } else { - // Check if major is not 0, so that we stay compatible with local compiled versions - // of angular. - if (!v.isGreaterThanOrEqualTo(new SemVer('2.3.1')) && v.major != 0) { - console.error(bold(red(stripIndents` - This version of CLI is only compatible with angular version 2.3.1 or better. Please - upgrade your angular version, e.g. by running: - - npm install @angular/core@latest - ` + '\n'))); - process.exit(3); - } - } - } else { - console.error(bold(red(stripIndents` - You seem to not be depending on "@angular/core". This is an error. - `))); - process.exit(2); - } - } - - static assertPostWebpackVersion() { - if (this.isPreWebpack()) { - console.error(bold(red('\n' + stripIndents` - It seems like you're using a project generated using an old version of the Angular CLI. - The latest CLI now uses webpack and has a lot of improvements including a simpler - workflow, a faster build, and smaller bundles. - - To get more info, including a step-by-step guide to upgrade the CLI, follow this link: - https://github.com/angular/angular-cli/wiki/Upgrading-from-Beta.10-to-Beta.14 - ` + '\n'))); - process.exit(1); - } else { - // Verify that there's no build file. - if (_hasOldCliBuildFile()) { - console.error(bold(yellow('\n' + stripIndents` - It seems like you're using the newest version of the Angular CLI that uses webpack. - This version does not require an angular-cli-build file, but your project has one. - It will be ignored. - ` + '\n'))); - } - } - } - - static isPreWebpack(): boolean { - // CliConfig is a bit stricter with the schema, so we need to be a little looser with it. - const version = Version.fromProject(); - - if (version && version.isKnown()) { - if (version.major == 0) { - return true; - } else if (version.minor != 0) { - return false; - } else if (version.isBeta() && !version.toString().match(/webpack/)) { - const betaVersion = version.extra; - - if (parseInt(betaVersion) < 12) { - return true; - } - } - } else { - return _hasOldCliBuildFile(); - } - - return false; - } -} diff --git a/packages/@angular/cli/utilities/INITIAL_COMMIT_MESSAGE.txt b/packages/@angular/cli/utilities/INITIAL_COMMIT_MESSAGE.txt deleted file mode 100644 index 2f0b94d3b50c..000000000000 --- a/packages/@angular/cli/utilities/INITIAL_COMMIT_MESSAGE.txt +++ /dev/null @@ -1,8 +0,0 @@ -chore: initial commit from @angular/cli - - _ _ ____ _ ___ - / \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _| - / △ \ | '_ \ / _\` | | | | |/ _\` | '__| | | | | | | - / ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | | -/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___| - |___/ diff --git a/packages/@angular/cli/utilities/app-utils.ts b/packages/@angular/cli/utilities/app-utils.ts deleted file mode 100644 index b38857e3979c..000000000000 --- a/packages/@angular/cli/utilities/app-utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -const SilentError = require('silent-error'); -const chalk = require('chalk'); - -import { oneLine } from 'common-tags'; -import { CliConfig } from '../models/config'; - -export function getAppFromConfig(nameOrIndex?: String) { - const apps: any[] = CliConfig.getValue('apps'); - if (!apps) { - throw new SilentError(chalk.red('Unable to find any apps in `.angular-cli.json`.')); - } - - if (nameOrIndex) { - if (nameOrIndex.match(/^[0-9]+$/)) { - const index = parseInt(nameOrIndex.toString(), 10); - if (apps[index]) { - return apps[index]; - } - } else { - const filtered = apps.filter((currentApp: any) => currentApp.name === nameOrIndex); - if (filtered.length > 0) { - return filtered[0]; - } - } - } else { - return apps[0]; - } - throw new SilentError(chalk.red(oneLine` - Unable to find app with name or index. - Verify the configuration in \`.angular-cli.json\` - `)); -} diff --git a/packages/@angular/cli/utilities/check-package-manager.ts b/packages/@angular/cli/utilities/check-package-manager.ts deleted file mode 100644 index 826c738f7bd5..000000000000 --- a/packages/@angular/cli/utilities/check-package-manager.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as chalk from 'chalk'; -import {exec} from 'child_process'; -import {CliConfig} from '../models/config'; -import denodeify = require('denodeify'); - -const execPromise = denodeify(exec); -const packageManager = CliConfig.fromGlobal().get('packageManager'); - - -export function checkYarnOrCNPM() { - - // Don't show messages if user has already changed the default. - if (packageManager !== 'default') { - return Promise.resolve(); - } - - return Promise - .all([checkYarn(), checkCNPM()]) - .then((data: Array) => { - const [isYarnInstalled, isCNPMInstalled] = data; - if (isYarnInstalled && isCNPMInstalled) { - console.log(chalk.yellow('You can `ng set --global packageManager=yarn` ' - + 'or `ng set --global packageManager=cnpm`.')); - } else if (isYarnInstalled) { - console.log(chalk.yellow('You can `ng set --global packageManager=yarn`.')); - } else if (isCNPMInstalled) { - console.log(chalk.yellow('You can `ng set --global packageManager=cnpm`.')); - } else { - if (packageManager !== 'default' && packageManager !== 'npm') { - console.log(chalk.yellow(`Seems that ${packageManager} is not installed.`)); - console.log(chalk.yellow('You can `ng set --global packageManager=npm`.')); - } - } - }); -} - -function checkYarn() { - return execPromise('yarn --version') - .then(() => true, () => false); -} - -function checkCNPM() { - return execPromise('cnpm --version') - .then(() => true, () => false); -} diff --git a/packages/@angular/cli/utilities/check-port.ts b/packages/@angular/cli/utilities/check-port.ts deleted file mode 100644 index 29fa55500481..000000000000 --- a/packages/@angular/cli/utilities/check-port.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as denodeify from 'denodeify'; - -const SilentError = require('silent-error'); -const PortFinder = require('portfinder'); -const getPort = denodeify<{host: string, port: number}, number>(PortFinder.getPort); - -export function checkPort(port: number, host: string, basePort = 49152): Promise { - PortFinder.basePort = basePort; - return getPort({ port, host }) - .then(foundPort => { - - // If the port isn't available and we weren't looking for any port, throw error. - if (port !== foundPort && port !== 0) { - throw new SilentError( - `Port ${port} is already in use. Use '--port' to specify a different port.` - ); - } - - // Otherwise, our found port is good. - return foundPort; - }); -} diff --git a/packages/@angular/cli/utilities/dynamic-path-parser.ts b/packages/@angular/cli/utilities/dynamic-path-parser.ts deleted file mode 100644 index fd0b259bc4ea..000000000000 --- a/packages/@angular/cli/utilities/dynamic-path-parser.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as path from 'path'; -import * as process from 'process'; -import * as fs from 'fs-extra'; -const stringUtils = require('ember-cli-string-utils'); - -export interface DynamicPathOptions { - project: any; - entityName: string; - appConfig: any; - dryRun: boolean; -} - -export function dynamicPathParser(options: DynamicPathOptions) { - const projectRoot = options.project.root; - const sourceDir = options.appConfig.root; - const appRoot = path.join(sourceDir, 'app'); - const cwd = process.env.PWD; - - const rootPath = path.join(projectRoot, appRoot); - let outputPath = path.join(rootPath, options.entityName); - - if (options.entityName.indexOf(path.sep) === 0) { - outputPath = path.join(rootPath, options.entityName.substr(1)); - } else if (cwd.indexOf(rootPath) >= 0) { - outputPath = path.join(cwd, options.entityName); - } - - if (!fs.existsSync(outputPath)) { - // Verify the path exists on disk. - const parsedOutputPath = path.parse(outputPath); - const parts = parsedOutputPath.dir.split(path.sep).slice(1); - const newPath = parts.reduce((tempPath: string, part: string) => { - // if (tempPath === '') { - // return part; - // } - - const withoutPlus = path.join(tempPath, part); - const withPlus = path.join(tempPath, '+' + part); - if (fs.existsSync(withoutPlus)) { - return withoutPlus; - } else if (fs.existsSync(withPlus)) { - return withPlus; - } - - // Folder not found, create it, and return it - const dasherizedPart = stringUtils.dasherize(part); - const dasherizedDirName = path.join(tempPath, dasherizedPart); - if (!options.dryRun) { - fs.mkdirpSync(dasherizedDirName); - } - return dasherizedDirName; - - }, parsedOutputPath.root); - outputPath = path.join(newPath, parsedOutputPath.name); - } - - if (outputPath.indexOf(rootPath) < 0) { - throw `Invalid path: "${options.entityName}" cannot be ` + - `above the "${appRoot}" directory`; - } - - const adjustedPath = outputPath.replace(projectRoot, ''); - - const parsedPath = path.parse(adjustedPath); - - if (parsedPath.dir.indexOf(path.sep) === 0) { - parsedPath.dir = parsedPath.dir.substr(1); - } - - parsedPath.dir = parsedPath.dir === path.sep ? '' : parsedPath.dir; - - return { ...parsedPath, appRoot, sourceDir }; -} diff --git a/packages/@angular/cli/utilities/find-up.ts b/packages/@angular/cli/utilities/find-up.ts deleted file mode 100644 index bbf5ee0c6037..000000000000 --- a/packages/@angular/cli/utilities/find-up.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as path from 'path'; -import { existsSync } from 'fs'; - -export function findUp(names: string | string[], from: string, stopOnNodeModules = false) { - if (!Array.isArray(names)) { - names = [names]; - } - const root = path.parse(from).root; - - let currentDir = from; - while (currentDir && currentDir !== root) { - for (const name of names) { - const p = path.join(currentDir, name); - if (existsSync(p)) { - return p; - } - } - - if (stopOnNodeModules) { - const nodeModuleP = path.join(currentDir, 'node_modules'); - if (existsSync(nodeModuleP)) { - return null; - } - } - - currentDir = path.dirname(currentDir); - } - - return null; -} diff --git a/packages/@angular/cli/utilities/override-options.ts b/packages/@angular/cli/utilities/override-options.ts deleted file mode 100644 index 4c027abc824c..000000000000 --- a/packages/@angular/cli/utilities/override-options.ts +++ /dev/null @@ -1,12 +0,0 @@ -const cloneDeep = require('lodash/cloneDeep'); - -export function overrideOptions(original: any[], overrides: any[]) { - let copy = cloneDeep(original); - overrides.forEach(override => { - const option = copy.find((opt: any) => opt.name == override.name); - if (option) { - Object.assign(option, override); - } - }); - return copy; -} diff --git a/packages/@angular/cli/utilities/package-chunk-sort.ts b/packages/@angular/cli/utilities/package-chunk-sort.ts deleted file mode 100644 index a7841aeb544c..000000000000 --- a/packages/@angular/cli/utilities/package-chunk-sort.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ExtraEntry, extraEntryParser } from '../models/webpack-configs/utils'; - -// Sort chunks according to a predefined order: -// inline, polyfills, all styles, vendor, main -export function packageChunkSort(appConfig: any) { - let entryPoints = ['inline', 'polyfills', 'sw-register']; - - const pushExtraEntries = (extraEntry: ExtraEntry) => { - if (entryPoints.indexOf(extraEntry.entry) === -1) { - entryPoints.push(extraEntry.entry); - } - }; - - if (appConfig.styles) { - extraEntryParser(appConfig.styles, './', 'styles').forEach(pushExtraEntries); - } - - entryPoints.push(...['vendor', 'main']); - - function sort(left: any, right: any) { - let leftIndex = entryPoints.indexOf(left.names[0]); - let rightindex = entryPoints.indexOf(right.names[0]); - - if (leftIndex > rightindex) { - return 1; - } else if (leftIndex < rightindex) { - return -1; - } else { - return 0; - } - } - - // We need to list of entry points for the Ejected webpack config to work (we reuse the function - // defined above). - (sort as any).entryPoints = entryPoints; - return sort; -} diff --git a/packages/@angular/cli/utilities/require-project-module.ts b/packages/@angular/cli/utilities/require-project-module.ts deleted file mode 100644 index a655e99bad1c..000000000000 --- a/packages/@angular/cli/utilities/require-project-module.ts +++ /dev/null @@ -1,6 +0,0 @@ -const resolve = require('resolve'); - -// require dependencies within the target project -export function requireProjectModule(root: string, moduleName: string) { - return require(resolve.sync(moduleName, { basedir: root })); -} diff --git a/packages/@angular/cli/utilities/schematics.ts b/packages/@angular/cli/utilities/schematics.ts deleted file mode 100644 index 32912b81c6cf..000000000000 --- a/packages/@angular/cli/utilities/schematics.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Refer to the angular shematics library to let the dependency validator - * know it is used.. - * - * require('@schematics/angular') - */ - -import { - Collection, - Schematic, - SchematicEngine, -} from '@angular-devkit/schematics'; -import { - FileSystemSchematicDesc, - NodeModulesEngineHost -} from '@angular-devkit/schematics/tools'; -import { SchemaClassFactory } from '@ngtools/json-schema'; -import 'rxjs/add/operator/concatMap'; -import 'rxjs/add/operator/map'; - -const SilentError = require('silent-error'); - -export function getEngineHost() { - const engineHost = new NodeModulesEngineHost(); - return engineHost; -} - -export function getCollection(collectionName: string): Collection { - const engineHost = getEngineHost(); - const engine = new SchematicEngine(engineHost); - - // Add support for schemaJson. - engineHost.registerOptionsTransform((schematic: FileSystemSchematicDesc, options: any) => { - if (schematic.schema) { - const SchemaMetaClass = SchemaClassFactory(schematic.schemaJson!); - const schemaClass = new SchemaMetaClass(options); - return schemaClass.$$root(); - } - return options; - }); - - const collection = engine.createCollection(collectionName); - - if (collection === null) { - throw new SilentError(`Invalid collection (${collectionName}).`); - } - return collection; -} - -export function getSchematic(collection: Collection, - schematicName: string): Schematic { - return collection.createSchematic(schematicName); -} diff --git a/packages/@angular/cli/utilities/stats.ts b/packages/@angular/cli/utilities/stats.ts deleted file mode 100644 index 87bb31a540c6..000000000000 --- a/packages/@angular/cli/utilities/stats.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { bold, green, red, reset, white, yellow } from 'chalk'; -import { stripIndents } from 'common-tags'; - - -function _formatSize(size: number): string { - if (size <= 0) { - return '0 bytes'; - } - - const abbreviations = ['bytes', 'kB', 'MB', 'GB']; - const index = Math.floor(Math.log(size) / Math.log(1000)); - - return `${+(size / Math.pow(1000, index)).toPrecision(3)} ${abbreviations[index]}`; -} - - -export function statsToString(json: any, statsConfig: any) { - const colors = statsConfig.colors; - const rs = (x: string) => colors ? reset(x) : x; - const w = (x: string) => colors ? bold(white(x)) : x; - const g = (x: string) => colors ? bold(green(x)) : x; - const y = (x: string) => colors ? bold(yellow(x)) : x; - - return rs(stripIndents` - Date: ${w(new Date().toISOString())} - Hash: ${w(json.hash)} - Time: ${w('' + json.time)}ms - ${json.chunks.map((chunk: any) => { - const asset = json.assets.filter((x: any) => x.name == chunk.files[0])[0]; - const size = asset ? ` ${_formatSize(asset.size)}` : ''; - const files = chunk.files.join(', '); - const names = chunk.names ? ` (${chunk.names.join(', ')})` : ''; - const parents = chunk.parents.map((id: string) => ` {${y(id)}}`).join(''); - const initial = y(chunk.entry ? '[entry]' : chunk.initial ? '[initial]' : ''); - const flags = ['rendered', 'recorded'] - .map(f => f && chunk[f] ? g(` [${f}]`) : '') - .join(''); - - return `chunk {${y(chunk.id)}} ${g(files)}${names}${size}${parents} ${initial}${flags}`; - }).join('\n')} - `); -} - -export function statsWarningsToString(json: any, statsConfig: any) { - const colors = statsConfig.colors; - const rs = (x: string) => colors ? reset(x) : x; - const y = (x: string) => colors ? bold(yellow(x)) : x; - - return rs('\n' + json.warnings.map((warning: any) => y(`WARNING in ${warning}`)).join('\n\n')); -} - -export function statsErrorsToString(json: any, statsConfig: any) { - const colors = statsConfig.colors; - const rs = (x: string) => colors ? reset(x) : x; - const r = (x: string) => colors ? bold(red(x)) : x; - - return rs('\n' + json.errors.map((error: any) => r(`ERROR in ${error}`)).join('\n')); -} diff --git a/packages/@angular/cli/utilities/validate-project-name.ts b/packages/@angular/cli/utilities/validate-project-name.ts deleted file mode 100644 index df7a0ef6e7cb..000000000000 --- a/packages/@angular/cli/utilities/validate-project-name.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {oneLine, stripIndent} from 'common-tags'; - -const SilentError = require('silent-error'); - -const projectNameRegexp = /^[a-zA-Z][.0-9a-zA-Z]*(-[.0-9a-zA-Z]*)*$/; -const unsupportedProjectNames = ['test', 'ember', 'ember-cli', 'vendor', 'app']; - -function getRegExpFailPosition(str: string): number | null { - const parts = str.split('-'); - const matched: string[] = []; - - parts.forEach(part => { - if (part.match(projectNameRegexp)) { - matched.push(part); - } - }); - - const compare = matched.join('-'); - return (str !== compare) ? compare.length : null; -} - -export function validateProjectName(projectName: string) { - const errorIndex = getRegExpFailPosition(projectName); - if (errorIndex !== null) { - const firstMessage = oneLine` - Project name "${projectName}" is not valid. New project names must - start with a letter, and must contain only alphanumeric characters or dashes. - When adding a dash the segment after the dash must also start with a letter. - `; - const msg = stripIndent` - ${firstMessage} - ${projectName} - ${Array(errorIndex + 1).join(' ') + '^'} - `; - throw new SilentError(msg); - } else if (unsupportedProjectNames.indexOf(projectName) !== -1) { - throw new SilentError(`Project name "${projectName}" is not a supported name.`); - } - -} diff --git a/packages/@angular/cli/webpack-custom-typings.d.ts b/packages/@angular/cli/webpack-custom-typings.d.ts deleted file mode 100644 index b57841eb3c17..000000000000 --- a/packages/@angular/cli/webpack-custom-typings.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as webpack from 'webpack'; - -declare module 'webpack' { - export class NamedChunksPlugin { - constructor(nameResolver: (chunk: any) => string | null); - } - export class HashedModuleIdsPlugin { - constructor(); - } -} diff --git a/packages/@ngtools/json-schema/package.json b/packages/@ngtools/json-schema/package.json deleted file mode 100644 index 9a043af510b5..000000000000 --- a/packages/@ngtools/json-schema/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@ngtools/json-schema", - "version": "1.1.0", - "description": "Schema validating and reading for configurations, similar to Angular CLI config.", - "main": "./src/index.js", - "typings": "src/index.d.ts", - "license": "MIT", - "keywords": [ - "angular", - "json", - "json-schema", - "schema", - "config" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli/tree/master/packages/@ngtools/json-schema", - "engines": { - "node": ">= 4.1.0", - "npm": ">= 3.0.0" - }, - "dependencies": { - }, - "peerDependencies": { - } -} diff --git a/packages/@ngtools/json-schema/src/error.ts b/packages/@ngtools/json-schema/src/error.ts deleted file mode 100644 index 5863a5decd4e..000000000000 --- a/packages/@ngtools/json-schema/src/error.ts +++ /dev/null @@ -1,12 +0,0 @@ - -export class JsonSchemaErrorBase extends Error { - constructor(message?: string) { - super(); - - if (message) { - this.message = message; - } else { - this.message = (this.constructor).name; - } - } -} diff --git a/packages/@ngtools/json-schema/src/index.ts b/packages/@ngtools/json-schema/src/index.ts deleted file mode 100644 index 1021cae0c409..000000000000 --- a/packages/@ngtools/json-schema/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {SchemaClass, SchemaClassFactory} from './schema-class-factory'; diff --git a/packages/@ngtools/json-schema/src/mimetypes.ts b/packages/@ngtools/json-schema/src/mimetypes.ts deleted file mode 100644 index 6c22ccb9fa81..000000000000 --- a/packages/@ngtools/json-schema/src/mimetypes.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {JsonSchemaErrorBase} from './error'; -import {Serializer, WriterFn} from './serializer'; -import {JsonSerializer} from './serializers/json'; -import {DTsSerializer} from './serializers/dts'; - - -export class UnknownMimetype extends JsonSchemaErrorBase {} - - -export function createSerializerFromMimetype(mimetype: string, - writer: WriterFn, - ...opts: any[]): Serializer { - let Klass: { new (writer: WriterFn, ...args: any[]): Serializer } = null; - switch (mimetype) { - case 'application/json': Klass = JsonSerializer; break; - case 'text/json': Klass = JsonSerializer; break; - case 'text/x.typescript': Klass = DTsSerializer; break; - case 'text/x.dts': Klass = DTsSerializer; break; - - default: throw new UnknownMimetype(); - } - - return new Klass(writer, ...opts); - -} - - -declare module './serializer' { - namespace Serializer { - export let fromMimetype: typeof createSerializerFromMimetype; - } -} - -Serializer.fromMimetype = createSerializerFromMimetype; diff --git a/packages/@ngtools/json-schema/src/node.ts b/packages/@ngtools/json-schema/src/node.ts deleted file mode 100644 index 873ac6697503..000000000000 --- a/packages/@ngtools/json-schema/src/node.ts +++ /dev/null @@ -1,42 +0,0 @@ -import {Serializer} from './serializer'; - - -// A TypeScript Type. This can be used to do `new tsType(value)`. -// `null` implies any type; be careful. -export type TypeScriptType = typeof Number - | typeof Boolean - | typeof String - | typeof Object - | typeof Array - | null; - - -// The most generic interface for a schema node. This is used by the serializers. -export interface SchemaNode { - readonly name: string; - readonly type: string; - readonly tsType: TypeScriptType; - readonly defined: boolean; - readonly dirty: boolean; - readonly frozen: boolean; - readonly readOnly: boolean; - readonly defaultValue: any | null; - readonly required: boolean; - readonly parent: SchemaNode | null; - - // Schema related properties. - readonly description: string | null; - - // Object-only properties. `null` for everything else. - readonly children: { [key: string]: SchemaNode } | null; - - // Array-only properties. `null` for everything else. - readonly items: SchemaNode[] | null; - readonly itemPrototype: SchemaNode | null; - - // Mutable properties. - value: any; - - // Serialization. - serialize(serializer: Serializer): void; -} diff --git a/packages/@ngtools/json-schema/src/schema-class-factory.ts b/packages/@ngtools/json-schema/src/schema-class-factory.ts deleted file mode 100644 index f83bf95d2ca6..000000000000 --- a/packages/@ngtools/json-schema/src/schema-class-factory.ts +++ /dev/null @@ -1,203 +0,0 @@ -import {Serializer} from './serializer'; -import {RootSchemaTreeNode, SchemaTreeNode} from './schema-tree'; -import {JsonSchemaErrorBase} from './error'; - -import './mimetypes'; - -export class InvalidJsonPath extends JsonSchemaErrorBase {} - -// The schema tree node property of the SchemaClass. -const kSchemaNode = Symbol('schema-node'); -// The value property of the SchemaClass. -const kOriginalRoot = Symbol('schema-value'); - - -/** - * Splits a JSON path string into fragments. Fragments can be used to get the value referenced - * by the path. For example, a path of "a[3].foo.bar[2]" would give you a fragment array of - * ["a", 3, "foo", "bar", 2]. - * @param path The JSON string to parse. - * @returns {string[]} The fragments for the string. - * @private - */ -function _parseJsonPath(path: string): string[] { - const fragments = (path || '').split(/\./g); - const result: string[] = []; - - while (fragments.length > 0) { - const fragment = fragments.shift(); - - const match = fragment.match(/([^\[]+)((\[.*\])*)/); - if (!match) { - throw new InvalidJsonPath(); - } - - result.push(match[1]); - if (match[2]) { - const indices = match[2].slice(1, -1).split(']['); - result.push(...indices); - } - } - - return result.filter(fragment => !!fragment); -} - - -/** Get a SchemaTreeNode from the JSON path string. */ -function _getSchemaNodeForPath(rootMetaData: SchemaTreeNode, - path: string): SchemaTreeNode { - let fragments = _parseJsonPath(path); - // TODO: make this work with union (oneOf) schemas - return fragments.reduce((md: SchemaTreeNode, current: string) => { - if (md && md.children) { - return md.children[current]; - } else if (md && md.items) { - return md.items[parseInt(current, 10)]; - } else { - return md; - } - }, rootMetaData); -} - - -/** The interface the SchemaClassFactory returned class implements. */ -export interface SchemaClass extends Object { - $$root(): JsonType; - $$get(path: string): any; - $$set(path: string, value: any): void; - $$alias(source: string, destination: string): boolean; - $$dispose(): void; - - // Metadata of the schema. - $$typeOf(path: string): string; - $$defined(path: string): boolean; - $$delete(path: string): void; - - // Direct access to the schema. - $$schema(): RootSchemaTreeNode; - - $$serialize(mimetype?: string, ...args: any[]): string; -} - - -class SchemaClassBase implements SchemaClass { - constructor(schema: Object, value: T, ...fallbacks: T[]) { - (this as any)[kOriginalRoot] = value; - const forward = fallbacks.length > 0 - ? (new SchemaClassBase(schema, fallbacks.pop(), ...fallbacks).$$schema()) - : null; - (this as any)[kSchemaNode] = new RootSchemaTreeNode(this, { - forward, - value, - schema - }); - } - - $$root(): T { return this as any; } - $$schema(): RootSchemaTreeNode { return (this as any)[kSchemaNode] as RootSchemaTreeNode; } - $$originalRoot(): T { return (this as any)[kOriginalRoot] as T; } - - /** Sets the value of a destination if the value is currently undefined. */ - $$alias(source: string, destination: string) { - let sourceSchemaTreeNode = _getSchemaNodeForPath(this.$$schema(), source); - if (!sourceSchemaTreeNode) { - return false; - } - - const fragments = _parseJsonPath(destination); - const maybeValue = fragments.reduce((value: any, current: string) => { - return value && value[current]; - }, this.$$originalRoot()); - - if (maybeValue !== undefined) { - sourceSchemaTreeNode.set(maybeValue); - return true; - } - return false; - } - - /** Destroy all links between schemas to allow for GC. */ - $$dispose() { - this.$$schema().dispose(); - } - - /** Get a value from a JSON path. */ - $$get(path: string): any { - const node = _getSchemaNodeForPath(this.$$schema(), path); - return node ? node.get() : undefined; - } - - /** Set a value from a JSON path. */ - $$set(path: string, value: any): void { - const node = _getSchemaNodeForPath(this.$$schema(), path); - - if (node) { - node.set(value); - } else { - // This might be inside an object that can have additionalProperties, so - // a TreeNode would not exist. - const splitPath = _parseJsonPath(path); - if (!splitPath) { - return undefined; - } - const parent: any = splitPath - .slice(0, -1) - .reduce((parent: any, curr: string) => parent && parent[curr], this); - - if (parent) { - parent[splitPath[splitPath.length - 1]] = value; - } - } - } - - /** Get the Schema associated with a path. */ - $$typeOf(path: string): string { - const node = _getSchemaNodeForPath(this.$$schema(), path); - return node ? node.type : null; - } - - $$defined(path: string): boolean { - const node = _getSchemaNodeForPath(this.$$schema(), path); - return node ? node.defined : false; - } - - $$delete(path: string) { - const node = _getSchemaNodeForPath(this.$$schema(), path); - if (node) { - node.destroy(); - } - } - - /** Serialize into a string. */ - $$serialize(mimetype = 'application/json', ...options: any[]): string { - let str = ''; - const serializer = Serializer.fromMimetype(mimetype, (s) => str += s, ...options); - - serializer.start(); - this.$$schema().serialize(serializer); - serializer.end(); - - return str; - } -} -export interface SchemaClassFactoryReturn { - new (value: T, ...fallbacks: T[]): SchemaClass; -} - -/** - * Create a class from a JSON SCHEMA object. Instanciating that class with an object - * allows for extended behaviour. - * This is the base API to access the Configuration in the CLI. - * @param schema - * @returns {GeneratedSchemaClass} - * @constructor - */ -export function SchemaClassFactory(schema: Object): SchemaClassFactoryReturn { - class GeneratedSchemaClass extends SchemaClassBase { - constructor(value: T, ...fallbacks: T[]) { - super(schema, value, ...fallbacks); - } - } - - return GeneratedSchemaClass; -} diff --git a/packages/@ngtools/json-schema/src/schema-tree.spec.ts b/packages/@ngtools/json-schema/src/schema-tree.spec.ts deleted file mode 100644 index fb101763a42e..000000000000 --- a/packages/@ngtools/json-schema/src/schema-tree.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {readFileSync} from 'fs'; -import {join} from 'path'; - -import {RootSchemaTreeNode} from './schema-tree'; - - -describe('@ngtools/json-schema', () => { - - describe('OneOfSchemaTreeNode', () => { - const schemaJsonFilePath = join(__dirname, '../tests/schema1.json'); - const schemaJson = JSON.parse(readFileSync(schemaJsonFilePath, 'utf-8')); - const valueJsonFilePath = join(__dirname, '../tests/value1-1.json'); - const valueJson = JSON.parse(readFileSync(valueJsonFilePath, 'utf-8')); - - - it('works', () => { - const proto: any = Object.create(null); - // tslint:disable-next-line - new RootSchemaTreeNode(proto, { - value: valueJson, - schema: schemaJson - }); - - expect(proto.oneOfKey2 instanceof Array).toBe(true); - expect(proto.oneOfKey2.length).toBe(2); - - // Set it to a string, which is valid. - proto.oneOfKey2 = 'hello'; - expect(proto.oneOfKey2 instanceof Array).toBe(false); - }); - - it('returns undefined for values that are non-existent', () => { - const proto: any = Object.create(null); - const root = new RootSchemaTreeNode(proto, { value: valueJson, schema: schemaJson }); - - const value = root.children['objectKey1'].children['objectKey'].children['stringKey'].get(); - expect(value).toBe(undefined); - }); - }); - - - describe('EnumSchemaTreeNode', () => { - const schemaJsonFilePath = join(__dirname, '../tests/schema2.json'); - const schemaJson = JSON.parse(readFileSync(schemaJsonFilePath, 'utf-8')); - const valueJsonFilePath = join(__dirname, '../tests/value2-1.json'); - const valueJson = JSON.parse(readFileSync(valueJsonFilePath, 'utf-8')); - - - it('works', () => { - const proto: any = Object.create(null); - // tslint:disable-next-line - new RootSchemaTreeNode(proto, { - value: valueJson, - schema: schemaJson - }); - - expect(proto.a instanceof Array).toBe(true); - expect(proto.a).toEqual(['v1', 'v3']); - - // Set it to a string, which is valid. - proto.a[0] = 'v2'; - expect(proto.a).toEqual(['v2', 'v3']); - }); - - it('supports default values', () => { - const proto: any = Object.create(null); - const schema = new RootSchemaTreeNode(proto, { - value: valueJson, - schema: schemaJson - }); - - expect(schema.children['b'].get()).toEqual('default'); - }); - - - it('should throw error when setting invalid value', () => { - const proto: any = Object.create(null); - // tslint:disable-next-line - new RootSchemaTreeNode(proto, { - value: valueJson, - schema: schemaJson - }); - - try { - proto.a[0] = 'INVALID'; - } catch (error) { - expect(error.message).toBe('Invalid value can only be one of these: v1,v2,v3'); - } - }); - - }); - -}); diff --git a/packages/@ngtools/json-schema/src/schema-tree.ts b/packages/@ngtools/json-schema/src/schema-tree.ts deleted file mode 100644 index 674e31bb175d..000000000000 --- a/packages/@ngtools/json-schema/src/schema-tree.ts +++ /dev/null @@ -1,535 +0,0 @@ -import {JsonSchemaErrorBase} from './error'; -import {Serializer} from './serializer'; -import {SchemaNode, TypeScriptType} from './node'; - - -export class InvalidSchema extends JsonSchemaErrorBase {} -export class InvalidValueError extends JsonSchemaErrorBase {} -export class MissingImplementationError extends JsonSchemaErrorBase {} -export class SettingReadOnlyPropertyError extends JsonSchemaErrorBase {} -export class InvalidUpdateValue extends JsonSchemaErrorBase {} - -export interface Schema { - [key: string]: any; -} - - -/** This interface is defined to simplify the arguments passed in to the SchemaTreeNode. */ -export type TreeNodeConstructorArgument = { - parent?: SchemaTreeNode; - name?: string; - value: T; - forward?: SchemaTreeNode; - schema: Schema; -}; - - -/** - * Holds all the information, including the value, of a node in the schema tree. - */ -export abstract class SchemaTreeNode implements SchemaNode { - // Hierarchy objects - protected _parent: SchemaTreeNode; - - protected _defined = false; - protected _dirty = false; - - protected _schema: Schema; - protected _name: string; - - protected _value: T; - protected _forward: SchemaTreeNode; - - constructor(nodeMetaData: TreeNodeConstructorArgument) { - this._schema = nodeMetaData.schema; - this._name = nodeMetaData.name; - this._value = nodeMetaData.value; - this._forward = nodeMetaData.forward; - this._parent = nodeMetaData.parent; - } - dispose() { - this._parent = null; - this._schema = null; - this._value = null; - - if (this._forward) { - this._forward.dispose(); - } - this._forward = null; - } - - get defined() { return this._defined; } - get dirty() { return this._dirty; } - set dirty(v: boolean) { - if (v) { - this._defined = true; - this._dirty = true; - if (this._parent) { - this._parent.dirty = true; - } - } - } - - get value(): T { return this.get(); } - - abstract get type(): string; - abstract get tsType(): TypeScriptType; - abstract destroy(): void; - abstract get defaultValue(): any | null; - get name() { return this._name; } - get readOnly(): boolean { return this._schema['readOnly']; } - get frozen(): boolean { return true; } - get description() { - return 'description' in this._schema ? this._schema['description'] : null; - } - get required() { - if (!this._parent) { - return false; - } - return this._parent.isChildRequired(this.name); - } - - isChildRequired(_name: string) { return false; } - - get parent(): SchemaTreeNode { return this._parent; } - get children(): { [key: string]: SchemaTreeNode } | null { return null; } - get items(): SchemaTreeNode[] | null { return null; } - get itemPrototype(): SchemaTreeNode | null { return null; } - - abstract get(): T; - set(_v: T, _init = false, _force = false) { - if (!this.readOnly) { - throw new MissingImplementationError(); - } - throw new SettingReadOnlyPropertyError(); - } - isCompatible(_v: any) { return false; } - - abstract serialize(serializer: Serializer): void; - - protected static _defineProperty(proto: any, treeNode: SchemaTreeNode): void { - if (treeNode.readOnly) { - Object.defineProperty(proto, treeNode.name, { - enumerable: true, - get: () => treeNode.get() - }); - } else { - Object.defineProperty(proto, treeNode.name, { - enumerable: true, - get: () => treeNode.get(), - set: (v: T) => treeNode.set(v) - }); - } - } -} - - -/** Base Class used for Non-Leaves TreeNode. Meaning they can have children. */ -export abstract class NonLeafSchemaTreeNode extends SchemaTreeNode { - dispose() { - for (const key of Object.keys(this.children || {})) { - this.children[key].dispose(); - } - for (let item of this.items || []) { - item.dispose(); - } - super.dispose(); - } - - get() { - if (this.defined) { - return this._value; - } else { - return undefined; - } - } - - destroy() { - this._defined = false; - this._value = null; - } - - // Helper function to create a child based on its schema. - protected _createChildProperty(name: string, value: T, forward: SchemaTreeNode, - schema: Schema, define = true): SchemaTreeNode { - const type: string = - ('oneOf' in schema) ? 'oneOf' : - ('enum' in schema) ? 'enum' : schema['type']; - let Klass: { new (arg: TreeNodeConstructorArgument): SchemaTreeNode } = null; - - switch (type) { - case 'object': Klass = ObjectSchemaTreeNode; break; - case 'array': Klass = ArraySchemaTreeNode; break; - case 'string': Klass = StringSchemaTreeNode; break; - case 'boolean': Klass = BooleanSchemaTreeNode; break; - case 'number': Klass = NumberSchemaTreeNode; break; - case 'integer': Klass = IntegerSchemaTreeNode; break; - - case 'enum': Klass = EnumSchemaTreeNode; break; - case 'oneOf': Klass = OneOfSchemaTreeNode; break; - - default: - throw new InvalidSchema('Type ' + type + ' not understood by SchemaClassFactory.'); - } - - const metaData = new Klass({ parent: this, forward, value, schema, name }); - if (define) { - SchemaTreeNode._defineProperty(this._value, metaData); - } - return metaData; - } -} - - -export class OneOfSchemaTreeNode extends NonLeafSchemaTreeNode { - protected _typesPrototype: SchemaTreeNode[]; - protected _currentTypeHolder: SchemaTreeNode | null; - - constructor(metaData: TreeNodeConstructorArgument) { - super(metaData); - - let { value, forward, schema } = metaData; - this._typesPrototype = schema['oneOf'].map((schema: Object) => { - return this._createChildProperty('', '', forward, schema, false); - }); - - this._currentTypeHolder = null; - this._set(value, true, false); - } - - _set(v: any, init: boolean, force: boolean) { - if (!init && this.readOnly && !force) { - throw new SettingReadOnlyPropertyError(); - } - - // Find the first type prototype that is compatible with the - let proto: SchemaTreeNode = null; - for (let i = 0; i < this._typesPrototype.length; i++) { - const p = this._typesPrototype[i]; - if (p.isCompatible(v)) { - proto = p; - break; - } - } - if (proto == null) { - return; - } - - if (!init) { - this.dirty = true; - } - - this._currentTypeHolder = proto; - this._currentTypeHolder.set(v, false, true); - } - - set(v: any, _init = false, force = false) { - return this._set(v, false, force); - } - - get(): any { - return this._currentTypeHolder ? this._currentTypeHolder.get() : null; - } - get defaultValue(): any | null { - return null; - } - - get defined() { return this._currentTypeHolder ? this._currentTypeHolder.defined : false; } - get items() { return this._typesPrototype; } - get type() { return 'oneOf'; } - get tsType(): null { return null; } - - serialize(serializer: Serializer) { serializer.outputOneOf(this); } -} - - -/** A Schema Tree Node that represents an object. */ -export class ObjectSchemaTreeNode extends NonLeafSchemaTreeNode<{[key: string]: any}> { - // The map of all children metadata. - protected _children: { [key: string]: SchemaTreeNode }; - protected _frozen = false; - - constructor(metaData: TreeNodeConstructorArgument) { - super(metaData); - - this._set(metaData.value, true, false); - } - - _set(value: any, init: boolean, force: boolean) { - if (!init && this.readOnly && !force) { - throw new SettingReadOnlyPropertyError(); - } - - const schema = this._schema; - const forward = this._forward; - - this._defined = !!value; - this._children = Object.create(null); - this._value = Object.create(null); - this._dirty = this._dirty || !init; - - if (schema['properties']) { - for (const name of Object.keys(schema['properties'])) { - const propertySchema = schema['properties'][name]; - this._children[name] = this._createChildProperty( - name, - value ? value[name] : undefined, - forward ? (forward as ObjectSchemaTreeNode).children[name] : null, - propertySchema); - } - } else if (!schema['additionalProperties']) { - throw new InvalidSchema('Schema does not have a properties, but doesnt allow for ' - + 'additional properties.'); - } - - if (!schema['additionalProperties']) { - this._frozen = true; - Object.freeze(this._value); - Object.freeze(this._children); - } else if (value) { - // Set other properties which don't have a schema. - for (const key of Object.keys(value)) { - if (!this._children[key]) { - this._value[key] = value[key]; - } - } - } - } - - set(v: any, force = false) { - return this._set(v, false, force); - } - - get frozen(): boolean { return this._frozen; } - - get children(): { [key: string]: SchemaTreeNode } | null { return this._children; } - get type() { return 'object'; } - get tsType() { return Object; } - get defaultValue(): any | null { return null; } - - isCompatible(v: any) { return typeof v == 'object' && v !== null; } - isChildRequired(name: string) { - if (this._schema['required']) { - return this._schema['required'].indexOf(name) != -1; - } - return false; - } - - serialize(serializer: Serializer) { serializer.object(this); } -} - - -/** A Schema Tree Node that represents an array. */ -export class ArraySchemaTreeNode extends NonLeafSchemaTreeNode> { - // The map of all items metadata. - protected _items: SchemaTreeNode[]; - protected _itemPrototype: SchemaTreeNode; - - constructor(metaData: TreeNodeConstructorArgument>) { - super(metaData); - this._set(metaData.value, true, false); - - // Keep the item's schema as a schema node. This is important to keep type information. - this._itemPrototype = this._createChildProperty( - '', undefined, null, metaData.schema['items'], false); - } - - _set(value: any, init: boolean, _force: boolean) { - const schema = this._schema; - const forward = this._forward; - - this._value = Object.create(null); - this._dirty = this._dirty || !init; - - if (value) { - this._defined = true; - } else { - this._defined = false; - value = []; - } - this._items = []; - this._value = []; - - for (let index = 0; index < value.length; index++) { - this._items[index] = this._createChildProperty( - '' + index, - value && value[index], - forward && (forward as ArraySchemaTreeNode).items[index], - schema['items'] - ); - } - } - - set(v: any, init = false, force = false) { - return this._set(v, init, force); - } - - isCompatible(v: any) { return Array.isArray(v); } - get type() { return 'array'; } - get tsType() { return Array; } - get items(): SchemaTreeNode[] { return this._items; } - get itemPrototype(): SchemaTreeNode { return this._itemPrototype; } - get defaultValue(): any | null { return null; } - - serialize(serializer: Serializer) { serializer.array(this); } -} - - -/** - * The root class of the tree node. Receives a prototype that will be filled with the - * properties of the Schema root. - */ -export class RootSchemaTreeNode extends ObjectSchemaTreeNode { - constructor(proto: any, metaData: TreeNodeConstructorArgument) { - super(metaData); - - for (const key of Object.keys(this._children)) { - if (this._children[key]) { - SchemaTreeNode._defineProperty(proto, this._children[key]); - } - } - } -} - - -/** A leaf in the schema tree. Must contain a single primitive value. */ -export abstract class LeafSchemaTreeNode extends SchemaTreeNode { - protected _default: T; - - constructor(metaData: TreeNodeConstructorArgument) { - super(metaData); - this._defined = metaData.value !== undefined; - if ('default' in metaData.schema) { - this._default = this.convert(metaData.schema['default']); - } - } - - get() { - if (!this.defined && this._forward) { - return this._forward.get(); - } - if (!this.defined) { - return 'default' in this._schema ? this._default : undefined; - } - return this._value === undefined - ? undefined - : (this._value === null ? null : this.convert(this._value)); - } - set(v: T, init = false, force = false) { - if (this.readOnly && !force) { - throw new SettingReadOnlyPropertyError(); - } - - let convertedValue: T | null = this.convert(v); - if (convertedValue === null || convertedValue === undefined) { - if (this.required) { - throw new InvalidValueError(`Invalid value "${v}" on a required field.`); - } - } - - this.dirty = !init; - this._value = convertedValue; - } - - destroy() { - this._defined = false; - this._value = null; - } - - get defaultValue(): T { - return this.hasDefault ? this._default : null; - } - get hasDefault() { - return 'default' in this._schema; - } - - abstract convert(v: any): T; - abstract isCompatible(v: any): boolean; - - serialize(serializer: Serializer) { - serializer.outputValue(this); - } -} - - -/** Basic primitives for JSON Schema. */ -class StringSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer) { serializer.outputString(this); } - - isCompatible(v: any) { return typeof v == 'string' || v instanceof String; } - convert(v: any) { return v === undefined ? undefined : '' + v; } - get type() { return 'string'; } - get tsType() { return String; } -} - - -class EnumSchemaTreeNode extends LeafSchemaTreeNode { - constructor(metaData: TreeNodeConstructorArgument) { - super(metaData); - - if (!Array.isArray(metaData.schema['enum'])) { - throw new InvalidSchema(); - } - if (this.hasDefault && !this._isInEnum(this._default)) { - throw new InvalidSchema(); - } - this.set(metaData.value, true, true); - } - - protected _isInEnum(value: string) { - return this._schema['enum'].some((v: string) => v === value); - } - - get items() { return this._schema['enum']; } - - set(value: string, init = false, force = false) { - if (!(value === undefined || this._isInEnum(value))) { - throw new InvalidUpdateValue('Invalid value can only be one of these: ' + this.items); - } - super.set(value, init, force); - } - - isCompatible(v: any) { - return this._isInEnum(v); - } - convert(v: any) { - if (v === undefined) { - return undefined; - } - if (!this._isInEnum(v)) { - return undefined; - } - return v; - } - - get type() { - return this._schema['type'] || 'any'; - } - get tsType(): null { return null; } - serialize(serializer: Serializer) { serializer.outputEnum(this); } -} - - -class BooleanSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer) { serializer.outputBoolean(this); } - - isCompatible(v: any) { return typeof v == 'boolean' || v instanceof Boolean; } - convert(v: any) { return v === undefined ? undefined : !!v; } - get type() { return 'boolean'; } - get tsType() { return Boolean; } -} - - -class NumberSchemaTreeNode extends LeafSchemaTreeNode { - serialize(serializer: Serializer) { serializer.outputNumber(this); } - - isCompatible(v: any) { return typeof v == 'number' || v instanceof Number; } - convert(v: any) { return v === undefined ? undefined : +v; } - get type() { return 'number'; } - get tsType() { return Number; } -} - - -class IntegerSchemaTreeNode extends NumberSchemaTreeNode { - convert(v: any) { return v === undefined ? undefined : Math.floor(+v); } -} diff --git a/packages/@ngtools/json-schema/src/serializer.ts b/packages/@ngtools/json-schema/src/serializer.ts deleted file mode 100644 index 00b2370bc4e7..000000000000 --- a/packages/@ngtools/json-schema/src/serializer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {JsonSchemaErrorBase} from './error'; -import {SchemaNode} from './node'; -export class InvalidStateError extends JsonSchemaErrorBase {} - - -export interface WriterFn { - (str: string): void; -} - -export abstract class Serializer { - abstract start(): void; - abstract end(): void; - - abstract object(node: SchemaNode): void; - abstract property(node: SchemaNode): void; - abstract array(node: SchemaNode): void; - - abstract outputOneOf(node: SchemaNode): void; - abstract outputEnum(node: SchemaNode): void; - - abstract outputString(node: SchemaNode): void; - abstract outputNumber(node: SchemaNode): void; - abstract outputBoolean(node: SchemaNode): void; - - // Fallback when the value does not have metadata. - abstract outputValue(node: SchemaNode): void; -} diff --git a/packages/@ngtools/json-schema/src/serializers/dts.spec.ts b/packages/@ngtools/json-schema/src/serializers/dts.spec.ts deleted file mode 100644 index 69eb833e5242..000000000000 --- a/packages/@ngtools/json-schema/src/serializers/dts.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; - -import {DTsSerializer} from './dts'; -import {SchemaClassFactory} from '../schema-class-factory'; -import {RootSchemaTreeNode} from '../schema-tree'; - - -describe('DtsSerializer', () => { - for (const nb of [1, 2, 3]) { - it(`works (${nb})`, () => { - const schemaJsonFilePath = path.join(__dirname, `../../tests/serializer/schema${nb}.json`); - const schemaJson = JSON.parse(fs.readFileSync(schemaJsonFilePath, 'utf-8')); - const valueDTsFilePath = path.join(__dirname, `../../tests/serializer/value${nb}.d.ts`); - const valueDTs = fs.readFileSync(valueDTsFilePath, 'utf-8'); - const valueSourceFile = ts.createSourceFile('test.d.ts', valueDTs, ts.ScriptTarget.Latest); - - const schemaClass = new (SchemaClassFactory(schemaJson))({}); - const schema: RootSchemaTreeNode = schemaClass.$$schema(); - - let str = ''; - function writer(s: string) { - str += s; - } - - const serializer = new DTsSerializer(writer); - - serializer.start(); - schema.serialize(serializer); - serializer.end(); - - const sourceFile = ts.createSourceFile('test.d.ts', str, ts.ScriptTarget.Latest); - expect(sourceFile).toEqual(valueSourceFile); - }); - } -}); diff --git a/packages/@ngtools/json-schema/src/serializers/dts.ts b/packages/@ngtools/json-schema/src/serializers/dts.ts deleted file mode 100644 index 454a69f3e437..000000000000 --- a/packages/@ngtools/json-schema/src/serializers/dts.ts +++ /dev/null @@ -1,165 +0,0 @@ -import {SchemaNode} from '../node'; -import {Serializer, WriterFn, InvalidStateError} from '../serializer'; - - -interface DTsSerializerState { - empty?: boolean; - type?: string; - property?: boolean; -} - -export class DTsSerializer implements Serializer { - private _state: DTsSerializerState[] = []; - - constructor(private _writer: WriterFn, private interfaceName?: string, private _indentDelta = 4) { - if (interfaceName) { - _writer(`export interface ${interfaceName} `); - } else { - _writer('interface _ '); - } - } - - private _willOutputValue() { - if (this._state.length > 0) { - const top = this._top(); - top.empty = false; - - if (!top.property) { - this._indent(); - } - } - } - - private _top(): DTsSerializerState { - return this._state[this._state.length - 1] || {}; - } - - private _indent(): string { - if (this._indentDelta == 0) { - return; - } - - let str = '\n'; - let i = this._state.length * this._indentDelta; - while (i--) { - str += ' '; - } - this._writer(str); - } - - start() {} - end() { - if (this._indentDelta) { - this._writer('\n'); - } - if (!this.interfaceName) { - this._writer('export default _;\n'); - } - } - - object(node: SchemaNode) { - this._willOutputValue(); - - this._writer('{'); - - this._state.push({ empty: true, type: 'object' }); - for (const key of Object.keys(node.children)) { - this.property(node.children[key]); - } - - // Fallback to direct value output for additional properties. - if (!node.frozen) { - this._indent(); - this._writer('[name: string]: any;'); - } - this._state.pop(); - - if (!this._top().empty) { - this._indent(); - } - this._writer('}'); - } - - property(node: SchemaNode) { - this._willOutputValue(); - - if (node.description) { - this._writer('/**'); - this._indent(); - node.description.split('\n').forEach(line => { - this._writer(' * ' + line); - this._indent(); - }); - this._writer(' */'); - this._indent(); - } - - this._writer(node.name); - if (!node.required) { - this._writer('?'); - } - - this._writer(': '); - this._top().property = true; - node.serialize(this); - this._top().property = false; - this._writer(';'); - } - - array(node: SchemaNode) { - this._willOutputValue(); - - node.itemPrototype.serialize(this); - this._writer('[]'); - } - - outputOneOf(node: SchemaNode) { - this._willOutputValue(); - if (!node.items) { - throw new InvalidStateError(); - } - - this._writer('('); - for (let i = 0; i < node.items.length; i++) { - node.items[i].serialize(this); - if (i != node.items.length - 1) { - this._writer(' | '); - } - } - this._writer(')'); - } - - outputEnum(node: SchemaNode) { - this._willOutputValue(); - this._writer('('); - for (let i = 0; i < node.items.length; i++) { - this._writer(JSON.stringify(node.items[i])); - if (i != node.items.length - 1) { - this._writer(' | '); - } - } - this._writer(')'); - } - - outputValue(_node: SchemaNode) { - this._willOutputValue(); - this._writer('any'); - } - - outputString(_node: SchemaNode) { - this._willOutputValue(); - this._writer('string'); - } - outputNumber(_node: SchemaNode) { - this._willOutputValue(); - this._writer('number'); - } - outputInteger(_node: SchemaNode) { - this._willOutputValue(); - this._writer('number'); - } - outputBoolean(_node: SchemaNode) { - this._willOutputValue(); - this._writer('boolean'); - } -} diff --git a/packages/@ngtools/json-schema/src/serializers/json.spec.ts b/packages/@ngtools/json-schema/src/serializers/json.spec.ts deleted file mode 100644 index bf8465161a21..000000000000 --- a/packages/@ngtools/json-schema/src/serializers/json.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as path from 'path'; -import * as fs from 'fs'; - -import {JsonSerializer} from './json'; -import {SchemaClassFactory} from '../schema-class-factory'; -import {RootSchemaTreeNode} from '../schema-tree'; - - -describe('JsonSerializer', () => { - for (const nb of [1, 2, 3]) { - it(`works (${nb})`, () => { - const schemaJsonFilePath = path.join(__dirname, `../../tests/serializer/schema${nb}.json`); - const schemaJson = JSON.parse(fs.readFileSync(schemaJsonFilePath, 'utf-8')); - const valueJsonFilePath = path.join(__dirname, `../../tests/serializer/value${nb}.json`); - const valueJson = JSON.parse(fs.readFileSync(valueJsonFilePath, 'utf-8')); - - const schemaClass = new (SchemaClassFactory(schemaJson))(valueJson); - const schema: RootSchemaTreeNode = schemaClass.$$schema(); - - let str = ''; - function writer(s: string) { - str += s; - } - - const serializer = new JsonSerializer(writer); - - serializer.start(); - schema.serialize(serializer); - serializer.end(); - - expect(JSON.stringify(JSON.parse(str))).toEqual(JSON.stringify(valueJson)); - }); - } -}); diff --git a/packages/@ngtools/json-schema/src/serializers/json.ts b/packages/@ngtools/json-schema/src/serializers/json.ts deleted file mode 100644 index 2422bb969114..000000000000 --- a/packages/@ngtools/json-schema/src/serializers/json.ts +++ /dev/null @@ -1,159 +0,0 @@ -import {SchemaNode} from '../node'; -import {Serializer, WriterFn} from '../serializer'; - - -interface JsonSerializerState { - empty?: boolean; - type?: string; - property?: boolean; -} - -export class JsonSerializer implements Serializer { - private _state: JsonSerializerState[] = []; - - constructor(private _writer: WriterFn, private _indentDelta = 2) {} - - private _willOutputValue() { - if (this._state.length > 0) { - const top = this._top(); - - const wasEmpty = top.empty; - top.empty = false; - - if (!wasEmpty && !top.property) { - this._writer(','); - } - if (!top.property) { - this._indent(); - } - } - } - - private _top(): JsonSerializerState { - return this._state[this._state.length - 1] || {}; - } - - private _indent(): string { - if (this._indentDelta == 0) { - return; - } - - let str = '\n'; - let i = this._state.length * this._indentDelta; - while (i--) { - str += ' '; - } - this._writer(str); - } - - start() {} - end() { - if (this._indentDelta) { - this._writer('\n'); - } - } - - object(node: SchemaNode) { - if (node.defined == false) { - return; - } - - this._willOutputValue(); - - this._writer('{'); - this._state.push({ empty: true, type: 'object' }); - - for (const key of Object.keys(node.children)) { - this.property(node.children[key]); - } - - // Fallback to direct value output for additional properties. - if (!node.frozen) { - for (const key of Object.keys(node.value)) { - if (key in node.children) { - continue; - } - - this._willOutputValue(); - this._writer(JSON.stringify(key)); - this._writer(': '); - this._writer(JSON.stringify(node.value[key])); - } - } - - this._state.pop(); - - if (!this._top().empty) { - this._indent(); - } - this._writer('}'); - } - - property(node: SchemaNode) { - if (node.defined == false) { - return; - } - - this._willOutputValue(); - - this._writer(JSON.stringify(node.name)); - this._writer(': '); - this._top().property = true; - node.serialize(this); - this._top().property = false; - } - - array(node: SchemaNode) { - if (node.defined == false) { - return; - } - - this._willOutputValue(); - - if (node.items.length === 0) { - this._writer('[]'); - return; - } - - this._writer('['); - this._state.push({ empty: true, type: 'array' }); - for (let i = 0; i < node.items.length; i++) { - node.items[i].serialize(this); - } - this._state.pop(); - - if (!this._top().empty) { - this._indent(); - } - this._writer(']'); - } - - outputOneOf(node: SchemaNode) { - this.outputValue(node); - } - outputEnum(node: SchemaNode) { - this.outputValue(node); - } - - outputValue(node: SchemaNode) { - this._willOutputValue(); - this._writer(JSON.stringify(node.value, null, this._indentDelta)); - } - - outputString(node: SchemaNode) { - this._willOutputValue(); - this._writer(JSON.stringify(node.value)); - } - outputNumber(node: SchemaNode) { - this._willOutputValue(); - this._writer(JSON.stringify(node.value)); - } - outputInteger(node: SchemaNode) { - this._willOutputValue(); - this._writer(JSON.stringify(node.value)); - } - outputBoolean(node: SchemaNode) { - this._willOutputValue(); - this._writer(JSON.stringify(node.value)); - } -} diff --git a/packages/@ngtools/json-schema/tests/schema1.json b/packages/@ngtools/json-schema/tests/schema1.json deleted file mode 100644 index 983feceb8bcd..000000000000 --- a/packages/@ngtools/json-schema/tests/schema1.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "JsonSchema", - "type": "object", - "properties": { - "requiredKey": { - "type": "number" - }, - "stringKeyDefault": { - "type": "string", - "default": "defaultValue" - }, - "stringKey": { - "type": "string" - }, - "booleanKey": { - "type": "boolean" - }, - "numberKey": { - "type": "number" - }, - "oneOfKey1": { - "oneOf": [ - { "type": "string" }, - { "type": "number" } - ] - }, - "oneOfKey2": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" } } - ] - }, - "objectKey1": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - }, - "objectKey": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - } - }, - "objectKey2": { - "type": "object", - "properties": { - "stringKey": { - "type": "string", - "default": "default objectKey2.stringKey" - } - }, - "additionalProperties": true - }, - "arrayKey1": { - "type": "array", - "items": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - }, - "arrayKey2": { - "type": "array", - "items": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - } - }, - "required": ["requiredKey"] -} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/schema2.json b/packages/@ngtools/json-schema/tests/schema2.json deleted file mode 100644 index c9a8f1bcf755..000000000000 --- a/packages/@ngtools/json-schema/tests/schema2.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "JsonSchema", - "type": "object", - "properties": { - "a": { - "type": "array", - "items": { - "enum": [ "v1", "v2", "v3" ] - } - }, - "b": { - "enum": [ "default", "v1", "v2" ], - "default": "default" - } - } -} diff --git a/packages/@ngtools/json-schema/tests/serializer/schema1.json b/packages/@ngtools/json-schema/tests/serializer/schema1.json deleted file mode 100644 index 983feceb8bcd..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/schema1.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "JsonSchema", - "type": "object", - "properties": { - "requiredKey": { - "type": "number" - }, - "stringKeyDefault": { - "type": "string", - "default": "defaultValue" - }, - "stringKey": { - "type": "string" - }, - "booleanKey": { - "type": "boolean" - }, - "numberKey": { - "type": "number" - }, - "oneOfKey1": { - "oneOf": [ - { "type": "string" }, - { "type": "number" } - ] - }, - "oneOfKey2": { - "oneOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" } } - ] - }, - "objectKey1": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - }, - "objectKey": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - } - }, - "objectKey2": { - "type": "object", - "properties": { - "stringKey": { - "type": "string", - "default": "default objectKey2.stringKey" - } - }, - "additionalProperties": true - }, - "arrayKey1": { - "type": "array", - "items": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - }, - "arrayKey2": { - "type": "array", - "items": { - "type": "object", - "properties": { - "stringKey": { - "type": "string" - } - } - } - } - }, - "required": ["requiredKey"] -} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/serializer/schema2.json b/packages/@ngtools/json-schema/tests/serializer/schema2.json deleted file mode 100644 index 989f924b8ecf..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/schema2.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "JsonSchema", - "type": "object", - "properties": { - "a": { - "type": "array", - "items": { - "enum": [ "v1", "v2", "v3" ] - } - }, - "b": { - "type": "array", - "items": { - "enum": [ 0, 1, "string", true, null ] - } - } - } -} diff --git a/packages/@ngtools/json-schema/tests/serializer/schema3.json b/packages/@ngtools/json-schema/tests/serializer/schema3.json deleted file mode 100644 index cf863abc8506..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/schema3.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "$comment": "Please run `npm run build-config-interface` after changing this file. Thanks!", - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "CliConfig", - "title": "Angular CLI Config Schema", - "type": "object", - "properties": { - "project": { - "description": "The global configuration of the project.", - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "additionalProperties": false - }, - "apps": { - "description": "Properties of the different applications in this project.", - "type": "array", - "items": { - "type": "object", - "properties": { - "root": { - "type": "string" - }, - "outDir": { - "type": "string", - "default": "dist/" - }, - "assets": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "default": [] - }, - "deployUrl": { - "type": "string" - }, - "index": { - "type": "string", - "default": "index.html" - }, - "main": { - "type": "string" - }, - "test": { - "type": "string" - }, - "tsconfig": { - "type": "string", - "default": "tsconfig.json" - }, - "prefix": { - "type": "string" - }, - "styles": { - "description": "Global styles to be included in the build.", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "input": { - "type": "string" - } - }, - "additionalProperties": true - } - ] - }, - "additionalProperties": false - }, - "scripts": { - "description": "Global scripts to be included in the build.", - "type": "array", - "items": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "input": { - "type": "string" - } - }, - "additionalProperties": true, - "required": ["input"] - } - ] - }, - "additionalProperties": false - }, - "environments": { - "description": "Name and corresponding file for environment config.", - "type": "object", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - "additionalProperties": false - }, - "addons": { - "description": "Configuration reserved for installed third party addons.", - "type": "array", - "items": { - "type": "object", - "properties": {}, - "additionalProperties": true - } - }, - "packages": { - "description": "Configuration reserved for installed third party packages.", - "type": "array", - "items": { - "type": "object", - "properties": {}, - "additionalProperties": true - } - }, - "e2e": { - "type": "object", - "properties": { - "protractor": { - "type": "object", - "properties": { - "config": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "test": { - "type": "object", - "properties": { - "karma": { - "type": "object", - "properties": { - "config": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "defaults": { - "type": "object", - "properties": { - "styleExt": { - "type": "string" - }, - "prefixInterfaces": { - "type": "boolean" - }, - "poll": { - "type": "number" - }, - "viewEncapsulation": { - "type": "string" - }, - "changeDetection": { - "type": "string" - }, - "inline": { - "type": "object", - "properties": { - "style": { - "type": "boolean", - "default": false - }, - "template": { - "type": "boolean", - "default": false - } - } - }, - "spec": { - "type": "object", - "properties": { - "class": { - "type": "boolean", - "default": false - }, - "component": { - "type": "boolean", - "default": true - }, - "directive": { - "type": "boolean", - "default": true - }, - "module": { - "type": "boolean", - "default": false - }, - "pipe": { - "type": "boolean", - "default": true - }, - "service": { - "type": "boolean", - "default": true - } - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false -} diff --git a/packages/@ngtools/json-schema/tests/serializer/value1.d.ts b/packages/@ngtools/json-schema/tests/serializer/value1.d.ts deleted file mode 100644 index f4ac4a1ff47e..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/value1.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -interface _ { - requiredKey: number; - stringKeyDefault?: string; - stringKey?: string; - booleanKey?: boolean; - numberKey?: number; - oneOfKey1?: (string | number); - oneOfKey2?: (string | string[]); - objectKey1?: { - stringKey?: string; - objectKey?: { - stringKey?: string; - }; - }; - objectKey2?: { - stringKey?: string; - [name: string]: any; - }; - arrayKey1?: { - stringKey?: string; - }[]; - arrayKey2?: { - stringKey?: string; - }[]; -} -export default _; diff --git a/packages/@ngtools/json-schema/tests/serializer/value1.json b/packages/@ngtools/json-schema/tests/serializer/value1.json deleted file mode 100644 index 31f049534148..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/value1.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "requiredKey": 1, - "arrayKey2": [ - { "stringKey": "value1" }, - { "stringKey": "value2" } - ] -} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/serializer/value2.d.ts b/packages/@ngtools/json-schema/tests/serializer/value2.d.ts deleted file mode 100644 index 81ffef785d80..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/value2.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -interface _ { - a?: ("v1" | "v2" | "v3")[]; - b?: (0 | 1 | "string" | true | null)[]; -} -export default _; diff --git a/packages/@ngtools/json-schema/tests/serializer/value2.json b/packages/@ngtools/json-schema/tests/serializer/value2.json deleted file mode 100644 index bb8bdf0300c6..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/value2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "a": [ - "v2", - "v1", - "v2", - "v3" - ], - "b": [ - 1, - null, - "string", - true - ] -} diff --git a/packages/@ngtools/json-schema/tests/serializer/value3.d.ts b/packages/@ngtools/json-schema/tests/serializer/value3.d.ts deleted file mode 100644 index 150edbc203ee..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/value3.d.ts +++ /dev/null @@ -1,85 +0,0 @@ -interface _ { - /** - * The global configuration of the project. - */ - project?: { - version?: string; - name?: string; - }; - /** - * Properties of the different applications in this project. - */ - apps?: { - root?: string; - outDir?: string; - assets?: (string | string[]); - deployUrl?: string; - index?: string; - main?: string; - test?: string; - tsconfig?: string; - prefix?: string; - /** - * Global styles to be included in the build. - */ - styles?: (string | { - input?: string; - [name: string]: any; - })[]; - /** - * Global scripts to be included in the build. - */ - scripts?: (string | { - input: string; - [name: string]: any; - })[]; - /** - * Name and corresponding file for environment config. - */ - environments?: { - [name: string]: any; - }; - }[]; - /** - * Configuration reserved for installed third party addons. - */ - addons?: { - [name: string]: any; - }[]; - /** - * Configuration reserved for installed third party packages. - */ - packages?: { - [name: string]: any; - }[]; - e2e?: { - protractor?: { - config?: string; - }; - }; - test?: { - karma?: { - config?: string; - }; - }; - defaults?: { - styleExt?: string; - prefixInterfaces?: boolean; - poll?: number; - viewEncapsulation?: string; - changeDetection?: string; - inline?: { - style?: boolean; - template?: boolean; - }; - spec?: { - class?: boolean; - component?: boolean; - directive?: boolean; - module?: boolean; - pipe?: boolean; - service?: boolean; - }; - }; -} -export default _; diff --git a/packages/@ngtools/json-schema/tests/serializer/value3.json b/packages/@ngtools/json-schema/tests/serializer/value3.json deleted file mode 100644 index 1ab96e50648d..000000000000 --- a/packages/@ngtools/json-schema/tests/serializer/value3.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "project": { - "version": "<%= version %>", - "name": "<%= htmlComponentName %>" - }, - "apps": [ - { - "root": "<%= sourceDir %>", - "outDir": "dist", - "assets": [ - "assets", - "favicon.ico" - ], - "index": "index.html", - "main": "main.ts", - "test": "test.ts", - "tsconfig": "tsconfig.json", - "prefix": "<%= prefix %>", - "styles": [ - "styles.<%= styleExt %>" - ], - "scripts": [], - "environments": { - "source": "environments/environment.ts", - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" - } - } - ], - "e2e": { - "protractor": { - "config": "./protractor.conf.js" - } - }, - "test": { - "karma": { - "config": "./karma.conf.js" - } - }, - "defaults": { - "styleExt": "<%= styleExt %>", - "prefixInterfaces": false, - "inline": { - "style": false, - "template": false - }, - "spec": { - "class": false, - "component": true, - "directive": false, - "module": false, - "pipe": true, - "service": false - } - } -} diff --git a/packages/@ngtools/json-schema/tests/value1-1.json b/packages/@ngtools/json-schema/tests/value1-1.json deleted file mode 100644 index 4f2d08d38fb4..000000000000 --- a/packages/@ngtools/json-schema/tests/value1-1.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "requiredKey": 1, - "arrayKey2": [ - { "stringKey": "value1" }, - { "stringKey": "value2" } - ], - "oneOfKey2": [ "hello", "world" ] -} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/value1.json b/packages/@ngtools/json-schema/tests/value1.json deleted file mode 100644 index 31f049534148..000000000000 --- a/packages/@ngtools/json-schema/tests/value1.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "requiredKey": 1, - "arrayKey2": [ - { "stringKey": "value1" }, - { "stringKey": "value2" } - ] -} \ No newline at end of file diff --git a/packages/@ngtools/json-schema/tests/value2-1.json b/packages/@ngtools/json-schema/tests/value2-1.json deleted file mode 100644 index 0bd3cff6754c..000000000000 --- a/packages/@ngtools/json-schema/tests/value2-1.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "a": [ - "v1", - "v3" - ] -} diff --git a/packages/@ngtools/json-schema/tsconfig.json b/packages/@ngtools/json-schema/tsconfig.json deleted file mode 100644 index 775e35fdba6f..000000000000 --- a/packages/@ngtools/json-schema/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - - "compilerOptions": { - "outDir": "../../../dist/@ngtools/json-schema", - "rootDir": ".", - "baseUrl": "" - } -} diff --git a/packages/@ngtools/logger/package.json b/packages/@ngtools/logger/package.json deleted file mode 100644 index 638dc4596df4..000000000000 --- a/packages/@ngtools/logger/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@ngtools/logger", - "version": "1.1.2", - "description": "", - "main": "./src/index.js", - "typings": "./src/index.d.ts", - "license": "MIT", - "keywords": [ - "reporter", - "logger", - "rxjs", - "typescript", - "log" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli/tree/master/packages/@ngtools/logger", - "engines": { - "node": ">= 6.9.0", - "npm": ">= 3.0.0" - }, - "dependencies": { - "rxjs": "^5.0.1" - } -} diff --git a/packages/@ngtools/logger/src/console-logger-stack.spec.ts b/packages/@ngtools/logger/src/console-logger-stack.spec.ts deleted file mode 100644 index f408c44e8f70..000000000000 --- a/packages/@ngtools/logger/src/console-logger-stack.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {LogEntry, Logger} from './logger'; -import {ConsoleLoggerStack} from './console-logger-stack'; -import {NullLogger} from './null-logger'; - - -describe('ConsoleLoggerStack', () => { - it('works', (done: DoneFn) => { - const logger = ConsoleLoggerStack.start('test'); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'test' }), - jasmine.objectContaining({ message: 'world', level: 'info', name: 'test' }), - ]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - (console as any).debug('hello'); - console.log('world'); - ConsoleLoggerStack.end(); - }); - - it('works as a stack', (done: DoneFn) => { - const oldConsoleLog = console.log; - const logger = ConsoleLoggerStack.start('test'); - expect(console.log).not.toBe(oldConsoleLog); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'red', level: 'info', name: 'test' }), - jasmine.objectContaining({ message: 'blue', level: 'info', name: 'test2' }), - jasmine.objectContaining({ message: 'yellow', level: 'info', name: 'test3' }), - jasmine.objectContaining({ message: 'green', level: 'info', name: 'test2' }), - ]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - console.log('red'); - ConsoleLoggerStack.push('test2'); - console.log('blue'); - ConsoleLoggerStack.push('test3'); - console.log('yellow'); - ConsoleLoggerStack.pop(); - console.log('green'); - ConsoleLoggerStack.end(); - expect(console.log).toBe(oldConsoleLog); - }); - - it('can push instances or classes', (done: DoneFn) => { - const oldConsoleLog = console.log; - const logger = new Logger('test'); - ConsoleLoggerStack.start(logger); - expect(console.log).not.toBe(oldConsoleLog); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'red', level: 'info', name: 'test' }), - jasmine.objectContaining({ message: 'green', level: 'info', name: 'test2' }), - ]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - console.log('red'); - ConsoleLoggerStack.push(new NullLogger(logger)); - console.log('blue'); - ConsoleLoggerStack.pop(); - ConsoleLoggerStack.push(new Logger('test2', logger)); - console.log('green'); - ConsoleLoggerStack.end(); - expect(console.log).toBe(oldConsoleLog); - }); -}); diff --git a/packages/@ngtools/logger/src/console-logger-stack.ts b/packages/@ngtools/logger/src/console-logger-stack.ts deleted file mode 100644 index 1814e864baa4..000000000000 --- a/packages/@ngtools/logger/src/console-logger-stack.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {Logger} from './logger'; - -type ConsoleWriter = (message?: any, ...optionalParams: any[]) => void; - -let globalConsoleStack: Logger[] | null = null; -let originalConsoleDebug: ConsoleWriter; -let originalConsoleLog: ConsoleWriter; -let originalConsoleWarn: ConsoleWriter; -let originalConsoleError: ConsoleWriter; - - -function _push(logger: Logger): Logger { - if (!globalConsoleStack) { - throw new Error('ConsoleLoggerStack must be started before pushing a logger.'); - } - - if (globalConsoleStack.length == 0) { - originalConsoleDebug = (console as any).debug; // Some environment (node) don't have debug. - originalConsoleLog = console.log; - originalConsoleWarn = console.warn; - originalConsoleError = console.error; - - (console as any).debug = (msg: string, ...args: any[]) => { - const logger = ConsoleLoggerStack.top(); - if (logger) { - logger.debug(msg, { args }); - } - }; - console.log = (msg: string, ...args: any[]) => { - const logger = ConsoleLoggerStack.top(); - if (logger) { - logger.info(msg, { args }); - } - }; - console.warn = (msg: string, ...args: any[]) => { - const logger = ConsoleLoggerStack.top(); - if (logger) { - logger.warn(msg, { args }); - } - }; - console.error = (msg: string, ...args: any[]) => { - const logger = ConsoleLoggerStack.top(); - if (logger) { - logger.error(msg, { args }); - } - }; - } - globalConsoleStack.push(logger); - - return logger; -} - -function _pop() { - if (!globalConsoleStack) { - return; - } - globalConsoleStack[globalConsoleStack.length - 1].complete(); - globalConsoleStack.pop(); - if (globalConsoleStack.length == 0) { - console.log = originalConsoleLog; - console.warn = originalConsoleWarn; - console.error = originalConsoleError; - (console as any).debug = originalConsoleDebug; // Some environment (node) don't have debug. - globalConsoleStack = null; - } -} - - -export type LoggerConstructor = { - new (...args: any[]): T; -}; - - -export class ConsoleLoggerStack { - static push(name: string): Logger; - static push(logger: Logger): Logger; - static push(loggerClass: LoggerConstructor, ...args: any[]): Logger; - static push(nameOrLogger: string | Logger | LoggerConstructor = '', - ...args: any[]): Logger { - if (typeof nameOrLogger == 'string') { - return _push(new Logger(nameOrLogger, this.top())); - } else if (nameOrLogger instanceof Logger) { - if (nameOrLogger.parent !== this.top()) { - throw new Error('Pushing a logger that is not a direct child of the top of the stack.'); - } - return _push(nameOrLogger); - } else { - return _push(new nameOrLogger(...args, this.top())); - } - } - static pop(): Logger | null { - _pop(); - return this.top(); - } - - static top(): Logger | null { - return globalConsoleStack && globalConsoleStack[globalConsoleStack.length - 1]; - } - - static start(name: string): Logger; - static start(logger: Logger): Logger; - static start(loggerClass: LoggerConstructor, ...args: any[]): Logger; - static start(nameOrLogger: string | Logger | LoggerConstructor = '', - ...args: any[]): Logger { - if (globalConsoleStack !== null) { - throw new Error('Cannot start a new console logger stack while one is already going.'); - } - - globalConsoleStack = []; - if (typeof nameOrLogger == 'string') { - return _push(new Logger(nameOrLogger, this.top())); - } else if (nameOrLogger instanceof Logger) { - return _push(nameOrLogger); - } else { - return _push(new nameOrLogger(...args, this.top())); - } - } - static end() { - while (globalConsoleStack !== null) { - this.pop(); - } - } -} diff --git a/packages/@ngtools/logger/src/indent.spec.ts b/packages/@ngtools/logger/src/indent.spec.ts deleted file mode 100644 index aae7a897c6eb..000000000000 --- a/packages/@ngtools/logger/src/indent.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {LogEntry, Logger} from './logger'; -import {IndentLogger} from './indent'; - - -describe('IndentSpec', () => { - it('works', (done: DoneFn) => { - const logger = new IndentLogger('test'); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'test', level: 'info', name: 'test' }), - jasmine.objectContaining({ message: ' test2', level: 'info', name: 'test2' }), - jasmine.objectContaining({ message: ' test3', level: 'info', name: 'test3' }), - jasmine.objectContaining({ message: ' test4', level: 'info', name: 'test4' }), - jasmine.objectContaining({ message: 'test5', level: 'info', name: 'test' }), - ]); - }) - .then(() => done(), (err: any) => done.fail(err)); - const logger2 = new Logger('test2', logger); - const logger3 = new Logger('test3', logger2); - const logger4 = new Logger('test4', logger); - - logger.info('test'); - logger2.info('test2'); - logger3.info('test3'); - logger4.info('test4'); - logger.info('test5'); - - logger.complete(); - }); -}); diff --git a/packages/@ngtools/logger/src/indent.ts b/packages/@ngtools/logger/src/indent.ts deleted file mode 100644 index 98cdc21f77cb..000000000000 --- a/packages/@ngtools/logger/src/indent.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {Logger} from './logger'; - -import 'rxjs/add/operator/map'; - - -/** - * Keep an map of indentation => array of indentations based on the level. - * This is to optimize calculating the prefix based on the indentation itself. Since most logs - * come from similar levels, and with similar indentation strings, this will be shared by all - * loggers. Also, string concatenation is expensive so performing concats for every log entries - * is expensive; this alleviates it. - */ -const indentationMap: {[indentationType: string]: string[]} = {}; - - -export class IndentLogger extends Logger { - constructor(name: string, parent: Logger | null = null, indentation = ' ') { - super(name, parent); - - indentationMap[indentation] = indentationMap[indentation] || ['']; - const map = indentationMap[indentation]; - - this._observable = this._observable.map(entry => { - const l = entry.path.length; - if (l >= map.length) { - let current = map[map.length - 1]; - while (l >= map.length) { - current += indentation; - map.push(current); - } - } - - entry.message = map[l] + entry.message; - return entry; - }); - } -} diff --git a/packages/@ngtools/logger/src/index.ts b/packages/@ngtools/logger/src/index.ts deleted file mode 100644 index 2c16a440a527..000000000000 --- a/packages/@ngtools/logger/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ - -export * from './console-logger-stack'; -export * from './indent'; -export * from './logger'; -export * from './null-logger'; -export * from './transform-logger'; diff --git a/packages/@ngtools/logger/src/logger.spec.ts b/packages/@ngtools/logger/src/logger.spec.ts deleted file mode 100644 index 2cc9ed4bad72..000000000000 --- a/packages/@ngtools/logger/src/logger.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {Logger, JsonValue} from './logger'; -import 'rxjs/add/operator/toArray'; -import 'rxjs/add/operator/toPromise'; - - -describe('Logger', () => { - it('works', (done: DoneFn) => { - const logger = new Logger('test'); - logger - .toArray() - .toPromise() - .then((observed: JsonValue[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'test' }), - jasmine.objectContaining({ message: 'world', level: 'info', name: 'test' }), - ]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - logger.debug('hello'); - logger.info('world'); - logger.complete(); - }); - - it('works with children', (done: DoneFn) => { - const logger = new Logger('test'); - let hasCompleted = false; - logger - .toArray() - .toPromise() - .then((observed: JsonValue[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'child' }), - jasmine.objectContaining({ message: 'world', level: 'info', name: 'child' }), - ]); - expect(hasCompleted).toBe(true); - }) - .then(() => done(), (err: any) => done.fail(err)); - - const childLogger = new Logger('child', logger); - childLogger.subscribe(undefined, undefined, () => hasCompleted = true); - childLogger.debug('hello'); - childLogger.info('world'); - logger.complete(); - }); -}); diff --git a/packages/@ngtools/logger/src/logger.ts b/packages/@ngtools/logger/src/logger.ts deleted file mode 100644 index 716c3b3ff9dc..000000000000 --- a/packages/@ngtools/logger/src/logger.ts +++ /dev/null @@ -1,122 +0,0 @@ -import {Observable} from 'rxjs/Observable'; -import {Operator} from 'rxjs/Operator'; -import {PartialObserver} from 'rxjs/Observer'; -import {Subject} from 'rxjs/Subject'; -import {Subscription} from 'rxjs/Subscription'; - - -export type JsonValue = boolean | number | string | JsonObject | JsonArray; -export interface JsonObject { - [key: string]: JsonValue; -} -export interface JsonArray extends Array {} - -export interface LoggerMetadata extends JsonObject { - name: string; - path: string[]; -} -export interface LogEntry extends LoggerMetadata { - level: LogLevel; - message: string; - timestamp: number; -} - -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - - -export class Logger extends Observable { - protected readonly _subject: Subject = new Subject(); - protected _metadata: LoggerMetadata; - - private _obs: Observable; - private _subscription: Subscription | null; - - protected get _observable() { return this._obs; } - protected set _observable(v: Observable) { - if (this._subscription) { - this._subscription.unsubscribe(); - } - this._obs = v; - if (this.parent) { - this._subscription = this.subscribe((value: LogEntry) => { - if (this.parent) { - this.parent._subject.next(value); - } - }, (error: any) => { - if (this.parent) { - this.parent._subject.error(error); - } - }, () => { - if (this._subscription) { - this._subscription.unsubscribe(); - } - this._subscription = null; - }); - } - } - - constructor(public readonly name: string, public readonly parent: Logger | null = null) { - super(); - - let path: string[] = []; - let p = parent; - while (p) { - path.push(p.name); - p = p.parent; - } - this._metadata = { name, path }; - this._observable = this._subject.asObservable(); - if (this.parent) { - // When the parent completes, complete us as well. - this.parent._subject.subscribe(undefined, undefined, () => this.complete()); - } - } - - complete() { - this._subject.complete(); - } - - log(level: LogLevel, message: string, metadata: JsonObject = {}): void { - const entry: LogEntry = Object.assign({}, this._metadata, metadata, { - level, message, timestamp: +Date.now() - }); - this._subject.next(entry); - } - - debug(message: string, metadata: JsonObject = {}) { - return this.log('debug', message, metadata); - } - info(message: string, metadata: JsonObject = {}) { - return this.log('info', message, metadata); - } - warn(message: string, metadata: JsonObject = {}) { - return this.log('warn', message, metadata); - } - error(message: string, metadata: JsonObject = {}) { - return this.log('error', message, metadata); - } - fatal(message: string, metadata: JsonObject = {}) { - return this.log('fatal', message, metadata); - } - - toString() { - return ``; - } - - lift(operator: Operator): Observable { - return this._observable.lift(operator); - } - - subscribe(): Subscription; - subscribe(observer: PartialObserver): Subscription; - subscribe(next?: (value: LogEntry) => void, error?: (error: any) => void, - complete?: () => void): Subscription; - subscribe(_observerOrNext?: PartialObserver | ((value: LogEntry) => void), - _error?: (error: any) => void, - _complete?: () => void): Subscription { - return this._observable.subscribe.apply(this._observable, arguments); - } - forEach(next: (value: LogEntry) => void, PromiseCtor?: typeof Promise): Promise { - return this._observable.forEach(next, PromiseCtor); - } -} diff --git a/packages/@ngtools/logger/src/null-logger.spec.ts b/packages/@ngtools/logger/src/null-logger.spec.ts deleted file mode 100644 index 3b66f4a39dfe..000000000000 --- a/packages/@ngtools/logger/src/null-logger.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {NullLogger} from './null-logger'; -import {LogEntry, Logger} from './logger'; - - -describe('NullLogger', () => { - it('works', (done: DoneFn) => { - const logger = new NullLogger(); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - logger.debug('hello'); - logger.info('world'); - logger.complete(); - }); - - it('nullifies children', (done: DoneFn) => { - const logger = new Logger('test'); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - const nullLogger = new NullLogger(logger); - const child = new Logger('test', nullLogger); - child.debug('hello'); - child.info('world'); - logger.complete(); - }); -}); diff --git a/packages/@ngtools/logger/src/null-logger.ts b/packages/@ngtools/logger/src/null-logger.ts deleted file mode 100644 index e01b4229163e..000000000000 --- a/packages/@ngtools/logger/src/null-logger.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {Logger} from './logger'; - -import {Observable} from 'rxjs/Observable'; - -import 'rxjs/add/observable/empty'; - - -export class NullLogger extends Logger { - constructor(parent: Logger | null = null) { - super('', parent); - this._observable = Observable.empty(); - } -} diff --git a/packages/@ngtools/logger/src/transform-logger.spec.ts b/packages/@ngtools/logger/src/transform-logger.spec.ts deleted file mode 100644 index f0cf572e7023..000000000000 --- a/packages/@ngtools/logger/src/transform-logger.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {TransformLogger} from './transform-logger'; -import {LogEntry} from './logger'; - -import 'rxjs/add/operator/filter'; -import 'rxjs/add/operator/map'; - - -describe('TransformLogger', () => { - it('works', (done: DoneFn) => { - const logger = new TransformLogger('test', stream => { - return stream - .filter(entry => entry.message != 'hello') - .map(entry => { - entry.message += '1'; - return entry; - }); - }); - logger - .toArray() - .toPromise() - .then((observed: LogEntry[]) => { - expect(observed).toEqual([ - jasmine.objectContaining({ message: 'world1', level: 'info', name: 'test' }), - ]); - }) - .then(() => done(), (err: any) => done.fail(err)); - - logger.debug('hello'); - logger.info('world'); - logger.complete(); - }); -}); diff --git a/packages/@ngtools/logger/src/transform-logger.ts b/packages/@ngtools/logger/src/transform-logger.ts deleted file mode 100644 index e4d0a1fb7fcc..000000000000 --- a/packages/@ngtools/logger/src/transform-logger.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {Observable} from 'rxjs/Observable'; - -import {Logger, LogEntry} from './logger'; - - -export class TransformLogger extends Logger { - constructor(name: string, - transform: (stream: Observable) => Observable, - parent: Logger | null = null) { - super(name, parent); - this._observable = transform(this._observable); - } -} diff --git a/packages/@ngtools/logger/tsconfig.json b/packages/@ngtools/logger/tsconfig.json deleted file mode 100644 index 7019e572b507..000000000000 --- a/packages/@ngtools/logger/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - - "compilerOptions": { - "outDir": "../../../dist/@ngtools/logger", - "rootDir": ".", - "baseUrl": "" - } -} diff --git a/packages/@ngtools/webpack/README.md b/packages/@ngtools/webpack/README.md deleted file mode 100644 index bc34b62ede74..000000000000 --- a/packages/@ngtools/webpack/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Angular Ahead-of-Time Webpack Plugin - -Webpack plugin that AoT compiles your Angular components and modules. - -## Usage -In your webpack config, add the following plugin and loader: - -```typescript -import {AotPlugin} from '@ngtools/webpack' - -exports = { /* ... */ - module: { - rules: [ - { - test: /\.ts$/, - loader: '@ngtools/webpack', - } - ] - }, - - plugins: [ - new AotPlugin({ - tsConfigPath: 'path/to/tsconfig.json', - entryModule: 'path/to/app.module#AppModule' - }) - ] -} -``` - -The loader works with the webpack plugin to compile your TypeScript. It's important to include both, and to not include any other TypeScript compiler loader. - -## Options - -* `tsConfigPath`. The path to the `tsconfig.json` file. This is required. In your `tsconfig.json`, you can pass options to the Angular Compiler with `angularCompilerOptions`. -* `basePath`. Optional. The root to use by the compiler to resolve file paths. By default, use the `tsConfigPath` root. -* `entryModule`. Optional if specified in `angularCompilerOptions`. The path and classname of the main application module. This follows the format `path/to/file#ClassName`. -* `mainPath`. Optional if `entryModule` is specified. The `main.ts` file containing the bootstrap code. The plugin will use AST to determine the `entryModule`. -* `skipCodeGeneration`. Optional, defaults to false. Disable code generation and do not refactor the code to bootstrap. This replaces `templateUrl: "string"` with `template: require("string")` (and similar for styles) to allow for webpack to properly link the resources. -* `typeChecking`. Optional, defaults to true. Enable type checking through your application. This will slow down compilation, but show syntactic and semantic errors in webpack. -* `exclude`. Optional. Extra files to exclude from TypeScript compilation. -* `compilerOptions`. Optional. Override options in `tsconfig.json`. - -## Features -The benefits and ability of using [`@ngtools/webpack`](https://www.npmjs.com/~ngtools) standalone from the Angular CLI as presented in [Stephen Fluin's Angular CLI talk](https://youtu.be/uBRK6cTr4Vk?t=6m45s) at Angular Connect 2016: - -* Compiles SCSS/LESS -* TypeScript transpilation -* Bundles JavaScript, CSS -* Asset optimization -* Virtual filesystem for assets - * For serving local assets and compile versions. -* Live-reload via websockets -* Code splitting - * Recognizing the use of `loadChildren` in the router, and bundling those modules separately so that any dependencies of those modules are not going to be loaded as part of your main bundle. These separate bundles will be pulled out of the critical path of your application, making your total application bundle much smaller and loading it much more performant. diff --git a/packages/@ngtools/webpack/package.json b/packages/@ngtools/webpack/package.json deleted file mode 100644 index 106b624ecbf2..000000000000 --- a/packages/@ngtools/webpack/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@ngtools/webpack", - "version": "1.7.0-rc.0", - "description": "Webpack plugin that AoT compiles your Angular components and modules.", - "main": "./src/index.js", - "typings": "src/index.d.ts", - "license": "MIT", - "keywords": [ - "angular", - "webpack", - "plugin", - "aot" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli/tree/master/packages/@ngtools/webpack", - "engines": { - "node": ">= 4.1.0", - "npm": ">= 3.0.0" - }, - "dependencies": { - "loader-utils": "^1.0.2", - "enhanced-resolve": "^3.1.0", - "magic-string": "^0.22.3", - "source-map": "^0.5.6" - }, - "peerDependencies": { - "typescript": "^2.0.2", - "webpack": "^2.2.0 || ^3.0.0" - } -} diff --git a/packages/@ngtools/webpack/src/compiler_host.ts b/packages/@ngtools/webpack/src/compiler_host.ts deleted file mode 100644 index e8f49d645bce..000000000000 --- a/packages/@ngtools/webpack/src/compiler_host.ts +++ /dev/null @@ -1,270 +0,0 @@ -import * as ts from 'typescript'; -import {basename, dirname, join} from 'path'; -import * as fs from 'fs'; - - -export interface OnErrorFn { - (message: string): void; -} - - -const dev = Math.floor(Math.random() * 10000); - - -export class VirtualStats implements fs.Stats { - protected _ctime = new Date(); - protected _mtime = new Date(); - protected _atime = new Date(); - protected _btime = new Date(); - protected _dev = dev; - protected _ino = Math.floor(Math.random() * 100000); - protected _mode = parseInt('777', 8); // RWX for everyone. - protected _uid = process.env['UID'] || 0; - protected _gid = process.env['GID'] || 0; - - constructor(protected _path: string) {} - - isFile() { return false; } - isDirectory() { return false; } - isBlockDevice() { return false; } - isCharacterDevice() { return false; } - isSymbolicLink() { return false; } - isFIFO() { return false; } - isSocket() { return false; } - - get dev() { return this._dev; } - get ino() { return this._ino; } - get mode() { return this._mode; } - get nlink() { return 1; } // Default to 1 hard link. - get uid() { return this._uid; } - get gid() { return this._gid; } - get rdev() { return 0; } - get size() { return 0; } - get blksize() { return 512; } - get blocks() { return Math.ceil(this.size / this.blksize); } - get atime() { return this._atime; } - get mtime() { return this._mtime; } - get ctime() { return this._ctime; } - get birthtime() { return this._btime; } -} - -export class VirtualDirStats extends VirtualStats { - constructor(_fileName: string) { - super(_fileName); - } - - isDirectory() { return true; } - - get size() { return 1024; } -} - -export class VirtualFileStats extends VirtualStats { - private _sourceFile: ts.SourceFile | null; - constructor(_fileName: string, private _content: string) { - super(_fileName); - } - - get content() { return this._content; } - set content(v: string) { - this._content = v; - this._mtime = new Date(); - this._sourceFile = null; - } - setSourceFile(sourceFile: ts.SourceFile) { - this._sourceFile = sourceFile; - } - getSourceFile(languageVersion: ts.ScriptTarget, setParentNodes: boolean) { - if (!this._sourceFile) { - this._sourceFile = ts.createSourceFile( - this._path, - this._content, - languageVersion, - setParentNodes); - } - - return this._sourceFile; - } - - isFile() { return true; } - - get size() { return this._content.length; } -} - - -export class WebpackCompilerHost implements ts.CompilerHost { - private _delegate: ts.CompilerHost; - private _files: {[path: string]: VirtualFileStats | null} = Object.create(null); - private _directories: {[path: string]: VirtualDirStats | null} = Object.create(null); - - private _changedFiles: {[path: string]: boolean} = Object.create(null); - private _changedDirs: {[path: string]: boolean} = Object.create(null); - - private _basePath: string; - private _setParentNodes: boolean; - - private _cache = false; - - constructor(private _options: ts.CompilerOptions, basePath: string) { - this._setParentNodes = true; - this._delegate = ts.createCompilerHost(this._options, this._setParentNodes); - this._basePath = this._normalizePath(basePath); - } - - private _normalizePath(path: string) { - return path.replace(/\\/g, '/'); - } - - private _resolve(path: string) { - path = this._normalizePath(path); - if (path[0] == '.') { - return this._normalizePath(join(this.getCurrentDirectory(), path)); - } else if (path[0] == '/' || path.match(/^\w:\//)) { - return path; - } else { - return this._normalizePath(join(this._basePath, path)); - } - } - - private _setFileContent(fileName: string, content: string) { - this._files[fileName] = new VirtualFileStats(fileName, content); - - let p = dirname(fileName); - while (p && !this._directories[p]) { - this._directories[p] = new VirtualDirStats(p); - this._changedDirs[p] = true; - p = dirname(p); - } - - this._changedFiles[fileName] = true; - } - - get dirty() { - return Object.keys(this._changedFiles).length > 0; - } - - enableCaching() { - this._cache = true; - } - - resetChangedFileTracker() { - this._changedFiles = Object.create(null); - this._changedDirs = Object.create(null); - } - getChangedFilePaths(): string[] { - return Object.keys(this._changedFiles); - } - - invalidate(fileName: string): void { - fileName = this._resolve(fileName); - if (fileName in this._files) { - this._files[fileName] = null; - this._changedFiles[fileName] = true; - } - } - - fileExists(fileName: string, delegate = true): boolean { - fileName = this._resolve(fileName); - return this._files[fileName] != null || (delegate && this._delegate.fileExists(fileName)); - } - - readFile(fileName: string): string { - fileName = this._resolve(fileName); - - const stats = this._files[fileName]; - if (stats == null) { - const result = this._delegate.readFile(fileName); - if (result !== undefined && this._cache) { - this._setFileContent(fileName, result); - return result; - } else { - return result; - } - } - return stats.content; - } - - // Does not delegate, use with `fileExists/directoryExists()`. - stat(path: string): VirtualStats { - path = this._resolve(path); - return this._files[path] || this._directories[path]; - } - - directoryExists(directoryName: string, delegate = true): boolean { - directoryName = this._resolve(directoryName); - return (this._directories[directoryName] != null) - || (delegate - && this._delegate.directoryExists != undefined - && this._delegate.directoryExists(directoryName)); - } - - getFiles(path: string): string[] { - path = this._resolve(path); - return Object.keys(this._files) - .filter(fileName => dirname(fileName) == path) - .map(path => basename(path)); - } - - getDirectories(path: string): string[] { - path = this._resolve(path); - const subdirs = Object.keys(this._directories) - .filter(fileName => dirname(fileName) == path) - .map(path => basename(path)); - - let delegated: string[]; - try { - delegated = this._delegate.getDirectories(path); - } catch (e) { - delegated = []; - } - return delegated.concat(subdirs); - } - - getSourceFile(fileName: string, languageVersion: ts.ScriptTarget, _onError?: OnErrorFn) { - fileName = this._resolve(fileName); - - const stats = this._files[fileName]; - if (stats == null) { - const content = this.readFile(fileName); - if (!this._cache) { - return ts.createSourceFile(fileName, content, languageVersion, this._setParentNodes); - } - } - - return this._files[fileName]!.getSourceFile(languageVersion, this._setParentNodes); - } - - getCancellationToken() { - return this._delegate.getCancellationToken!(); - } - - getDefaultLibFileName(options: ts.CompilerOptions) { - return this._delegate.getDefaultLibFileName(options); - } - - // This is due to typescript CompilerHost interface being weird on writeFile. This shuts down - // typings in WebStorm. - get writeFile() { - return (fileName: string, data: string, _writeByteOrderMark: boolean, - _onError?: (message: string) => void, _sourceFiles?: ts.SourceFile[]): void => { - fileName = this._resolve(fileName); - this._setFileContent(fileName, data); - }; - } - - getCurrentDirectory(): string { - return this._basePath !== null ? this._basePath : this._delegate.getCurrentDirectory(); - } - - getCanonicalFileName(fileName: string): string { - fileName = this._resolve(fileName); - return this._delegate.getCanonicalFileName(fileName); - } - - useCaseSensitiveFileNames(): boolean { - return this._delegate.useCaseSensitiveFileNames(); - } - - getNewLine(): string { - return this._delegate.getNewLine(); - } -} diff --git a/packages/@ngtools/webpack/src/entry_resolver.ts b/packages/@ngtools/webpack/src/entry_resolver.ts deleted file mode 100644 index e406dc60e900..000000000000 --- a/packages/@ngtools/webpack/src/entry_resolver.ts +++ /dev/null @@ -1,162 +0,0 @@ -import * as fs from 'fs'; -import {join} from 'path'; -import * as ts from 'typescript'; - -import {TypeScriptFileRefactor} from './refactor'; - - -function _recursiveSymbolExportLookup(refactor: TypeScriptFileRefactor, - symbolName: string, - host: ts.CompilerHost, - program: ts.Program): string | null { - // Check this file. - const hasSymbol = refactor.findAstNodes(null, ts.SyntaxKind.ClassDeclaration) - .some((cd: ts.ClassDeclaration) => { - return cd.name != undefined && cd.name.text == symbolName; - }); - if (hasSymbol) { - return refactor.fileName; - } - - // We found the bootstrap variable, now we just need to get where it's imported. - const exports = refactor.findAstNodes(null, ts.SyntaxKind.ExportDeclaration) - .map(node => node as ts.ExportDeclaration); - - for (const decl of exports) { - if (!decl.moduleSpecifier || decl.moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { - continue; - } - - const modulePath = (decl.moduleSpecifier as ts.StringLiteral).text; - const resolvedModule = ts.resolveModuleName( - modulePath, refactor.fileName, program.getCompilerOptions(), host); - if (!resolvedModule.resolvedModule || !resolvedModule.resolvedModule.resolvedFileName) { - return null; - } - - const module = resolvedModule.resolvedModule.resolvedFileName; - if (!decl.exportClause) { - const moduleRefactor = new TypeScriptFileRefactor(module, host, program); - const maybeModule = _recursiveSymbolExportLookup(moduleRefactor, symbolName, host, program); - if (maybeModule) { - return maybeModule; - } - continue; - } - - const binding = decl.exportClause as ts.NamedExports; - for (const specifier of binding.elements) { - if (specifier.name.text == symbolName) { - // If it's a directory, load its index and recursively lookup. - if (fs.statSync(module).isDirectory()) { - const indexModule = join(module, 'index.ts'); - if (fs.existsSync(indexModule)) { - const indexRefactor = new TypeScriptFileRefactor(indexModule, host, program); - const maybeModule = _recursiveSymbolExportLookup( - indexRefactor, symbolName, host, program); - if (maybeModule) { - return maybeModule; - } - } - } - - // Create the source and verify that the symbol is at least a class. - const source = new TypeScriptFileRefactor(module, host, program); - const hasSymbol = source.findAstNodes(null, ts.SyntaxKind.ClassDeclaration) - .some((cd: ts.ClassDeclaration) => { - return cd.name != undefined && cd.name.text == symbolName; - }); - - if (hasSymbol) { - return module; - } - } - } - } - - return null; -} - -function _symbolImportLookup(refactor: TypeScriptFileRefactor, - symbolName: string, - host: ts.CompilerHost, - program: ts.Program): string | null { - // We found the bootstrap variable, now we just need to get where it's imported. - const imports = refactor.findAstNodes(null, ts.SyntaxKind.ImportDeclaration) - .map(node => node as ts.ImportDeclaration); - - for (const decl of imports) { - if (!decl.importClause || !decl.moduleSpecifier) { - continue; - } - if (decl.moduleSpecifier.kind !== ts.SyntaxKind.StringLiteral) { - continue; - } - - const resolvedModule = ts.resolveModuleName( - (decl.moduleSpecifier as ts.StringLiteral).text, - refactor.fileName, program.getCompilerOptions(), host); - if (!resolvedModule.resolvedModule || !resolvedModule.resolvedModule.resolvedFileName) { - continue; - } - - const module = resolvedModule.resolvedModule.resolvedFileName; - if (decl.importClause.namedBindings - && decl.importClause.namedBindings.kind == ts.SyntaxKind.NamespaceImport) { - const binding = decl.importClause.namedBindings as ts.NamespaceImport; - if (binding.name.text == symbolName) { - // This is a default export. - return module; - } - } else if (decl.importClause.namedBindings - && decl.importClause.namedBindings.kind == ts.SyntaxKind.NamedImports) { - const binding = decl.importClause.namedBindings as ts.NamedImports; - for (const specifier of binding.elements) { - if (specifier.name.text == symbolName) { - // Create the source and recursively lookup the import. - const source = new TypeScriptFileRefactor(module, host, program); - const maybeModule = _recursiveSymbolExportLookup(source, symbolName, host, program); - if (maybeModule) { - return maybeModule; - } - } - } - } - } - return null; -} - - -export function resolveEntryModuleFromMain(mainPath: string, - host: ts.CompilerHost, - program: ts.Program) { - const source = new TypeScriptFileRefactor(mainPath, host, program); - - const bootstrap = source.findAstNodes(source.sourceFile, ts.SyntaxKind.CallExpression, true) - .map(node => node as ts.CallExpression) - .filter(call => { - const access = call.expression as ts.PropertyAccessExpression; - return access.kind == ts.SyntaxKind.PropertyAccessExpression - && access.name.kind == ts.SyntaxKind.Identifier - && (access.name.text == 'bootstrapModule' - || access.name.text == 'bootstrapModuleFactory'); - }) - .map(node => node.arguments[0] as ts.Identifier) - .filter(node => node.kind == ts.SyntaxKind.Identifier); - - if (bootstrap.length != 1) { - throw new Error('Tried to find bootstrap code, but could not. Specify either ' - + 'statically analyzable bootstrap code or pass in an entryModule ' - + 'to the plugins options.'); - } - const bootstrapSymbolName = bootstrap[0].text; - const module = _symbolImportLookup(source, bootstrapSymbolName, host, program); - if (module) { - return `${module.replace(/\.ts$/, '')}#${bootstrapSymbolName}`; - } - - // shrug... something bad happened and we couldn't find the import statement. - throw new Error('Tried to find bootstrap code, but could not. Specify either ' - + 'statically analyzable bootstrap code or pass in an entryModule ' - + 'to the plugins options.'); -} diff --git a/packages/@ngtools/webpack/src/extract_i18n_plugin.ts b/packages/@ngtools/webpack/src/extract_i18n_plugin.ts deleted file mode 100644 index aa08eabf06ad..000000000000 --- a/packages/@ngtools/webpack/src/extract_i18n_plugin.ts +++ /dev/null @@ -1,191 +0,0 @@ -// @ignoreDep @angular/compiler-cli -import * as ts from 'typescript'; -import * as path from 'path'; -import * as fs from 'fs'; - -const {__NGTOOLS_PRIVATE_API_2, VERSION} = require('@angular/compiler-cli'); - -import {Tapable} from './webpack'; -import {WebpackResourceLoader} from './resource_loader'; - -export interface ExtractI18nPluginOptions { - tsConfigPath: string; - basePath?: string; - genDir?: string; - i18nFormat?: string; - locale?: string; - outFile?: string; - exclude?: string[]; -} - -export class ExtractI18nPlugin implements Tapable { - private _resourceLoader: WebpackResourceLoader; - - private _donePromise: Promise | null; - private _compiler: any = null; - private _compilation: any = null; - - private _tsConfigPath: string; - private _basePath: string; - private _genDir: string; - private _rootFilePath: string[]; - private _compilerOptions: any = null; - private _angularCompilerOptions: any = null; - // private _compilerHost: WebpackCompilerHost; - private _compilerHost: ts.CompilerHost; - private _program: ts.Program; - - private _i18nFormat?: string; - private _locale?: string; - private _outFile?: string; - - constructor(options: ExtractI18nPluginOptions) { - this._setupOptions(options); - } - - private _setupOptions(options: ExtractI18nPluginOptions) { - if (!options.hasOwnProperty('tsConfigPath')) { - throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); - } - // TS represents paths internally with '/' and expects the tsconfig path to be in this format - this._tsConfigPath = options.tsConfigPath.replace(/\\/g, '/'); - - // Check the base path. - const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath); - let basePath = maybeBasePath; - if (fs.statSync(maybeBasePath).isFile()) { - basePath = path.dirname(basePath); - } - if (options.hasOwnProperty('basePath')) { - basePath = path.resolve(process.cwd(), options.basePath); - } - - let tsConfigJson: any = null; - try { - tsConfigJson = JSON.parse(fs.readFileSync(this._tsConfigPath, 'utf8')); - } catch (err) { - throw new Error(`An error happened while parsing ${this._tsConfigPath} JSON: ${err}.`); - } - const tsConfig = ts.parseJsonConfigFileContent( - tsConfigJson, ts.sys, basePath, undefined, this._tsConfigPath); - - let fileNames = tsConfig.fileNames; - if (options.hasOwnProperty('exclude')) { - let exclude: string[] = typeof options.exclude == 'string' - ? [options.exclude as string] : (options.exclude as string[]); - - exclude.forEach((pattern: string) => { - const basePathPattern = '(' + basePath.replace(/\\/g, '/') - .replace(/[\-\[\]\/{}()+?.\\^$|*]/g, '\\$&') + ')?'; - pattern = pattern - // Replace windows path separators with forward slashes. - .replace(/\\/g, '/') - // Escape characters that are used normally in regexes, except stars. - .replace(/[\-\[\]{}()+?.\\^$|]/g, '\\$&') - // Two stars replacement. - .replace(/\*\*/g, '(?:.*)') - // One star replacement. - .replace(/\*/g, '(?:[^/]*)') - // Escape characters from the basePath and make sure it's forward slashes. - .replace(/^/, basePathPattern); - - const re = new RegExp('^' + pattern + '$'); - fileNames = fileNames.filter(x => !x.replace(/\\/g, '/').match(re)); - }); - } else { - fileNames = fileNames.filter(fileName => !/\.spec\.ts$/.test(fileName)); - } - this._rootFilePath = fileNames; - - // By default messages will be generated in basePath - let genDir = basePath; - - if (options.hasOwnProperty('genDir')) { - genDir = path.resolve(process.cwd(), options.genDir); - } - - this._compilerOptions = tsConfig.options; - this._angularCompilerOptions = Object.assign( - { genDir }, - this._compilerOptions, - tsConfig.raw['angularCompilerOptions'], - { basePath } - ); - - this._basePath = basePath; - this._genDir = genDir; - - // this._compilerHost = new WebpackCompilerHost(this._compilerOptions, this._basePath); - this._compilerHost = ts.createCompilerHost(this._compilerOptions, true); - this._program = ts.createProgram( - this._rootFilePath, this._compilerOptions, this._compilerHost); - - if (options.hasOwnProperty('i18nFormat')) { - this._i18nFormat = options.i18nFormat; - } - if (options.hasOwnProperty('locale')) { - if (VERSION.major === '2') { - console.warn("The option '--locale' is only available on the xi18n command" - + ' starting from Angular v4, please update to a newer version.', '\n\n'); - } - this._locale = options.locale; - } - if (options.hasOwnProperty('outFile')) { - if (VERSION.major === '2') { - console.warn("The option '--out-file' is only available on the xi18n command" - + ' starting from Angular v4, please update to a newer version.', '\n\n'); - } - this._outFile = options.outFile; - } - } - - apply(compiler: any) { - this._compiler = compiler; - - compiler.plugin('make', (compilation: any, cb: any) => this._make(compilation, cb)); - - compiler.plugin('after-emit', (compilation: any, cb: any) => { - this._donePromise = null; - this._compilation = null; - compilation._ngToolsWebpackXi18nPluginInstance = null; - cb(); - }); - } - - private _make(compilation: any, cb: (err?: any, request?: any) => void) { - this._compilation = compilation; - if (this._compilation._ngToolsWebpackXi18nPluginInstance) { - return cb(new Error('An @ngtools/webpack xi18n plugin already exist for ' + - 'this compilation.')); - } - if (!this._compilation._ngToolsWebpackPluginInstance) { - return cb(new Error('An @ngtools/webpack aot plugin does not exists ' + - 'for this compilation')); - } - - this._compilation._ngToolsWebpackXi18nPluginInstance = this; - - this._resourceLoader = new WebpackResourceLoader(compilation); - - this._donePromise = Promise.resolve() - .then(() => { - return __NGTOOLS_PRIVATE_API_2.extractI18n({ - basePath: this._basePath, - compilerOptions: this._compilerOptions, - program: this._program, - host: this._compilerHost, - angularCompilerOptions: this._angularCompilerOptions, - i18nFormat: this._i18nFormat || '', - locale: this._locale, - outFile: this._outFile, - - readResource: (path: string) => this._resourceLoader.get(path) - }); - }) - .then(() => cb(), (err: any) => { - this._compilation.errors.push(err); - cb(err); - }); - - } -} diff --git a/packages/@ngtools/webpack/src/index.ts b/packages/@ngtools/webpack/src/index.ts deleted file mode 100644 index b4f6bc8d5497..000000000000 --- a/packages/@ngtools/webpack/src/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -// @ignoreDep @angular/compiler-cli -import * as path from 'path'; - -let version; - -// Check that Angular is available. -try { - version = require('@angular/compiler-cli').VERSION; -} catch (e) { - throw new Error('The "@angular/compiler-cli" package was not properly installed. Error: ' + e); -} - -// Check that Angular is also not part of this module's node_modules (it should be the project's). -const compilerCliPath = require.resolve('@angular/compiler-cli'); -if (compilerCliPath.startsWith(path.dirname(__dirname))) { - throw new Error('The @ngtools/webpack plugin now relies on the project @angular/compiler-cli. ' - + 'Please clean your node_modules and reinstall.'); -} - -// Throw if we're neither 2.3.1 or more, nor 4.x.y, nor 5.x.y. -if (!( version.major == '5' - || version.major == '4' - || (version.major == '2' - && ( version.minor == '4' - || version.minor == '3' && version.patch == '1')))) { - throw new Error('Version of @angular/compiler-cli needs to be 2.3.1 or greater. ' - + `Current version is "${version.full}".`); -} - -export * from './plugin'; -export * from './extract_i18n_plugin'; -export {ngcLoader as default} from './loader'; -export {PathsPlugin} from './paths-plugin'; diff --git a/packages/@ngtools/webpack/src/lazy_routes.ts b/packages/@ngtools/webpack/src/lazy_routes.ts deleted file mode 100644 index 9f42aaf2bec9..000000000000 --- a/packages/@ngtools/webpack/src/lazy_routes.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {dirname, join} from 'path'; -import * as ts from 'typescript'; - -import {TypeScriptFileRefactor} from './refactor'; - - -function _getContentOfKeyLiteral(_source: ts.SourceFile, node: ts.Node): string | null { - if (node.kind == ts.SyntaxKind.Identifier) { - return (node as ts.Identifier).text; - } else if (node.kind == ts.SyntaxKind.StringLiteral) { - return (node as ts.StringLiteral).text; - } else { - return null; - } -} - - -export interface LazyRouteMap { - [path: string]: string | null; -} - - -export function findLazyRoutes(filePath: string, - program: ts.Program, - host: ts.CompilerHost): LazyRouteMap { - const refactor = new TypeScriptFileRefactor(filePath, host, program); - - return refactor - // Find all object literals in the file. - .findAstNodes(null, ts.SyntaxKind.ObjectLiteralExpression, true) - // Get all their property assignments. - .map((node: ts.ObjectLiteralExpression) => { - return refactor.findAstNodes(node, ts.SyntaxKind.PropertyAssignment, false); - }) - // Take all `loadChildren` elements. - .reduce((acc: ts.PropertyAssignment[], props: ts.PropertyAssignment[]) => { - return acc.concat(props.filter(literal => { - return _getContentOfKeyLiteral(refactor.sourceFile, literal.name) == 'loadChildren'; - })); - }, []) - // Get only string values. - .filter((node: ts.PropertyAssignment) => node.initializer.kind == ts.SyntaxKind.StringLiteral) - // Get the string value. - .map((node: ts.PropertyAssignment) => (node.initializer as ts.StringLiteral).text) - // Map those to either [path, absoluteModulePath], or [path, null] if the module pointing to - // does not exist. - .map((routePath: string) => { - const moduleName = routePath.split('#')[0]; - const resolvedModuleName: ts.ResolvedModuleWithFailedLookupLocations = moduleName[0] == '.' - ? ({ - resolvedModule: { resolvedFileName: join(dirname(filePath), moduleName) + '.ts' } - } as any) - : ts.resolveModuleName(moduleName, filePath, program.getCompilerOptions(), host); - if (resolvedModuleName.resolvedModule - && resolvedModuleName.resolvedModule.resolvedFileName - && host.fileExists(resolvedModuleName.resolvedModule.resolvedFileName)) { - return [routePath, resolvedModuleName.resolvedModule.resolvedFileName]; - } else { - return [routePath, null]; - } - }) - // Reduce to the LazyRouteMap map. - .reduce((acc: LazyRouteMap, [routePath, resolvedModuleName]: [string, string | null]) => { - acc[routePath] = resolvedModuleName; - return acc; - }, {}); -} diff --git a/packages/@ngtools/webpack/src/loader.spec.ts b/packages/@ngtools/webpack/src/loader.spec.ts deleted file mode 100644 index 5514dd342f7f..000000000000 --- a/packages/@ngtools/webpack/src/loader.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as ts from 'typescript'; -import {removeModuleIdOnlyForTesting} from './loader'; -import {WebpackCompilerHost} from './compiler_host'; -import {TypeScriptFileRefactor} from './refactor'; - -describe('@ngtools/webpack', () => { - describe('loader', () => { - describe('removeModuleId', () => { - it('should work', () => { - const host = new WebpackCompilerHost({}, ''); - host.writeFile('/file.ts', ` - export const obj = { moduleId: 123 }; - export const obj2 = { moduleId: 123, otherValue: 1 }; - export const obj3 = { otherValue: 1, moduleId: 123 }; - `, false); - host.writeFile('/file2.ts', ` - @SomeDecorator({ moduleId: 123 }) class CLS {} - @SomeDecorator({ moduleId: 123, otherValue1: 1 }) class CLS2 {} - @SomeDecorator({ otherValue2: 2, moduleId: 123, otherValue3: 3 }) class CLS3 {} - @SomeDecorator({ otherValue4: 4, moduleId: 123 }) class CLS4 {} - `, false); - - const program = ts.createProgram(['/file.ts', '/file2.ts'], {}, host); - - const refactor = new TypeScriptFileRefactor('/file.ts', host, program); - removeModuleIdOnlyForTesting(refactor); - expect(refactor.sourceText).not.toMatch(/obj = \{\s+};/); - expect(refactor.sourceText).not.toMatch(/\{\s*otherValue: 1\s*};/); - - const refactor2 = new TypeScriptFileRefactor('/file2.ts', host, program); - removeModuleIdOnlyForTesting(refactor2); - expect(refactor2.sourceText).toMatch(/\(\{\s+}\)/); - expect(refactor2.sourceText).toMatch(/\(\{\s*otherValue1: 1\s*}\)/); - expect(refactor2.sourceText).toMatch(/\(\{\s*otherValue2: 2\s*,\s*otherValue3: 3\s*}\)/); - expect(refactor2.sourceText).toMatch(/\(\{\s*otherValue4: 4\s*}\)/); - }); - - it('should work without a root name', () => { - const host = new WebpackCompilerHost({}, ''); - host.writeFile('/file.ts', ` - import './file2.ts'; - `, false); - host.writeFile('/file2.ts', ` - @SomeDecorator({ moduleId: 123 }) class CLS {} - @SomeDecorator({ moduleId: 123, otherValue1: 1 }) class CLS2 {} - @SomeDecorator({ otherValue2: 2, moduleId: 123, otherValue3: 3 }) class CLS3 {} - @SomeDecorator({ otherValue4: 4, moduleId: 123 }) class CLS4 {} - `, false); - - const program = ts.createProgram(['/file.ts'], {}, host); - const refactor = new TypeScriptFileRefactor('/file2.ts', host, program); - removeModuleIdOnlyForTesting(refactor); - expect(refactor.sourceText).toMatch(/\(\{\s+}\)/); - expect(refactor.sourceText).toMatch(/\(\{\s*otherValue1: 1\s*}\)/); - expect(refactor.sourceText).toMatch(/\(\{\s*otherValue2: 2\s*,\s*otherValue3: 3\s*}\)/); - expect(refactor.sourceText).toMatch(/\(\{\s*otherValue4: 4\s*}\)/); - }); - }); - }); -}); diff --git a/packages/@ngtools/webpack/src/loader.ts b/packages/@ngtools/webpack/src/loader.ts deleted file mode 100644 index 4be0c7e04905..000000000000 --- a/packages/@ngtools/webpack/src/loader.ts +++ /dev/null @@ -1,640 +0,0 @@ -import * as path from 'path'; -import * as ts from 'typescript'; -import {AotPlugin} from './plugin'; -import {TypeScriptFileRefactor} from './refactor'; -import {LoaderContext, ModuleReason} from './webpack'; - -interface Platform { - name: string; - importLocation: string; -} - -const loaderUtils = require('loader-utils'); -const NormalModule = require('webpack/lib/NormalModule'); - -// This is a map of changes which need to be made -const changeMap: {[key: string]: Platform} = { - platformBrowserDynamic: { - name: 'platformBrowser', - importLocation: '@angular/platform-browser' - }, - platformDynamicServer: { - name: 'platformServer', - importLocation: '@angular/platform-server' - } -}; - -function _getContentOfKeyLiteral(_source: ts.SourceFile, node?: ts.Node): string | null { - if (!node) { - return null; - } else if (node.kind == ts.SyntaxKind.Identifier) { - return (node as ts.Identifier).text; - } else if (node.kind == ts.SyntaxKind.StringLiteral) { - return (node as ts.StringLiteral).text; - } else { - return null; - } -} - - -function _angularImportsFromNode(node: ts.ImportDeclaration, _sourceFile: ts.SourceFile): string[] { - const ms = node.moduleSpecifier; - let modulePath: string | null = null; - switch (ms.kind) { - case ts.SyntaxKind.StringLiteral: - modulePath = (ms as ts.StringLiteral).text; - break; - default: - return []; - } - - if (!modulePath.startsWith('@angular/')) { - return []; - } - - if (node.importClause) { - if (node.importClause.name) { - // This is of the form `import Name from 'path'`. Ignore. - return []; - } else if (node.importClause.namedBindings) { - const nb = node.importClause.namedBindings; - if (nb.kind == ts.SyntaxKind.NamespaceImport) { - // This is of the form `import * as name from 'path'`. Return `name.`. - return [(nb as ts.NamespaceImport).name.text + '.']; - } else { - // This is of the form `import {a,b,c} from 'path'` - const namedImports = nb as ts.NamedImports; - - return namedImports.elements - .map((is: ts.ImportSpecifier) => is.propertyName ? is.propertyName.text : is.name.text); - } - } - } else { - // This is of the form `import 'path';`. Nothing to do. - return []; - } - return []; -} - - -function _ctorParameterFromTypeReference(paramNode: ts.ParameterDeclaration, - angularImports: string[], - refactor: TypeScriptFileRefactor) { - let typeName = 'undefined'; - - if (paramNode.type) { - switch (paramNode.type.kind) { - case ts.SyntaxKind.TypeReference: - const type = paramNode.type as ts.TypeReferenceNode; - if (type.typeName) { - typeName = type.typeName.getText(refactor.sourceFile); - } else { - typeName = type.getText(refactor.sourceFile); - } - break; - case ts.SyntaxKind.AnyKeyword: - typeName = 'undefined'; - break; - default: - typeName = 'null'; - } - } - - const decorators = refactor.findAstNodes(paramNode, ts.SyntaxKind.Decorator) as ts.Decorator[]; - const decoratorStr = decorators - .map(decorator => { - const call = - refactor.findFirstAstNode(decorator, ts.SyntaxKind.CallExpression) as ts.CallExpression; - - if (!call) { - return null; - } - - const fnName = call.expression.getText(refactor.sourceFile); - const args = call.arguments.map(x => x.getText(refactor.sourceFile)).join(', '); - if (angularImports.indexOf(fnName) === -1) { - return null; - } else { - return [fnName, args]; - } - }) - .filter(x => !!x) - .map(([name, args]: string[]) => { - if (args) { - return `{ type: ${name}, args: [${args}] }`; - } - return `{ type: ${name} }`; - }) - .join(', '); - - if (decorators.length > 0) { - return `{ type: ${typeName}, decorators: [${decoratorStr}] }`; - } - return `{ type: ${typeName} }`; -} - - -function _addCtorParameters(classNode: ts.ClassDeclaration, - angularImports: string[], - refactor: TypeScriptFileRefactor) { - // For every classes with constructors, output the ctorParameters function which contains a list - // of injectable types. - const ctor = ( - refactor.findFirstAstNode(classNode, ts.SyntaxKind.Constructor) as ts.ConstructorDeclaration); - if (!ctor) { - // A class can be missing a constructor, and that's _okay_. - return; - } - - const params = Array.from(ctor.parameters).map(paramNode => { - return _ctorParameterFromTypeReference(paramNode, angularImports, refactor); - }); - - const ctorParametersDecl = `static ctorParameters() { return [ ${params.join(', ')} ]; }`; - refactor.prependBefore(classNode.getLastToken(refactor.sourceFile), ctorParametersDecl); -} - - -function _removeDecorators(refactor: TypeScriptFileRefactor) { - const angularImports: string[] - = refactor.findAstNodes(refactor.sourceFile, ts.SyntaxKind.ImportDeclaration) - .map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, refactor.sourceFile)) - .reduce((acc: string[], current: string[]) => acc.concat(current), []); - - // Find all decorators. - refactor.findAstNodes(refactor.sourceFile, ts.SyntaxKind.Decorator) - .forEach(node => { - // First, add decorators to classes to the classes array. - if (node.parent) { - const declarations = refactor.findAstNodes(node.parent, - ts.SyntaxKind.ClassDeclaration, false, 1); - if (declarations.length > 0) { - _addCtorParameters(declarations[0] as ts.ClassDeclaration, angularImports, refactor); - } - } - - refactor.findAstNodes(node, ts.SyntaxKind.CallExpression) - .filter((node: ts.CallExpression) => { - const fnName = node.expression.getText(refactor.sourceFile); - if (fnName.indexOf('.') != -1) { - // Since this is `a.b`, see if it's the same namespace as a namespace import. - return angularImports.indexOf(fnName.replace(/\..*$/, '') + '.') != -1; - } else { - return angularImports.indexOf(fnName) != -1; - } - }) - .forEach(() => refactor.removeNode(node)); - }); -} - - -function _getNgFactoryPath(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { - // Calculate the base path. - const basePath = path.normalize(plugin.basePath); - const genDir = path.normalize(plugin.genDir); - const dirName = path.normalize(path.dirname(refactor.fileName)); - const entryModule = plugin.entryModule; - const entryModuleFileName = path.normalize(entryModule.path + '.ngfactory'); - const relativeEntryModulePath = path.relative(basePath, entryModuleFileName); - const fullEntryModulePath = path.resolve(genDir, relativeEntryModulePath); - const relativeNgFactoryPath = path.relative(dirName, fullEntryModulePath); - return './' + relativeNgFactoryPath.replace(/\\/g, '/'); -} - - -function _replacePlatform( - refactor: TypeScriptFileRefactor, bootstrapCall: ts.PropertyAccessExpression) { - const platforms = (refactor.findAstNodes(bootstrapCall, - ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]) - .filter(call => { - return call.expression.kind == ts.SyntaxKind.Identifier; - }) - .filter(call => !!changeMap[(call.expression as ts.Identifier).text]); - - platforms.forEach(call => { - const platform = changeMap[(call.expression as ts.Identifier).text]; - - // Replace with mapped replacement - refactor.replaceNode(call.expression, platform.name); - - // Add the appropriate import - refactor.insertImport(platform.name, platform.importLocation); - }); -} - - -function _replaceBootstrapOrRender(refactor: TypeScriptFileRefactor, call: ts.CallExpression) { - // If neither bootstrapModule or renderModule can't be found, bail out early. - let replacementTarget: string | undefined; - let identifier: ts.Identifier | undefined; - if (call.getText().includes('bootstrapModule')) { - if (call.expression.kind != ts.SyntaxKind.PropertyAccessExpression) { - return; - } - - replacementTarget = 'bootstrapModule'; - const access = call.expression as ts.PropertyAccessExpression; - identifier = access.name; - _replacePlatform(refactor, access); - - } else if (call.getText().includes('renderModule')) { - if (call.expression.kind != ts.SyntaxKind.Identifier) { - return; - } - - replacementTarget = 'renderModule'; - identifier = call.expression as ts.Identifier; - refactor.insertImport('renderModuleFactory', '@angular/platform-server'); - } - - if (identifier && identifier.text === replacementTarget) { - refactor.replaceNode(identifier, replacementTarget + 'Factory'); - } -} - - -function _getCaller(node: ts.Node): ts.CallExpression | null { - while (node.parent) { - node = node.parent; - if (node.kind === ts.SyntaxKind.CallExpression) { - return node as ts.CallExpression; - } - } - return null; -} - - -function _replaceEntryModule(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { - const modules = refactor.findAstNodes(refactor.sourceFile, ts.SyntaxKind.Identifier, true) - .filter(identifier => identifier.getText() === plugin.entryModule.className) - .filter(identifier => - identifier.parent && - (identifier.parent.kind === ts.SyntaxKind.CallExpression || - identifier.parent.kind === ts.SyntaxKind.PropertyAssignment)) - .filter(node => !!_getCaller(node)); - - if (modules.length == 0) { - return; - } - - const factoryClassName = plugin.entryModule.className + 'NgFactory'; - - refactor.insertImport(factoryClassName, _getNgFactoryPath(plugin, refactor)); - - modules - .forEach(reference => { - refactor.replaceNode(reference, factoryClassName); - const caller = _getCaller(reference); - if (caller) { - _replaceBootstrapOrRender(refactor, caller); - } - }); -} - - -function _refactorBootstrap(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { - const genDir = path.normalize(plugin.genDir); - const dirName = path.normalize(path.dirname(refactor.fileName)); - - // Bail if in the generated directory - if (dirName.startsWith(genDir)) { - return; - } - - _replaceEntryModule(plugin, refactor); -} - -export function removeModuleIdOnlyForTesting(refactor: TypeScriptFileRefactor) { - _removeModuleId(refactor); -} - -function _removeModuleId(refactor: TypeScriptFileRefactor) { - const sourceFile = refactor.sourceFile; - - refactor.findAstNodes(sourceFile, ts.SyntaxKind.Decorator, true) - .reduce((acc, node) => { - return acc.concat(refactor.findAstNodes(node, ts.SyntaxKind.ObjectLiteralExpression, true)); - }, new Array()) - // Get all their property assignments. - .filter((node: ts.ObjectLiteralExpression) => { - return node.properties.some(prop => { - return prop.kind == ts.SyntaxKind.PropertyAssignment - && _getContentOfKeyLiteral(sourceFile, prop.name) == 'moduleId'; - }); - }) - .forEach((node: ts.ObjectLiteralExpression) => { - const moduleIdProp = node.properties.filter((prop: ts.ObjectLiteralElement, _idx: number) => { - return prop.kind == ts.SyntaxKind.PropertyAssignment - && _getContentOfKeyLiteral(sourceFile, prop.name) == 'moduleId'; - })[0]; - // Get the trailing comma. - const moduleIdCommaProp = moduleIdProp.parent - ? moduleIdProp.parent.getChildAt(1).getChildren()[1] : null; - refactor.removeNodes(moduleIdProp, moduleIdCommaProp); - }); -} - -function _getResourceRequest(element: ts.Expression, sourceFile: ts.SourceFile) { - if (element.kind == ts.SyntaxKind.StringLiteral) { - const url = (element as ts.StringLiteral).text; - // If the URL does not start with ./ or ../, prepends ./ to it. - return `'${/^\.?\.\//.test(url) ? '' : './'}${url}'`; - } else { - // if not string, just use expression directly - return element.getFullText(sourceFile); - } -} - -function _replaceResources(refactor: TypeScriptFileRefactor): void { - const sourceFile = refactor.sourceFile; - - _getResourceNodes(refactor) - // Get the full text of the initializer. - .forEach((node: ts.PropertyAssignment) => { - const key = _getContentOfKeyLiteral(sourceFile, node.name); - - if (key == 'templateUrl') { - refactor.replaceNode(node, - `template: require(${_getResourceRequest(node.initializer, sourceFile)})`); - } else if (key == 'styleUrls') { - const arr = ( - refactor.findAstNodes(node, ts.SyntaxKind.ArrayLiteralExpression, false)); - if (!arr || arr.length == 0 || arr[0].elements.length == 0) { - return; - } - - const initializer = arr[0].elements.map((element: ts.Expression) => { - return _getResourceRequest(element, sourceFile); - }); - refactor.replaceNode(node, `styles: [require(${initializer.join('), require(')})]`); - } - }); -} - - -function _getResourceNodes(refactor: TypeScriptFileRefactor) { - const { sourceFile } = refactor; - - // Find all object literals. - return refactor.findAstNodes(sourceFile, ts.SyntaxKind.ObjectLiteralExpression, true) - // Get all their property assignments. - .map(node => refactor.findAstNodes(node, ts.SyntaxKind.PropertyAssignment)) - // Flatten into a single array (from an array of array). - .reduce((prev, curr) => curr ? prev.concat(curr) : prev, []) - // Remove every property assignment that aren't 'loadChildren'. - .filter((node: ts.PropertyAssignment) => { - const key = _getContentOfKeyLiteral(sourceFile, node.name); - if (!key) { - // key is an expression, can't do anything. - return false; - } - return key == 'templateUrl' || key == 'styleUrls'; - }); -} - - -function _getResourcesUrls(refactor: TypeScriptFileRefactor): string[] { - return _getResourceNodes(refactor) - .reduce((acc: string[], node: ts.PropertyAssignment) => { - const key = _getContentOfKeyLiteral(refactor.sourceFile, node.name); - - if (key == 'templateUrl') { - const url = (node.initializer as ts.StringLiteral).text; - if (url) { - acc.push(url); - } - } else if (key == 'styleUrls') { - const arr = ( - refactor.findAstNodes(node, ts.SyntaxKind.ArrayLiteralExpression, false)); - if (!arr || arr.length == 0 || arr[0].elements.length == 0) { - return acc; - } - - arr[0].elements.forEach((element: ts.Expression) => { - if (element.kind == ts.SyntaxKind.StringLiteral) { - const url = (element as ts.StringLiteral).text; - if (url) { - acc.push(url); - } - } - }); - } - return acc; - }, []); -} - - -/** - * Recursively calls diagnose on the plugins for all the reverse dependencies. - * @private - */ -function _diagnoseDeps(reasons: ModuleReason[], plugin: AotPlugin, checked: Set) { - reasons - .filter(reason => reason && reason.module && reason.module instanceof NormalModule) - .filter(reason => !checked.has(reason.module.resource)) - .forEach(reason => { - checked.add(reason.module.resource); - plugin.diagnose(reason.module.resource); - _diagnoseDeps(reason.module.reasons, plugin, checked); - }); -} - - -export function _getModuleExports(plugin: AotPlugin, - refactor: TypeScriptFileRefactor): ts.Identifier[] { - const exports = refactor - .findAstNodes(refactor.sourceFile, ts.SyntaxKind.ExportDeclaration, true); - - return exports - .filter(node => { - - const identifiers = refactor.findAstNodes(node, ts.SyntaxKind.Identifier, false) - .filter(node => node.getText() === plugin.entryModule.className); - - return identifiers.length > 0; - }) as ts.Identifier[]; -} - - -export function _replaceExport(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { - if (!plugin.replaceExport) { - return; - } - _getModuleExports(plugin, refactor) - .forEach(node => { - const factoryPath = _getNgFactoryPath(plugin, refactor); - const factoryClassName = plugin.entryModule.className + 'NgFactory'; - const exportStatement = `export \{ ${factoryClassName} \} from '${factoryPath}'`; - refactor.appendAfter(node, exportStatement); - }); -} - - -export function _exportModuleMap(plugin: AotPlugin, refactor: TypeScriptFileRefactor) { - if (!plugin.replaceExport) { - return; - } - - const dirName = path.normalize(path.dirname(refactor.fileName)); - const classNameAppend = plugin.skipCodeGeneration ? '' : 'NgFactory'; - const modulePathAppend = plugin.skipCodeGeneration ? '' : '.ngfactory'; - - _getModuleExports(plugin, refactor) - .forEach(node => { - const modules = Object.keys(plugin.discoveredLazyRoutes) - .map((loadChildrenString) => { - let [lazyRouteKey, moduleName] = loadChildrenString.split('#'); - - if (!lazyRouteKey || !moduleName) { - throw new Error(`${loadChildrenString} was not a proper loadChildren string`); - } - - moduleName += classNameAppend; - lazyRouteKey += modulePathAppend; - const modulePath = plugin.lazyRoutes[lazyRouteKey]; - - return { - modulePath, - moduleName, - loadChildrenString - }; - }); - - modules.forEach((module, index) => { - const relativePath = path.relative(dirName, module.modulePath!).replace(/\\/g, '/'); - refactor.prependBefore(node, `import * as __lazy_${index}__ from './${relativePath}'`); - }); - - const jsonContent: string = modules - .map((module, index) => - `"${module.loadChildrenString}": __lazy_${index}__.${module.moduleName}`) - .join(); - - refactor.appendAfter(node, `export const LAZY_MODULE_MAP = {${jsonContent}};`); - }); -} - - -// Super simple TS transpiler loader for testing / isolated usage. does not type check! -export function ngcLoader(this: LoaderContext & { _compilation: any }, source: string | null) { - const cb = this.async(); - const sourceFileName: string = this.resourcePath; - - const plugin = this._compilation._ngToolsWebpackPluginInstance as AotPlugin; - if (plugin) { - // We must verify that AotPlugin is an instance of the right class. - // Throw an error if it isn't, that often means multiple @ngtools/webpack installs. - if (!(plugin instanceof AotPlugin)) { - throw new Error('AotPlugin was detected but it was an instance of the wrong class.\n' - + 'This likely means you have several @ngtools/webpack packages installed. ' - + 'You can check this with `npm ls @ngtools/webpack`, and then remove the extra copies.' - ); - } - - if (plugin.compilerHost.readFile(sourceFileName) == source) { - // In the case where the source is the same as the one in compilerHost, we don't have - // extra TS loaders and there's no need to do any trickery. - source = null; - } - const refactor = new TypeScriptFileRefactor( - sourceFileName, plugin.compilerHost, plugin.program, source); - - Promise.resolve() - .then(() => { - if (!plugin.skipCodeGeneration) { - return Promise.resolve() - .then(() => _removeDecorators(refactor)) - .then(() => _refactorBootstrap(plugin, refactor)) - .then(() => _replaceExport(plugin, refactor)) - .then(() => _exportModuleMap(plugin, refactor)); - } else { - return Promise.resolve() - .then(() => _replaceResources(refactor)) - .then(() => _removeModuleId(refactor)) - .then(() => _exportModuleMap(plugin, refactor)); - } - }) - .then(() => { - if (plugin.typeCheck) { - // Check all diagnostics from this and reverse dependencies also. - if (!plugin.firstRun) { - _diagnoseDeps(this._module.reasons, plugin, new Set()); - } - // We do this here because it will throw on error, resulting in rebuilding this file - // the next time around if it changes. - plugin.diagnose(sourceFileName); - } - }) - .then(() => { - // Add resources as dependencies. - _getResourcesUrls(refactor).forEach((url: string) => { - this.addDependency(path.resolve(path.dirname(sourceFileName), url)); - }); - }) - .then(() => { - if (source) { - // We need to validate diagnostics. We ignore type checking though, to save time. - const diagnostics = refactor.getDiagnostics(false); - if (diagnostics.length) { - let message = ''; - - diagnostics.forEach(diagnostic => { - const messageText = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - - if (diagnostic.file) { - const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!); - const fileName = diagnostic.file.fileName; - const {line, character} = position; - message += `${fileName} (${line + 1},${character + 1}): ${messageText}\n`; - } else { - message += `${messageText}\n`; - } - }); - throw new Error(message); - } - } - - // Force a few compiler options to make sure we get the result we want. - const compilerOptions: ts.CompilerOptions = Object.assign({}, plugin.compilerOptions, { - inlineSources: true, - inlineSourceMap: false, - sourceRoot: plugin.basePath - }); - - const result = refactor.transpile(compilerOptions); - cb(null, result.outputText, result.sourceMap); - }) - .catch(err => cb(err)); - } else { - const options = loaderUtils.getOptions(this) || {}; - const tsConfigPath = options.tsConfigPath; - - if (tsConfigPath === undefined) { - throw new Error('@ngtools/webpack is being used as a loader but no `tsConfigPath` option nor ' - + 'AotPlugin was detected. You must provide at least one of these.' - ); - } - - const tsConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile); - - if (tsConfig.error) { - throw tsConfig.error; - } - - const compilerOptions: ts.CompilerOptions = tsConfig.config.compilerOptions; - for (const key of Object.keys(options)) { - if (key == 'tsConfigPath') { - continue; - } - compilerOptions[key] = options[key]; - } - const compilerHost = ts.createCompilerHost(compilerOptions); - const refactor = new TypeScriptFileRefactor(sourceFileName, compilerHost); - _replaceResources(refactor); - - const result = refactor.transpile(compilerOptions); - // Webpack is going to take care of this. - result.outputText = result.outputText.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); - cb(null, result.outputText, result.sourceMap); - } -} diff --git a/packages/@ngtools/webpack/src/paths-plugin.ts b/packages/@ngtools/webpack/src/paths-plugin.ts deleted file mode 100644 index 0c5d16da62c0..000000000000 --- a/packages/@ngtools/webpack/src/paths-plugin.ts +++ /dev/null @@ -1,162 +0,0 @@ -import * as path from 'path'; -import * as ts from 'typescript'; -import { - ResolverPlugin, - Callback, - Tapable, - NormalModuleFactory, - NormalModuleFactoryRequest, -} from './webpack'; - - -const ModulesInRootPlugin: new (a: string, b: string, c: string) => ResolverPlugin - = require('enhanced-resolve/lib/ModulesInRootPlugin'); - -export interface Mapping { - onlyModule: boolean; - alias: string; - aliasPattern: RegExp; - target: string; -} - - -function escapeRegExp(str: string): string { - return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); -} - - -export interface PathsPluginOptions { - nmf: NormalModuleFactory; - tsConfigPath: string; - compilerOptions?: ts.CompilerOptions; - compilerHost?: ts.CompilerHost; -} - -export class PathsPlugin implements Tapable { - private _nmf: NormalModuleFactory; - private _tsConfigPath: string; - private _compilerOptions: ts.CompilerOptions; - private _host: ts.CompilerHost; - - source: string; - target: string; - - private _mappings: Mapping[]; - - private _absoluteBaseUrl: string; - - private static _loadOptionsFromTsConfig(tsConfigPath: string, host?: ts.CompilerHost): - ts.CompilerOptions { - const tsConfig = ts.readConfigFile(tsConfigPath, (path: string) => { - if (host) { - return host.readFile(path); - } else { - return ts.sys.readFile(path); - } - }); - if (tsConfig.error) { - throw tsConfig.error; - } - return tsConfig.config.compilerOptions; - } - - constructor(options: PathsPluginOptions) { - if (!options.hasOwnProperty('tsConfigPath')) { - // This could happen in JavaScript. - throw new Error('tsConfigPath option is mandatory.'); - } - this._tsConfigPath = options.tsConfigPath; - - if (options.compilerOptions) { - this._compilerOptions = options.compilerOptions; - } else { - this._compilerOptions = PathsPlugin._loadOptionsFromTsConfig(this._tsConfigPath); - } - - if (options.compilerHost) { - this._host = options.compilerHost; - } else { - this._host = ts.createCompilerHost(this._compilerOptions, false); - } - - this._nmf = options.nmf; - this.source = 'described-resolve'; - this.target = 'resolve'; - - this._absoluteBaseUrl = path.resolve( - path.dirname(this._tsConfigPath), - this._compilerOptions.baseUrl || '.' - ); - - this._mappings = []; - let paths = this._compilerOptions.paths || {}; - Object.keys(paths).forEach(alias => { - let onlyModule = alias.indexOf('*') === -1; - let excapedAlias = escapeRegExp(alias); - let targets = paths[alias]; - targets.forEach(target => { - let aliasPattern: RegExp; - if (onlyModule) { - aliasPattern = new RegExp(`^${excapedAlias}$`); - } else { - let withStarCapturing = excapedAlias.replace('\\*', '(.*)'); - aliasPattern = new RegExp(`^${withStarCapturing}`); - } - - this._mappings.push({ - onlyModule, - alias, - aliasPattern, - target: target - }); - }); - }); - } - - apply(resolver: ResolverPlugin): void { - let baseUrl = this._compilerOptions.baseUrl || '.'; - - if (baseUrl) { - resolver.apply(new ModulesInRootPlugin('module', this._absoluteBaseUrl, 'resolve')); - } - - this._nmf.plugin('before-resolve', (request: NormalModuleFactoryRequest, - callback: Callback) => { - // Only work on TypeScript issuers. - if (!request.contextInfo.issuer || !request.contextInfo.issuer.endsWith('.ts')) { - return callback(null, request); - } - - for (let mapping of this._mappings) { - const match = request.request.match(mapping.aliasPattern); - if (!match) { continue; } - let newRequestStr = mapping.target; - if (!mapping.onlyModule) { - newRequestStr = newRequestStr.replace('*', match[1]); - } - const moduleResolver = ts.resolveModuleName( - request.request, - request.contextInfo.issuer, - this._compilerOptions, - this._host - ); - let moduleFilePath = moduleResolver.resolvedModule - && moduleResolver.resolvedModule.resolvedFileName; - - // If TypeScript gives us a .d.ts it's probably a node module and we need to let webpack - // do the resolution. - if (moduleFilePath && moduleFilePath.endsWith('.d.ts')) { - moduleFilePath = moduleFilePath.replace(/\.d\.ts$/, '.js'); - if (!this._host.fileExists(moduleFilePath)) { - continue; - } - } - if (moduleFilePath) { - return callback(null, Object.assign({}, request, { request: moduleFilePath })); - } - } - - return callback(null, request); - }); - } -} diff --git a/packages/@ngtools/webpack/src/plugin.ts b/packages/@ngtools/webpack/src/plugin.ts deleted file mode 100644 index 81ae899bd757..000000000000 --- a/packages/@ngtools/webpack/src/plugin.ts +++ /dev/null @@ -1,589 +0,0 @@ -// @ignoreDep @angular/compiler-cli -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; -import * as SourceMap from 'source-map'; - -const {__NGTOOLS_PRIVATE_API_2, VERSION} = require('@angular/compiler-cli'); -const ContextElementDependency = require('webpack/lib/dependencies/ContextElementDependency'); -const NodeWatchFileSystem = require('webpack/lib/node/NodeWatchFileSystem'); - -import {WebpackResourceLoader} from './resource_loader'; -import {WebpackCompilerHost} from './compiler_host'; -import {resolveEntryModuleFromMain} from './entry_resolver'; -import {Tapable} from './webpack'; -import {PathsPlugin} from './paths-plugin'; -import {findLazyRoutes, LazyRouteMap} from './lazy_routes'; -import {VirtualFileSystemDecorator} from './virtual_file_system_decorator'; - - -/** - * Option Constants - */ -export interface AotPluginOptions { - tsConfigPath: string; - basePath?: string; - entryModule?: string; - mainPath?: string; - typeChecking?: boolean; - skipCodeGeneration?: boolean; - replaceExport?: boolean; - hostOverrideFileSystem?: { [path: string]: string }; - hostReplacementPaths?: { [path: string]: string }; - i18nFile?: string; - i18nFormat?: string; - locale?: string; - missingTranslation?: string; - - // Use tsconfig to include path globs. - exclude?: string | string[]; - compilerOptions?: ts.CompilerOptions; -} - - -const inlineSourceMapRe = /\/\/# sourceMappingURL=data:application\/json;base64,([\s\S]+)$/; - - -export class AotPlugin implements Tapable { - private _options: AotPluginOptions; - - private _compilerOptions: ts.CompilerOptions; - private _angularCompilerOptions: any; - private _program: ts.Program; - private _rootFilePath: string[]; - private _compilerHost: WebpackCompilerHost; - private _resourceLoader: WebpackResourceLoader; - private _discoveredLazyRoutes: LazyRouteMap; - private _lazyRoutes: LazyRouteMap = Object.create(null); - private _tsConfigPath: string; - private _entryModule: string; - - private _donePromise: Promise | null; - private _compiler: any = null; - private _compilation: any = null; - - private _typeCheck = true; - private _skipCodeGeneration = false; - private _replaceExport = false; - private _basePath: string; - private _genDir: string; - - private _i18nFile?: string; - private _i18nFormat?: string; - private _locale?: string; - private _missingTranslation?: string; - - private _diagnoseFiles: { [path: string]: boolean } = {}; - private _firstRun = true; - - constructor(options: AotPluginOptions) { - this._options = Object.assign({}, options); - this._setupOptions(this._options); - } - - get options() { return this._options; } - get basePath() { return this._basePath; } - get compilation() { return this._compilation; } - get compilerHost() { return this._compilerHost; } - get compilerOptions() { return this._compilerOptions; } - get done() { return this._donePromise; } - get entryModule() { - const splitted = this._entryModule.split('#'); - const path = splitted[0]; - const className = splitted[1] || 'default'; - return {path, className}; - } - get genDir() { return this._genDir; } - get program() { return this._program; } - get skipCodeGeneration() { return this._skipCodeGeneration; } - get replaceExport() { return this._replaceExport; } - get typeCheck() { return this._typeCheck; } - get i18nFile() { return this._i18nFile; } - get i18nFormat() { return this._i18nFormat; } - get locale() { return this._locale; } - get missingTranslation() { return this._missingTranslation; } - get firstRun() { return this._firstRun; } - get lazyRoutes() { return this._lazyRoutes; } - get discoveredLazyRoutes() { return this._discoveredLazyRoutes; } - - private _setupOptions(options: AotPluginOptions) { - // Fill in the missing options. - if (!options.hasOwnProperty('tsConfigPath')) { - throw new Error('Must specify "tsConfigPath" in the configuration of @ngtools/webpack.'); - } - // TS represents paths internally with '/' and expects the tsconfig path to be in this format - this._tsConfigPath = options.tsConfigPath.replace(/\\/g, '/'); - - // Check the base path. - const maybeBasePath = path.resolve(process.cwd(), this._tsConfigPath); - let basePath = maybeBasePath; - if (fs.statSync(maybeBasePath).isFile()) { - basePath = path.dirname(basePath); - } - if (options.hasOwnProperty('basePath')) { - basePath = path.resolve(process.cwd(), options.basePath); - } - - const configResult = ts.readConfigFile(this._tsConfigPath, ts.sys.readFile); - if (configResult.error) { - const diagnostic = configResult.error; - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - - if (diagnostic.file) { - const {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!); - throw new Error(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message})`); - } else { - throw new Error(message); - } - } - - const tsConfigJson = configResult.config; - - if (options.hasOwnProperty('compilerOptions')) { - tsConfigJson.compilerOptions = Object.assign({}, - tsConfigJson.compilerOptions, - options.compilerOptions - ); - } - - // Default exclude to **/*.spec.ts files. - if (!options.hasOwnProperty('exclude')) { - options['exclude'] = ['**/*.spec.ts']; - } - - // Add custom excludes to default TypeScript excludes. - if (options.hasOwnProperty('exclude')) { - // If the tsconfig doesn't contain any excludes, we must add the default ones before adding - // any extra ones (otherwise we'd include all of these which can cause unexpected errors). - // This is the same logic as present in TypeScript. - if (!tsConfigJson.exclude) { - tsConfigJson['exclude'] = ['node_modules', 'bower_components', 'jspm_packages']; - if (tsConfigJson.compilerOptions && tsConfigJson.compilerOptions.outDir) { - tsConfigJson.exclude.push(tsConfigJson.compilerOptions.outDir); - } - } - - // Join our custom excludes with the existing ones. - tsConfigJson.exclude = tsConfigJson.exclude.concat(options.exclude); - } - - const tsConfig = ts.parseJsonConfigFileContent( - tsConfigJson, ts.sys, basePath, undefined, this._tsConfigPath); - - let fileNames = tsConfig.fileNames; - this._rootFilePath = fileNames; - - // Check the genDir. We generate a default gendir that's under basepath; it will generate - // a `node_modules` directory and because of that we don't want TypeScript resolution to - // resolve to that directory but the real `node_modules`. - let genDir = path.join(basePath, '$$_gendir'); - - this._compilerOptions = tsConfig.options; - this._angularCompilerOptions = Object.assign( - { genDir }, - this._compilerOptions, - tsConfig.raw['angularCompilerOptions'], - { basePath } - ); - - if (this._angularCompilerOptions.hasOwnProperty('genDir')) { - genDir = path.resolve(basePath, this._angularCompilerOptions.genDir); - this._angularCompilerOptions.genDir = genDir; - } - - this._basePath = basePath; - this._genDir = genDir; - - if (options.typeChecking !== undefined) { - this._typeCheck = options.typeChecking; - } - if (options.skipCodeGeneration !== undefined) { - this._skipCodeGeneration = options.skipCodeGeneration; - } - - this._compilerHost = new WebpackCompilerHost(this._compilerOptions, this._basePath); - - // Override some files in the FileSystem. - if (options.hostOverrideFileSystem) { - for (const filePath of Object.keys(options.hostOverrideFileSystem)) { - this._compilerHost.writeFile(filePath, options.hostOverrideFileSystem[filePath], false); - } - } - // Override some files in the FileSystem with paths from the actual file system. - if (options.hostReplacementPaths) { - for (const filePath of Object.keys(options.hostReplacementPaths)) { - const replacementFilePath = options.hostReplacementPaths[filePath]; - const content = this._compilerHost.readFile(replacementFilePath); - this._compilerHost.writeFile(filePath, content, false); - } - } - - this._program = ts.createProgram( - this._rootFilePath, this._compilerOptions, this._compilerHost); - - // We enable caching of the filesystem in compilerHost _after_ the program has been created, - // because we don't want SourceFile instances to be cached past this point. - this._compilerHost.enableCaching(); - - if (options.entryModule) { - this._entryModule = options.entryModule; - } else if ((tsConfig.raw['angularCompilerOptions'] as any) - && (tsConfig.raw['angularCompilerOptions'] as any).entryModule) { - this._entryModule = path.resolve(this._basePath, - (tsConfig.raw['angularCompilerOptions'] as any).entryModule); - } - - // still no _entryModule? => try to resolve from mainPath - if (!this._entryModule && options.mainPath) { - const mainPath = path.resolve(basePath, options.mainPath); - this._entryModule = resolveEntryModuleFromMain(mainPath, this._compilerHost, this._program); - } - - if (options.hasOwnProperty('i18nFile')) { - this._i18nFile = options.i18nFile; - } - if (options.hasOwnProperty('i18nFormat')) { - this._i18nFormat = options.i18nFormat; - } - if (options.hasOwnProperty('locale')) { - this._locale = options.locale; - } - if (options.hasOwnProperty('replaceExport')) { - this._replaceExport = options.replaceExport || this._replaceExport; - } - if (options.hasOwnProperty('missingTranslation')) { - const [MAJOR, MINOR, PATCH] = VERSION.full.split('.').map((x: string) => parseInt(x, 10)); - if (MAJOR < 4 || (MINOR == 2 && PATCH < 2)) { - console.warn((`The --missing-translation parameter will be ignored because it is only ` - + `compatible with Angular version 4.2.0 or higher. If you want to use it, please ` - + `upgrade your Angular version.\n`)); - } - this._missingTranslation = options.missingTranslation; - } - } - - private _findLazyRoutesInAst(): LazyRouteMap { - const result: LazyRouteMap = Object.create(null); - const changedFilePaths = this._compilerHost.getChangedFilePaths(); - for (const filePath of changedFilePaths) { - const fileLazyRoutes = findLazyRoutes(filePath, this._program, this._compilerHost); - for (const routeKey of Object.keys(fileLazyRoutes)) { - const route = fileLazyRoutes[routeKey]; - if (routeKey in this._lazyRoutes) { - if (route === null) { - this._lazyRoutes[routeKey] = null; - } else if (this._lazyRoutes[routeKey] !== route) { - this._compilation.warnings.push( - new Error(`Duplicated path in loadChildren detected during a rebuild. ` - + `We will take the latest version detected and override it to save rebuild time. ` - + `You should perform a full build to validate that your routes don't overlap.`) - ); - } - } else { - result[routeKey] = route; - } - } - } - return result; - } - - private _getLazyRoutesFromNgtools() { - try { - return __NGTOOLS_PRIVATE_API_2.listLazyRoutes({ - program: this._program, - host: this._compilerHost, - angularCompilerOptions: this._angularCompilerOptions, - entryModule: this._entryModule - }); - } catch (err) { - // We silence the error that the @angular/router could not be found. In that case, there is - // basically no route supported by the app itself. - if (err.message.startsWith('Could not resolve module @angular/router')) { - return {}; - } else { - throw err; - } - } - } - - // registration hook for webpack plugin - apply(compiler: any) { - this._compiler = compiler; - - // Decorate inputFileSystem to serve contents of CompilerHost. - // Use decorated inputFileSystem in watchFileSystem. - compiler.plugin('environment', () => { - compiler.inputFileSystem = new VirtualFileSystemDecorator( - compiler.inputFileSystem, this._compilerHost); - compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem); - }); - - compiler.plugin('invalid', () => { - // Turn this off as soon as a file becomes invalid and we're about to start a rebuild. - this._firstRun = false; - this._diagnoseFiles = {}; - }); - - // Add lazy modules to the context module for @angular/core/src/linker - compiler.plugin('context-module-factory', (cmf: any) => { - const angularCorePackagePath = require.resolve('@angular/core/package.json'); - const angularCorePackageJson = require(angularCorePackagePath); - const angularCoreModulePath = path.resolve(path.dirname(angularCorePackagePath), - angularCorePackageJson['module']); - // Pick the last part after the last node_modules instance. We do this to let people have - // a linked @angular/core or cli which would not be under the same path as the project - // being built. - const angularCoreModuleDir = path.dirname(angularCoreModulePath).split(/node_modules/).pop(); - - cmf.plugin('after-resolve', (result: any, callback: (err?: any, request?: any) => void) => { - if (!result) { - return callback(); - } - - // Alter only request from Angular; - // @angular/core/src/linker matches for 2.*.*, - // The other logic is for flat modules and requires reading the package.json of angular - // (see above). - if (!result.resource.endsWith(path.join('@angular/core/src/linker')) - && (angularCoreModuleDir && !result.resource.endsWith(angularCoreModuleDir))) { - return callback(null, result); - } - - this.done!.then(() => { - result.resource = this.genDir; - result.dependencies.forEach((d: any) => d.critical = false); - result.resolveDependencies = (_fs: any, _resource: any, _recursive: any, - _regExp: RegExp, cb: any) => { - const dependencies = Object.keys(this._lazyRoutes) - .map((key) => { - const value = this._lazyRoutes[key]; - if (value !== null) { - return new ContextElementDependency(value, key); - } else { - return null; - } - }) - .filter(x => !!x); - cb(null, dependencies); - }; - return callback(null, result); - }, () => callback(null)) - .catch(err => callback(err)); - }); - }); - - compiler.plugin('make', (compilation: any, cb: any) => this._make(compilation, cb)); - compiler.plugin('after-emit', (compilation: any, cb: any) => { - this._donePromise = null; - this._compilation = null; - compilation._ngToolsWebpackPluginInstance = null; - cb(); - }); - - compiler.plugin('after-resolvers', (compiler: any) => { - // Virtual file system. - // Wait for the plugin to be done when requesting `.ts` files directly (entry points), or - // when the issuer is a `.ts` file. - compiler.resolvers.normal.plugin('before-resolve', (request: any, cb: () => void) => { - if (request.request.endsWith('.ts') - || (request.context.issuer && request.context.issuer.endsWith('.ts'))) { - this.done!.then(() => cb(), () => cb()); - } else { - cb(); - } - }); - }); - - compiler.plugin('normal-module-factory', (nmf: any) => { - compiler.resolvers.normal.apply(new PathsPlugin({ - nmf, - tsConfigPath: this._tsConfigPath, - compilerOptions: this._compilerOptions, - compilerHost: this._compilerHost - })); - }); - } - - private _translateSourceMap(sourceText: string, fileName: string, - {line, character}: {line: number, character: number}) { - const match = sourceText.match(inlineSourceMapRe); - - if (!match) { - return {line, character, fileName}; - } - - // On any error, return line and character. - try { - const sourceMapJson = JSON.parse(Buffer.from(match[1], 'base64').toString()); - const consumer = new SourceMap.SourceMapConsumer(sourceMapJson); - - const original = consumer.originalPositionFor({ line, column: character }); - return { - line: typeof original.line == 'number' ? original.line : line, - character: typeof original.column == 'number' ? original.column : character, - fileName: original.source || fileName - }; - } catch (e) { - return {line, character, fileName}; - } - } - - diagnose(fileName: string) { - if (this._diagnoseFiles[fileName]) { - return; - } - this._diagnoseFiles[fileName] = true; - - const sourceFile = this._program.getSourceFile(fileName); - if (!sourceFile) { - return; - } - - const diagnostics: Array = [ - ...(this._program.getCompilerOptions().declaration - ? this._program.getDeclarationDiagnostics(sourceFile) : []), - ...this._program.getSyntacticDiagnostics(sourceFile), - ...this._program.getSemanticDiagnostics(sourceFile) - ]; - - if (diagnostics.length > 0) { - diagnostics.forEach(diagnostic => { - const messageText = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - let message; - - if (diagnostic.file) { - const position = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start!); - - const sourceText = diagnostic.file.getFullText(); - let {line, character, fileName} = this._translateSourceMap(sourceText, - diagnostic.file.fileName, position); - - message = `${fileName} (${line + 1},${character + 1}): ${messageText}`; - } else { - message = messageText; - } - - switch (diagnostic.category) { - case ts.DiagnosticCategory.Error: - this._compilation.errors.push(message); - break; - - default: - this._compilation.warnings.push(message); - } - }); - } - } - - private _make(compilation: any, cb: (err?: any, request?: any) => void) { - this._compilation = compilation; - if (this._compilation._ngToolsWebpackPluginInstance) { - return cb(new Error('An @ngtools/webpack plugin already exist for this compilation.')); - } - - this._compilation._ngToolsWebpackPluginInstance = this; - - this._resourceLoader = new WebpackResourceLoader(compilation); - - this._donePromise = Promise.resolve() - .then(() => { - if (this._skipCodeGeneration) { - return; - } - - // Create the Code Generator. - return __NGTOOLS_PRIVATE_API_2.codeGen({ - basePath: this._basePath, - compilerOptions: this._compilerOptions, - program: this._program, - host: this._compilerHost, - angularCompilerOptions: this._angularCompilerOptions, - i18nFile: this.i18nFile, - i18nFormat: this.i18nFormat, - locale: this.locale, - missingTranslation: this.missingTranslation, - - readResource: (path: string) => this._resourceLoader.get(path) - }); - }) - .then(() => { - // Get the ngfactory that were created by the previous step, and add them to the root - // file path (if those files exists). - const newRootFilePath = this._compilerHost.getChangedFilePaths() - .filter(x => x.match(/\.ngfactory\.ts$/)); - // Remove files that don't exist anymore, and add new files. - this._rootFilePath = this._rootFilePath - .filter(x => this._compilerHost.fileExists(x)) - .concat(newRootFilePath); - - // Create a new Program, based on the old one. This will trigger a resolution of all - // transitive modules, which include files that might just have been generated. - // This needs to happen after the code generator has been created for generated files - // to be properly resolved. - this._program = ts.createProgram( - this._rootFilePath, this._compilerOptions, this._compilerHost, this._program); - }) - .then(() => { - // Re-diagnose changed files. - const changedFilePaths = this._compilerHost.getChangedFilePaths(); - changedFilePaths.forEach(filePath => this.diagnose(filePath)); - }) - .then(() => { - if (this._typeCheck) { - const diagnostics = this._program.getGlobalDiagnostics(); - if (diagnostics.length > 0) { - const message = diagnostics - .map(diagnostic => { - const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - - if (diagnostic.file) { - const {line, character} = diagnostic.file.getLineAndCharacterOfPosition( - diagnostic.start!); - return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message})`; - } else { - return message; - } - }) - .join('\n'); - - throw new Error(message); - } - } - }) - .then(() => { - // We need to run the `listLazyRoutes` the first time because it also navigates libraries - // and other things that we might miss using the findLazyRoutesInAst. - this._discoveredLazyRoutes = this.firstRun - ? this._getLazyRoutesFromNgtools() - : this._findLazyRoutesInAst(); - - // Process the lazy routes discovered. - Object.keys(this.discoveredLazyRoutes) - .forEach(k => { - const lazyRoute = this.discoveredLazyRoutes[k]; - k = k.split('#')[0]; - if (lazyRoute === null) { - return; - } - - if (this.skipCodeGeneration) { - this._lazyRoutes[k] = lazyRoute; - } else { - const factoryPath = lazyRoute.replace(/(\.d)?\.ts$/, '.ngfactory.ts'); - const lr = path.relative(this.basePath, factoryPath); - this._lazyRoutes[k + '.ngfactory'] = path.join(this.genDir, lr); - } - }); - }) - .then(() => { - if (this._compilation.errors == 0) { - this._compilerHost.resetChangedFileTracker(); - } - - cb(); - }, (err: any) => { - compilation.errors.push(err.stack); - cb(); - }); - } -} diff --git a/packages/@ngtools/webpack/src/refactor.ts b/packages/@ngtools/webpack/src/refactor.ts deleted file mode 100644 index 57fc1449fb21..000000000000 --- a/packages/@ngtools/webpack/src/refactor.ts +++ /dev/null @@ -1,250 +0,0 @@ -// TODO: move this in its own package. -import * as path from 'path'; -import * as ts from 'typescript'; -import {SourceMapConsumer, SourceMapGenerator} from 'source-map'; - -const MagicString = require('magic-string'); - - -export interface TranspileOutput { - outputText: string; - sourceMap: any | null; -} - - -function resolve(filePath: string, _host: ts.CompilerHost, program: ts.Program) { - if (path.isAbsolute(filePath)) { - return filePath; - } - const compilerOptions = program.getCompilerOptions(); - const basePath = compilerOptions.baseUrl || compilerOptions.rootDir; - if (!basePath) { - throw new Error(`Trying to resolve '${filePath}' without a basePath.`); - } - return path.join(basePath, filePath); -} - - -export class TypeScriptFileRefactor { - private _fileName: string; - private _sourceFile: ts.SourceFile; - private _sourceString: any; - private _sourceText: string; - private _changed = false; - - get fileName() { return this._fileName; } - get sourceFile() { return this._sourceFile; } - get sourceText() { return this._sourceString.toString(); } - - constructor(fileName: string, - _host: ts.CompilerHost, - private _program?: ts.Program, - source?: string | null) { - fileName = resolve(fileName, _host, _program!).replace(/\\/g, '/'); - this._fileName = fileName; - if (_program) { - if (source) { - this._sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); - } else { - this._sourceFile = _program.getSourceFile(fileName); - } - } - if (!this._sourceFile) { - this._sourceFile = ts.createSourceFile(fileName, source || _host.readFile(fileName), - ts.ScriptTarget.Latest, true); - } - this._sourceText = this._sourceFile.getFullText(this._sourceFile); - this._sourceString = new MagicString(this._sourceText); - } - - /** - * Collates the diagnostic messages for the current source file - */ - getDiagnostics(typeCheck = true): ts.Diagnostic[] { - if (!this._program) { - return []; - } - let diagnostics: ts.Diagnostic[] = []; - // only concat the declaration diagnostics if the tsconfig config sets it to true. - if (this._program.getCompilerOptions().declaration == true) { - diagnostics = diagnostics.concat(this._program.getDeclarationDiagnostics(this._sourceFile)); - } - diagnostics = diagnostics.concat( - this._program.getSyntacticDiagnostics(this._sourceFile), - typeCheck ? this._program.getSemanticDiagnostics(this._sourceFile) : []); - - return diagnostics; - } - - /** - * Find all nodes from the AST in the subtree of node of SyntaxKind kind. - * @param node The root node to check, or null if the whole tree should be searched. - * @param kind The kind of nodes to find. - * @param recursive Whether to go in matched nodes to keep matching. - * @param max The maximum number of items to return. - * @return all nodes of kind, or [] if none is found - */ - findAstNodes(node: ts.Node | null, - kind: ts.SyntaxKind, - recursive = false, - max = Infinity): ts.Node[] { - if (max == 0) { - return []; - } - if (!node) { - node = this._sourceFile; - } - - let arr: ts.Node[] = []; - if (node.kind === kind) { - // If we're not recursively looking for children, stop here. - if (!recursive) { - return [node]; - } - - arr.push(node); - max--; - } - - if (max > 0) { - for (const child of node.getChildren(this._sourceFile)) { - this.findAstNodes(child, kind, recursive, max) - .forEach((node: ts.Node) => { - if (max > 0) { - arr.push(node); - } - max--; - }); - - if (max <= 0) { - break; - } - } - } - return arr; - } - - findFirstAstNode(node: ts.Node | null, kind: ts.SyntaxKind): ts.Node | null { - return this.findAstNodes(node, kind, false, 1)[0] || null; - } - - appendAfter(node: ts.Node, text: string): void { - this._sourceString.appendRight(node.getEnd(), text); - } - append(node: ts.Node, text: string): void { - this._sourceString.appendLeft(node.getEnd(), text); - } - - prependBefore(node: ts.Node, text: string) { - this._sourceString.appendLeft(node.getStart(), text); - } - - insertImport(symbolName: string, modulePath: string): void { - // Find all imports. - const allImports = this.findAstNodes(this._sourceFile, ts.SyntaxKind.ImportDeclaration); - const maybeImports = allImports - .filter((node: ts.ImportDeclaration) => { - // Filter all imports that do not match the modulePath. - return node.moduleSpecifier.kind == ts.SyntaxKind.StringLiteral - && (node.moduleSpecifier as ts.StringLiteral).text == modulePath; - }) - .filter((node: ts.ImportDeclaration) => { - // Remove import statements that are either `import 'XYZ'` or `import * as X from 'XYZ'`. - const clause = node.importClause as ts.ImportClause; - if (!clause || clause.name || !clause.namedBindings) { - return false; - } - return clause.namedBindings.kind == ts.SyntaxKind.NamedImports; - }) - .map((node: ts.ImportDeclaration) => { - // Return the `{ ... }` list of the named import. - return (node.importClause as ts.ImportClause).namedBindings as ts.NamedImports; - }); - - if (maybeImports.length) { - // There's an `import {A, B, C} from 'modulePath'`. - // Find if it's in either imports. If so, just return; nothing to do. - const hasImportAlready = maybeImports.some((node: ts.NamedImports) => { - return node.elements.some((element: ts.ImportSpecifier) => { - return element.name.text == symbolName; - }); - }); - if (hasImportAlready) { - return; - } - // Just pick the first one and insert at the end of its identifier list. - this.appendAfter(maybeImports[0].elements[maybeImports[0].elements.length - 1], - `, ${symbolName}`); - } else { - // Find the last import and insert after. - this.appendAfter(allImports[allImports.length - 1], - `import {${symbolName}} from '${modulePath}';`); - } - } - - removeNode(node: ts.Node) { - this._sourceString.remove(node.getStart(this._sourceFile), node.getEnd()); - this._changed = true; - } - - removeNodes(...nodes: Array) { - nodes.forEach(node => node && this.removeNode(node)); - } - - replaceNode(node: ts.Node, replacement: string) { - let replaceSymbolName: boolean = node.kind === ts.SyntaxKind.Identifier; - this._sourceString.overwrite(node.getStart(this._sourceFile), - node.getEnd(), - replacement, - { storeName: replaceSymbolName }); - this._changed = true; - } - - sourceMatch(re: RegExp) { - return this._sourceText.match(re) !== null; - } - - transpile(compilerOptions: ts.CompilerOptions): TranspileOutput { - const source = this.sourceText; - const result = ts.transpileModule(source, { - compilerOptions: Object.assign({}, compilerOptions, { - sourceMap: true, - inlineSources: false, - inlineSourceMap: false, - sourceRoot: '' - }), - fileName: this._fileName - }); - - if (result.sourceMapText) { - const sourceMapJson = JSON.parse(result.sourceMapText); - sourceMapJson.sources = [ this._fileName ]; - - const consumer = new SourceMapConsumer(sourceMapJson); - const map = SourceMapGenerator.fromSourceMap(consumer); - if (this._changed) { - const sourceMap = this._sourceString.generateMap({ - file: path.basename(this._fileName.replace(/\.ts$/, '.js')), - source: this._fileName, - hires: true, - }); - map.applySourceMap(new SourceMapConsumer(sourceMap), this._fileName); - } - - const sourceMap = map.toJSON(); - const fileName = process.platform.startsWith('win') - ? this._fileName.replace(/\//g, '\\') - : this._fileName; - sourceMap.sources = [ fileName ]; - sourceMap.file = path.basename(fileName, '.ts') + '.js'; - sourceMap.sourcesContent = [ this._sourceText ]; - - return { outputText: result.outputText, sourceMap }; - } else { - return { - outputText: result.outputText, - sourceMap: null - }; - } - } -} diff --git a/packages/@ngtools/webpack/src/resource_loader.ts b/packages/@ngtools/webpack/src/resource_loader.ts deleted file mode 100644 index a55d1f2716b8..000000000000 --- a/packages/@ngtools/webpack/src/resource_loader.ts +++ /dev/null @@ -1,117 +0,0 @@ -import {readFileSync} from 'fs'; -import * as vm from 'vm'; -import * as path from 'path'; - -const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin'); -const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin'); -const LoaderTargetPlugin = require('webpack/lib/LoaderTargetPlugin'); -const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); - - - -export class WebpackResourceLoader { - private _context: string; - private _uniqueId = 0; - - constructor(private _parentCompilation: any) { - this._context = _parentCompilation.context; - } - - private _compile(filePath: string, _content: string): Promise { - const compilerName = `compiler(${this._uniqueId++})`; - const outputOptions = { filename: filePath }; - const relativePath = path.relative(this._context || '', filePath); - const childCompiler = this._parentCompilation.createChildCompiler(relativePath, outputOptions); - childCompiler.context = this._context; - childCompiler.apply( - new NodeTemplatePlugin(outputOptions), - new NodeTargetPlugin(), - new SingleEntryPlugin(this._context, filePath), - new LoaderTargetPlugin('node') - ); - - // Store the result of the parent compilation before we start the child compilation - let assetsBeforeCompilation = Object.assign( - {}, - this._parentCompilation.assets[outputOptions.filename] - ); - - // Fix for "Uncaught TypeError: __webpack_require__(...) is not a function" - // Hot module replacement requires that every child compiler has its own - // cache. @see https://github.com/ampedandwired/html-webpack-plugin/pull/179 - childCompiler.plugin('compilation', function (compilation: any) { - if (compilation.cache) { - if (!compilation.cache[compilerName]) { - compilation.cache[compilerName] = {}; - } - compilation.cache = compilation.cache[compilerName]; - } - }); - - // Compile and return a promise - return new Promise((resolve, reject) => { - childCompiler.runAsChild((err: Error, entries: any[], childCompilation: any) => { - // Resolve / reject the promise - if (childCompilation && childCompilation.errors && childCompilation.errors.length) { - const errorDetails = childCompilation.errors.map(function (error: any) { - return error.message + (error.error ? ':\n' + error.error : ''); - }).join('\n'); - reject(new Error('Child compilation failed:\n' + errorDetails)); - } else if (err) { - reject(err); - } else { - // Replace [hash] placeholders in filename - const outputName = this._parentCompilation.mainTemplate.applyPluginsWaterfall( - 'asset-path', outputOptions.filename, { - hash: childCompilation.hash, - chunk: entries[0] - }); - - // Restore the parent compilation to the state like it was before the child compilation. - Object.keys(childCompilation.assets).forEach((fileName) => { - // If it wasn't there and it's a source file (absolute path) - delete it. - if (assetsBeforeCompilation[fileName] === undefined && path.isAbsolute(fileName)) { - delete this._parentCompilation.assets[fileName]; - } else { - // Otherwise, add it to the parent compilation. - this._parentCompilation.assets[fileName] = childCompilation.assets[fileName]; - } - }); - - resolve({ - // Hash of the template entry point. - hash: entries[0].hash, - // Output name. - outputName: outputName, - // Compiled code. - content: childCompilation.assets[outputName].source() - }); - } - }); - }); - } - - private _evaluate(fileName: string, source: string): Promise { - try { - const vmContext = vm.createContext(Object.assign({require: require}, global)); - const vmScript = new vm.Script(source, {filename: fileName}); - - // Evaluate code and cast to string - let newSource: string; - newSource = vmScript.runInContext(vmContext); - - if (typeof newSource == 'string') { - return Promise.resolve(newSource); - } - - return Promise.reject('The loader "' + fileName + '" didn\'t return a string.'); - } catch (e) { - return Promise.reject(e); - } - } - - get(filePath: string): Promise { - return this._compile(filePath, readFileSync(filePath, 'utf8')) - .then((result: any) => this._evaluate(result.outputName, result.content)); - } -} diff --git a/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts b/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts deleted file mode 100644 index e980df9df4a4..000000000000 --- a/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Stats } from 'fs'; -import { InputFileSystem, Callback } from './webpack'; -import { WebpackCompilerHost } from './compiler_host'; - - -export class VirtualFileSystemDecorator implements InputFileSystem { - constructor( - private _inputFileSystem: InputFileSystem, - private _webpackCompilerHost: WebpackCompilerHost - ) { } - - private _readFileSync(path: string): string | null { - if (this._webpackCompilerHost.fileExists(path, false)) { - return this._webpackCompilerHost.readFile(path); - } - - return null; - } - - private _statSync(path: string): Stats | null { - if (this._webpackCompilerHost.fileExists(path, false) - || this._webpackCompilerHost.directoryExists(path, false)) { - return this._webpackCompilerHost.stat(path); - } - - return null; - } - - private _readDirSync(path: string): string[] | null { - if (this._webpackCompilerHost.directoryExists(path, false)) { - const dirs = this._webpackCompilerHost.getDirectories(path); - const files = this._webpackCompilerHost.getFiles(path); - return files.concat(dirs); - } - - return null; - } - - stat(path: string, callback: Callback): void { - const result = this._statSync(path); - if (result) { - callback(null, result); - } else { - this._inputFileSystem.stat(path, callback); - } - } - - readdir(path: string, callback: Callback): void { - const result = this._readDirSync(path); - if (result) { - callback(null, result); - } else { - this._inputFileSystem.readdir(path, callback); - } - } - - readFile(path: string, callback: Callback): void { - const result = this._readFileSync(path); - if (result) { - callback(null, result); - } else { - this._inputFileSystem.readFile(path, callback); - } - } - - readJson(path: string, callback: Callback): void { - this._inputFileSystem.readJson(path, callback); - } - - readlink(path: string, callback: Callback): void { - this._inputFileSystem.readlink(path, callback); - } - - statSync(path: string): Stats { - const result = this._statSync(path); - return result || this._inputFileSystem.statSync(path); - } - - readdirSync(path: string): string[] { - const result = this._readDirSync(path); - return result || this._inputFileSystem.readdirSync(path); - } - - readFileSync(path: string): string { - const result = this._readFileSync(path); - return result || this._inputFileSystem.readFileSync(path); - } - - readJsonSync(path: string): string { - return this._inputFileSystem.readJsonSync(path); - } - - readlinkSync(path: string): string { - return this._inputFileSystem.readlinkSync(path); - } - - purge(changes?: string[] | string): void { - if (typeof changes === 'string') { - this._webpackCompilerHost.invalidate(changes); - } else if (Array.isArray(changes)) { - changes.forEach((fileName: string) => this._webpackCompilerHost.invalidate(fileName)); - } - if (this._inputFileSystem.purge) { - this._inputFileSystem.purge(changes); - } - } -} diff --git a/packages/@ngtools/webpack/src/webpack.ts b/packages/@ngtools/webpack/src/webpack.ts deleted file mode 100644 index 32d23501c8a8..000000000000 --- a/packages/@ngtools/webpack/src/webpack.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Stats } from 'fs'; - -// Declarations for (some) Webpack types. Only what's needed. - -export interface Request { - request?: Request; - relativePath: string; -} - -export interface Callback { - (err?: Error | null, result?: T): void; -} - -export interface ResolverCallback { - (request: Request, callback: Callback): void; -} - -export interface Tapable { - apply(plugin: ResolverPlugin): void; -} - -export interface ResolverPlugin extends Tapable { - plugin(source: string, cb: ResolverCallback): void; - doResolve(target: string, req: Request, desc: string, callback: Callback): void; - join(relativePath: string, innerRequest: Request): Request; -} - -export interface LoaderCallback { - (err: Error | null, source?: string, sourceMap?: string): void; -} - -export interface ModuleReason { - dependency: any; - module: NormalModule; -} - -export interface NormalModule { - buildTimestamp: number; - built: boolean; - reasons: ModuleReason[]; - resource: string; -} - -export interface NormalModuleFactory { - plugin(event: string, - callback: (data: NormalModuleFactoryRequest, callback: Callback) => void): any; -} - -export interface NormalModuleFactoryRequest { - request: string; - contextInfo: { issuer: string }; -} - -export interface LoaderContext { - _module: NormalModule; - - addDependency(path: string): void; - async(): LoaderCallback; - cacheable(): void; - - readonly resourcePath: string; - readonly query: any; -} - -export interface InputFileSystem { - stat(path: string, callback: Callback): void; - readdir(path: string, callback: Callback): void; - readFile(path: string, callback: Callback): void; - readJson(path: string, callback: Callback): void; - readlink(path: string, callback: Callback): void; - statSync(path: string): Stats; - readdirSync(path: string): string[]; - readFileSync(path: string): string; - readJsonSync(path: string): string; - readlinkSync(path: string): string; - purge(changes?: string[] | string): void; -} diff --git a/packages/@ngtools/webpack/tsconfig.json b/packages/@ngtools/webpack/tsconfig.json deleted file mode 100644 index 46fb29b75bb1..000000000000 --- a/packages/@ngtools/webpack/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - - "compilerOptions": { - "outDir": "../../../dist/@ngtools/webpack", - "rootDir": ".", - "baseUrl": "" - } -} diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 000000000000..aab7eadc1c92 --- /dev/null +++ b/packages/README.md @@ -0,0 +1,8 @@ +# `/packages` Folder + +This folder is the root of all defined packages in this repository. + +Packages that are marked as `private: true` will not be published to NPM. + +This folder includes a directory for every scope in NPM, without the `@` sign. Then one folder +per package, which contains the `package.json`. diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel new file mode 100644 index 000000000000..d0ab55750e98 --- /dev/null +++ b/packages/angular/build/BUILD.bazel @@ -0,0 +1,403 @@ +load("@devinfra//bazel/api-golden:index.bzl", "api_golden_test_npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "copy_to_bin", "jasmine_test", "npm_package", "ts_project") +load("//tools:ts_json_schema.bzl", "ts_json_schema") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages() + +ts_json_schema( + name = "application_schema", + src = "src/builders/application/schema.json", +) + +ts_json_schema( + name = "dev-server_schema", + src = "src/builders/dev-server/schema.json", +) + +ts_json_schema( + name = "extract_i18n_schema", + src = "src/builders/extract-i18n/schema.json", +) + +ts_json_schema( + name = "library_schema", + src = "src/builders/library/schema.json", +) + +ts_json_schema( + name = "ng_karma_schema", + src = "src/builders/karma/schema.json", +) + +ts_json_schema( + name = "ng_packagr_schema", + src = "src/builders/ng-packagr/schema.json", +) + +ts_json_schema( + name = "unit_test_schema", + src = "src/builders/unit-test/schema.json", +) + +copy_to_bin( + name = "schemas", + srcs = glob(["**/schema.json"]), +) + +RUNTIME_ASSETS = glob( + include = [ + "src/**/schema.json", + "src/**/*.js", + ], +) + [ + "builders.json", + "package.json", +] + +ts_project( + name = "build", + srcs = glob( + include = [ + "src/**/*.ts", + ], + exclude = [ + "src/test-utils.ts", + "src/**/*_spec.ts", + "src/**/tests/**/*.ts", + "src/testing/**/*.ts", + ], + ) + [ + "index.ts", + "//packages/angular/build:src/builders/application/schema.ts", + "//packages/angular/build:src/builders/dev-server/schema.ts", + "//packages/angular/build:src/builders/extract-i18n/schema.ts", + "//packages/angular/build:src/builders/karma/schema.ts", + "//packages/angular/build:src/builders/library/schema.ts", + "//packages/angular/build:src/builders/ng-packagr/schema.ts", + "//packages/angular/build:src/builders/unit-test/schema.ts", + ], + data = RUNTIME_ASSETS, + deps = [ + ":node_modules/@ampproject/remapping", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular/ssr", + ":node_modules/@babel/core", + ":node_modules/@inquirer/confirm", + ":node_modules/@oxc-project/types", + ":node_modules/@parcel/watcher", + ":node_modules/@vitejs/plugin-basic-ssl", + ":node_modules/beasties", + ":node_modules/browserslist", + ":node_modules/chokidar", + ":node_modules/https-proxy-agent", + ":node_modules/istanbul-lib-instrument", + ":node_modules/jsonc-parser", + ":node_modules/less", + ":node_modules/listr2", + ":node_modules/lmdb", + ":node_modules/magic-string", + ":node_modules/mrmime", + ":node_modules/ng-packagr", + ":node_modules/oxc-parser", + ":node_modules/parse5-html-rewriting-stream", + ":node_modules/picomatch", + ":node_modules/piscina", + ":node_modules/postcss", + ":node_modules/rolldown", + ":node_modules/rolldown-plugin-dts", + ":node_modules/rollup", + ":node_modules/sass", + ":node_modules/sass-embedded", + ":node_modules/source-map-support", + ":node_modules/tinyglobby", + ":node_modules/vite", + ":node_modules/vitest", + ":node_modules/xxhash-wasm", + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/localize", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/platform-server", + "//:node_modules/@angular/service-worker", + "//:node_modules/@types/babel__core", + "//:node_modules/@types/karma", + "//:node_modules/@types/less", + "//:node_modules/@types/node", + "//:node_modules/@types/picomatch", + "//:node_modules/@types/semver", + "//:node_modules/esbuild", + "//:node_modules/esbuild-wasm", + "//:node_modules/karma", + "//:node_modules/semver", + "//:node_modules/tslib", + "//:node_modules/typescript", + ], +) + +ts_project( + name = "unit_test_lib", + testonly = True, + srcs = glob( + include = ["src/**/*_spec.ts"], + exclude = ["src/builders/**/tests/**"], + ), + deps = [ + ":build", + ":node_modules/@angular-devkit/core", + ":node_modules/@babel/core", + ":node_modules/@parcel/watcher", + ":node_modules/oxc-parser", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/router", + "//:node_modules/@types/jasmine", + "//:node_modules/esbuild", + "//:node_modules/prettier", + "//:node_modules/typescript", + "//packages/angular/build/private", + ], +) + +jasmine_test( + name = "test", + data = [":unit_test_lib"], + shard_count = 4, +) + +ts_project( + name = "application_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/application/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + + # Base dependencies for the application in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "dev-server_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/dev-server/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + + # dev server only test deps + "//:node_modules/@types/http-proxy", + "//:node_modules/@types/node", + "//:node_modules/http-proxy", + "//:node_modules/puppeteer", + + # Base dependencies for the application in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/ng-packagr", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "karma_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/karma/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # karma specific test deps + "//:node_modules/karma-chrome-launcher", + "//:node_modules/karma-coverage", + "//:node_modules/karma-jasmine", + "//:node_modules/karma-jasmine-html-reporter", + "//:node_modules/puppeteer", + + # Base dependencies for the karma in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "unit-test_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/unit-test/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # unit test specific test deps + ":node_modules/@vitest/coverage-v8", + ":node_modules/vitest", + ":node_modules/jsdom", + + # Base dependencies for the hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + +ts_project( + name = "library_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/library/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # Base dependencies for the library in hello-world-lib. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + +jasmine_test( + name = "application_integration_tests", + size = "medium", + data = [":application_integration_test_lib"], + shard_count = 25, +) + +jasmine_test( + name = "dev-server_integration_tests", + size = "medium", + data = [":dev-server_integration_test_lib"], + env = { + # Force IPv4 to resolve RBE resolution issues + "NODE_OPTIONS": "--dns-result-order=ipv4first", + }, + shard_count = 10, +) + +jasmine_test( + name = "karma_integration_tests", + size = "medium", + data = [":karma_integration_test_lib"], + env = { + # TODO: Replace Puppeteer downloaded browsers with Bazel-managed browsers, + # or standardize to avoid complex configuration like this! + "PUPPETEER_DOWNLOAD_PATH": "../../../node_modules/puppeteer/downloads", + }, + shard_count = 10, +) + +jasmine_test( + name = "unit-test_integration_tests", + size = "medium", + data = [":unit-test_integration_test_lib"], + shard_count = 10, +) + +jasmine_test( + name = "library_integration_tests", + size = "medium", + data = [":library_integration_test_lib"], + shard_count = 6, +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular_devkit/architect:package.json", + ], + stamp_files = [ + "src/utils/version.js", + "src/tools/esbuild/target.js", + "src/utils/normalize-cache.js", + "src/utils/supported-browsers.js", + ], + tags = ["release-package"], + deps = RUNTIME_ASSETS + [ + ":README.md", + ":build", + ":license", + "//packages/angular/build/private", + ], +) + +api_golden_test_npm_package( + name = "api", + data = [ + ":npm_package", + "//goldens:public-api", + ], + golden_dir = "goldens/public-api/angular/build", + npm_package = "packages/angular/build/npm_package", +) diff --git a/packages/angular/build/README.md b/packages/angular/build/README.md new file mode 100644 index 000000000000..62249f7fe422 --- /dev/null +++ b/packages/angular/build/README.md @@ -0,0 +1,5 @@ +# Angular Build System for Applications and Libraries + +The sources for this package are in the [Angular CLI](https://github.com/angular/angular-cli) repository. Please file issues and pull requests against that repository. + +Usage information and reference details can be found in repository [README](https://github.com/angular/angular-cli/blob/main/README.md) file. diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json new file mode 100644 index 000000000000..d73c7a18fe58 --- /dev/null +++ b/packages/angular/build/builders.json @@ -0,0 +1,39 @@ +{ + "builders": { + "application": { + "implementation": "./src/builders/application/index", + "schema": "./src/builders/application/schema.json", + "description": "Build an application." + }, + "dev-server": { + "implementation": "./src/builders/dev-server/index", + "schema": "./src/builders/dev-server/schema.json", + "description": "Execute a development server for an application." + }, + "extract-i18n": { + "implementation": "./src/builders/extract-i18n/index", + "schema": "./src/builders/extract-i18n/schema.json", + "description": "Extract i18n messages from an application." + }, + "karma": { + "implementation": "./src/builders/karma", + "schema": "./src/builders/karma/schema.json", + "description": "Run Karma unit tests." + }, + "library": { + "implementation": "./src/builders/library/index", + "schema": "./src/builders/library/schema.json", + "description": "Build an Angular library package conforming to the Angular Package Format (APF)." + }, + "ng-packagr": { + "implementation": "./src/builders/ng-packagr/index", + "schema": "./src/builders/ng-packagr/schema.json", + "description": "Build a library with ng-packagr." + }, + "unit-test": { + "implementation": "./src/builders/unit-test", + "schema": "./src/builders/unit-test/schema.json", + "description": "Run application unit tests." + } + } +} diff --git a/packages/angular/build/index.ts b/packages/angular/build/index.ts new file mode 100644 index 000000000000..e6da94cc7ded --- /dev/null +++ b/packages/angular/build/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from './src/index'; diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json new file mode 100644 index 000000000000..7bcd33b3eff7 --- /dev/null +++ b/packages/angular/build/package.json @@ -0,0 +1,132 @@ +{ + "name": "@angular/build", + "version": "0.0.0-PLACEHOLDER", + "description": "Official build system for Angular", + "keywords": [ + "angular", + "angular-cli" + ], + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./private": { + "default": "./src/private.js" + }, + "./package.json": "./package.json" + }, + "builders": "builders.json", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@babel/core": "8.0.7", + "@inquirer/confirm": "6.3.4", + "@parcel/watcher": "2.6.0", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.5.5", + "browserslist": "^4.26.0", + "chokidar": "6.0.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.4.0", + "magic-string": "1.4.3", + "mrmime": "2.0.1", + "oxc-parser": "0.155.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "rolldown": "1.2.14", + "rolldown-plugin-dts": "0.28.8", + "sass": "1.106.0", + "sass-embedded": "1.106.0", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.3.5", + "xxhash-wasm": "1.1.0" + }, + "optionalDependencies": { + "@oxc-parser/binding-wasm32-wasi": "0.155.0", + "lmdb": "3.5.8" + }, + "devDependencies": { + "@angular-devkit/core": "workspace:*", + "@angular/ssr": "workspace:*", + "@oxc-project/types": "0.155.0", + "@vitest/coverage-v8": "5.0.5", + "istanbul-lib-instrument": "6.0.3", + "jsdom": "30.1.3", + "less": "4.9.1", + "ng-packagr": "22.3.0-next.2", + "postcss": "8.5.31", + "rollup": "4.64.12", + "rxjs": "7.8.2", + "vitest": "5.0.5" + }, + "peerDependencies": { + "@angular/compiler": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/compiler-cli": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/core": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/localize": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/platform-browser": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/platform-server": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/service-worker": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/ssr": "^0.0.0-PLACEHOLDER", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "0.0.0-NG-PACKAGR-PEER-DEP", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } +} diff --git a/packages/angular/build/private/BUILD.bazel b/packages/angular/build/private/BUILD.bazel new file mode 100644 index 000000000000..c3a100de897f --- /dev/null +++ b/packages/angular/build/private/BUILD.bazel @@ -0,0 +1,11 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "private", + srcs = ["index.ts"], + deps = [ + "//packages/angular/build", + ], +) diff --git a/packages/angular/build/private/index.ts b/packages/angular/build/private/index.ts new file mode 100644 index 000000000000..1c2b76656baf --- /dev/null +++ b/packages/angular/build/private/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from '../src/private'; diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts new file mode 100644 index 000000000000..eaae50848a48 --- /dev/null +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -0,0 +1,443 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { + BuildOutputAsset, + ExecutionResult, + RebuildState, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from '../../tools/esbuild/stylesheets/sass-language'; +import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils'; +import { initializeHash } from '../../utils/hash'; +import { NormalizedCachedOptions } from '../../utils/normalize-cache'; +import { ChangedFiles } from '../../utils/watcher'; +import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; +import { + ComponentUpdateResult, + FullResult, + IncrementalResult, + Result, + ResultKind, + ResultMessage, +} from './results'; + +// eslint-disable-next-line max-lines-per-function +export async function* runEsBuildBuildAction( + action: (rebuildState?: RebuildState) => Promise, + options: { + workspaceRoot: string; + projectRoot: string; + outputOptions: NormalizedOutputOptions; + logger: BuilderContext['logger']; + cacheOptions: NormalizedCachedOptions; + watch?: boolean; + verbose?: boolean; + progress?: boolean; + poll?: number; + signal?: AbortSignal; + preserveSymlinks?: boolean; + clearScreen?: boolean; + colors?: boolean; + jsonLogs?: boolean; + incrementalResults?: boolean; + }, +): AsyncIterable { + const { + watch, + poll, + clearScreen, + logger, + cacheOptions, + outputOptions, + verbose, + projectRoot, + workspaceRoot, + progress, + preserveSymlinks, + colors, + jsonLogs, + incrementalResults, + } = options; + + await initializeHash(); + + const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; + + // Initial build + let result: ExecutionResult | undefined; + try { + // Perform the build action + result = await withProgress('Building...', () => action()); + + // Log all diagnostic (error/warning/logs) messages + await logMessages(logger, result, colors, jsonLogs); + } finally { + // Ensure Sass workers are shutdown if not watching or if the initial build failed + if (!watch || !result) { + shutdownSassWorkerPool(); + } + } + + let watcher: import('../../utils/watcher').BuildWatcher | undefined; + let watchLoopStarted = false; + try { + // Setup watcher if watch mode enabled + if (watch) { + if (progress) { + logger.info('Watch mode enabled. Watching for file changes...'); + } + + // Setup a watcher + const { setupWatcher } = await import('../../utils/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath: outputOptions.base, + cacheOptions, + poll, + preserveSymlinks, + signal: options.signal, + watchFiles: result.watchFiles, + }); + } + + // Output the first build results after setting up the watcher to ensure that any code executed + // higher in the iterator call stack will trigger the watcher. This is particularly relevant for + // unit tests which execute the builder and modify the file system programmatically. + const outputResults = [...emitOutputResults(result, outputOptions)]; + if (!watch) { + await result.dispose(); + // Set to true to prevent double-disposal of the result context in the finally block on generator exit + watchLoopStarted = true; + } + yield* outputResults; + + // Finish if watch mode is not enabled + if (!watcher) { + return; + } + + watchLoopStarted = true; + } finally { + if (!watchLoopStarted && result) { + await result.dispose(); + } + } + + // Used to force a full result on next rebuild if there were initial errors. + // This ensures at least one full result is emitted. + let hasInitialErrors = result.errors.length > 0; + + // Wait for changes and rebuild as needed + const currentWatchFiles = new Set(result.watchFiles); + try { + for await (const changes of watcher) { + if (options.signal?.aborted) { + break; + } + + if (clearScreen) { + // eslint-disable-next-line no-console + console.clear(); + } + + if (verbose) { + logger.info(changes.toDebugString()); + } + + // Clear removed files from current watch files + changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath)); + + const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + + const rebuildState = result.createRebuildState(changes); + result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState)); + + // Log all diagnostic (error/warning/logs) messages + await logMessages(logger, result, colors, jsonLogs); + + // Update watched locations provided by the new build result. + // Keep watching all previous files if there are any errors; otherwise consider all + // files stale until confirmed present in the new result's watch files. + const staleWatchFiles = result.errors.length > 0 ? undefined : new Set(currentWatchFiles); + for (const watchFile of result.watchFiles) { + if (!currentWatchFiles.has(watchFile)) { + // Add new watch location + watcher.add(watchFile); + currentWatchFiles.add(watchFile); + } + + // Present so remove from stale locations + staleWatchFiles?.delete(watchFile); + } + // Remove any stale locations if the build was successful + if (staleWatchFiles?.size) { + watcher.remove([...staleWatchFiles]); + for (const staleFile of staleWatchFiles) { + currentWatchFiles.delete(staleFile); + } + } + + for (const outputResult of emitOutputResults( + result, + outputOptions, + changes, + incrementalResults && !hasInitialErrors ? rebuildState : undefined, + )) { + yield outputResult; + } + + // Clear initial build errors flag if no errors are now present + hasInitialErrors &&= result.errors.length > 0; + } + } finally { + // Stop the watcher and cleanup incremental rebuild state + await Promise.allSettled([watcher.close(), result.dispose()]); + + shutdownSassWorkerPool(); + } +} + +function* emitOutputResults( + { + outputFiles, + assetFiles, + errors, + warnings, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + templateUpdates, + }: ExecutionResult, + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + changes?: ChangedFiles, + rebuildState?: RebuildState, +): Iterable { + if (errors.length > 0) { + yield { + kind: ResultKind.Failure, + errors: errors as ResultMessage[], + warnings: warnings as ResultMessage[], + detail: { + outputOptions, + }, + }; + + return; + } + + // Use a full result if there is no rebuild state (no prior build result) + if (!rebuildState || !changes) { + yield createFullResult( + outputFiles, + assetFiles, + warnings, + outputOptions, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + ); + + return; + } + + // Template updates only exist if no other JS changes have occurred. + // A full page reload may be required based on the following incremental output change analysis. + const hasTemplateUpdates = !!templateUpdates?.size; + + // Use an incremental result if previous output information is available + const { previousAssetsInfo, previousOutputInfo } = rebuildState; + + const incrementalResult: IncrementalResult = { + kind: ResultKind.Incremental, + warnings: warnings as ResultMessage[], + // Initially attempt to use a background update of files to support component updates. + background: hasTemplateUpdates, + added: [], + removed: [], + modified: [], + files: [], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; + + let hasCssUpdates = false; + + // Initially assume all previous output files have been removed + const removedOutputFiles = new Map(previousOutputInfo); + for (const file of outputFiles) { + const key = `${file.type}:${file.path}`; + removedOutputFiles.delete(key); + + const previousHash = previousOutputInfo.get(key)?.hash; + let needFile = false; + if (previousHash === undefined) { + needFile = true; + incrementalResult.added.push(file.path); + } else if (previousHash !== file.hash) { + needFile = true; + incrementalResult.modified.push(file.path); + } + + if (needFile) { + if (file.path.endsWith('.css')) { + hasCssUpdates = true; + } else if (!canBackgroundUpdate(file)) { + incrementalResult.background = false; + } + + incrementalResult.files.push({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory', + hash: file.hash, + }); + } + } + + // Initially assume all previous assets files have been removed + const removedAssetFiles = new Map(previousAssetsInfo); + for (const { source, destination } of assetFiles) { + removedAssetFiles.delete(source); + + if (!previousAssetsInfo.has(source)) { + incrementalResult.added.push(destination); + incrementalResult.background = false; + } else if (changes.modified.has(source)) { + incrementalResult.modified.push(destination); + incrementalResult.background = false; + } else { + continue; + } + + hasCssUpdates ||= destination.endsWith('.css'); + + incrementalResult.files.push({ + path: destination, + type: BuildOutputFileType.Browser, + source, + origin: 'disk', + }); + } + + // Do not remove stale files yet if there are template updates. + // Component chunk files may still be referenced in running browser code. + // Module evaluation time component updates will update any of these files. + // This typically occurs when a lazy component is changed that has not yet + // been accessed at runtime. + if (hasTemplateUpdates && incrementalResult.background) { + removedOutputFiles.clear(); + } + + // Include the removed output and asset files + incrementalResult.removed.push( + ...Array.from(removedOutputFiles.values(), ({ type, path }) => ({ + path, + type, + })), + ...Array.from(removedAssetFiles.values(), (path) => ({ + path, + type: BuildOutputFileType.Browser, + })), + ); + + yield incrementalResult; + + // If there are template updates and the incremental update was background only, a component + // update is possible. + if (hasTemplateUpdates && incrementalResult.background) { + // Template changes may be accompanied by stylesheet changes and these should also be updated hot when possible. + if (hasCssUpdates) { + const styleResult: IncrementalResult = { + kind: ResultKind.Incremental, + added: incrementalResult.added.filter(isCssFilePath), + removed: incrementalResult.removed.filter(({ path }) => isCssFilePath(path)), + modified: incrementalResult.modified.filter(isCssFilePath), + files: incrementalResult.files.filter((file) => isCssFilePath(file.path)), + }; + + yield styleResult; + } + + const updateResult: ComponentUpdateResult = { + kind: ResultKind.ComponentUpdate, + updates: Array.from(templateUpdates, ([id, content]) => ({ + type: 'template', + id, + content, + })), + }; + + yield updateResult; + } +} + +function createFullResult( + outputFiles: readonly BuildOutputFile[], + assetFiles: readonly BuildOutputAsset[], + warnings: readonly unknown[], + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + externalMetadata: unknown, + htmlIndexPath: unknown, + htmlBaseHref: unknown, +): FullResult { + return { + kind: ResultKind.Full, + warnings: warnings as ResultMessage[], + files: [ + ...assetFiles.map(({ source, destination }) => ({ + path: destination, + type: BuildOutputFileType.Browser, + source, + origin: 'disk' as const, + })), + ...outputFiles.map((file) => ({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory' as const, + hash: file.hash, + })), + ], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; +} + +function isCssFilePath(filePath: string): boolean { + return /\.css(?:\.map)?$/i.test(filePath); +} + +function canBackgroundUpdate(file: BuildOutputFile): boolean { + // Files in the output root are not served and do not affect the + // application available with the development server. + if (file.type === BuildOutputFileType.Root) { + return true; + } + + // Updates to non-JS files must signal an update with the dev server + // except the service worker configuration which is special cased. + return /(?:\.m?js|\.map)$/.test(file.path) || file.path === 'ngsw.json'; +} diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts new file mode 100644 index 000000000000..7941ebe1f6a7 --- /dev/null +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -0,0 +1,418 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview This file provides a function to optimize JavaScript chunks using rolldown. + * It is designed to be used after an esbuild build to further optimize the output. + * The main function, `optimizeChunks`, takes the result of an esbuild build, + * identifies the main browser entry point, and then uses rolldown to rebundle + * and optimize the chunks. This process can result in smaller and more efficient + * code by combining and restructuring the original chunks. The file also includes + * helper functions to convert rolldown's output into an esbuild-compatible + * metafile, allowing for consistent analysis and reporting of the build output. + */ + +import type { Message, Metafile } from 'esbuild'; +import assert from 'node:assert'; +import type { Plugin } from 'rollup'; +import { BundleMergedContextResult } from '../../tools/esbuild/bundler-context'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, + findTransitiveInitialFiles, +} from '../../tools/esbuild/bundler-files'; +import { useRolldownChunks } from '../../utils/environment-options'; +import { assertIsError } from '../../utils/error'; +import { loadRolldown } from '../../utils/load-rolldown'; +import { toPosixPath } from '../../utils/path'; + +/** + * Represents a minimal subset of a Rollup/Rolldown output asset. + * This is manually defined to avoid hard dependencies on both bundlers' types + * and to ensure compatibility since Rolldown and Rollup types have slight differences + * but share these core properties. + */ +interface OutputAsset { + type: 'asset'; + fileName: string; + source: string | Uint8Array; +} + +/** + * Represents a minimal subset of a Rollup/Rolldown output chunk. + * This is manually defined to avoid hard dependencies on both bundlers' types + * and to ensure compatibility since Rolldown and Rollup types have slight differences + * but share these core properties. + */ +interface OutputChunk { + type: 'chunk'; + fileName: string; + code: string; + modules: Record; + imports: string[]; + dynamicImports?: string[]; + exports: string[]; + isEntry: boolean; + facadeModuleId: string | null | undefined; + map?: { toString(): string } | null; + sourcemapFileName?: string | null; +} + +/** + * Converts the output of a bundle build into an esbuild-compatible metafile. + * @param bundleOutput The output of a bundle build. + * @param originalMetafile The original esbuild metafile from the build. + * @returns An esbuild-compatible metafile. + */ +function bundleOutputToEsbuildMetafile( + bundleOutput: (OutputChunk | OutputAsset)[], + originalMetafile: Metafile, +): Metafile { + const newMetafile: Metafile = { + inputs: originalMetafile.inputs, + outputs: {}, + }; + + const intermediateChunkSizes: Record = {}; + for (const [path, output] of Object.entries(originalMetafile.outputs)) { + intermediateChunkSizes[path] = Object.values(output.inputs).reduce( + (s, i) => s + i.bytesInOutput, + 0, + ); + } + + for (const chunk of bundleOutput) { + if (chunk.type === 'asset') { + newMetafile.outputs[chunk.fileName] = { + bytes: + typeof chunk.source === 'string' + ? Buffer.byteLength(chunk.source, 'utf8') + : chunk.source.length, + inputs: {}, + imports: [], + exports: [], + }; + continue; + } + + const newOutputInputs: Record = {}; + if (chunk.modules) { + for (const [moduleId, renderedModule] of Object.entries(chunk.modules)) { + const originalOutputEntry = originalMetafile.outputs[moduleId]; + if (!originalOutputEntry?.inputs) { + continue; + } + + const totalOriginalBytesInModule = intermediateChunkSizes[moduleId]; + if (totalOriginalBytesInModule === 0) { + continue; + } + + // Read once per module: in Rolldown, `renderedLength` is an uncached getter that + // copies the entire module code across the NAPI bridge on each access. + const { renderedLength } = renderedModule; + + for (const [originalInputPath, originalInputInfo] of Object.entries( + originalOutputEntry.inputs, + )) { + const proportion = originalInputInfo.bytesInOutput / totalOriginalBytesInModule; + const newBytesInOutput = Math.floor(renderedLength * proportion); + + const existing = newOutputInputs[originalInputPath]; + if (existing) { + existing.bytesInOutput += newBytesInOutput; + } else { + newOutputInputs[originalInputPath] = { bytesInOutput: newBytesInOutput }; + } + + if (!newMetafile.inputs[originalInputPath]) { + newMetafile.inputs[originalInputPath] = originalMetafile.inputs[originalInputPath]; + } + } + } + } + + const imports = [ + ...chunk.imports.map((path) => ({ path, kind: 'import-statement' as const })), + ...(chunk.dynamicImports?.map((path) => ({ path, kind: 'dynamic-import' as const })) ?? []), + ]; + + let entryPoint: string | undefined; + if (chunk.facadeModuleId) { + const posixFacadeModuleId = toPosixPath(chunk.facadeModuleId); + for (const [outputPath, output] of Object.entries(originalMetafile.outputs)) { + if (posixFacadeModuleId.endsWith(outputPath)) { + entryPoint = output.entryPoint; + break; + } + } + } + + newMetafile.outputs[chunk.fileName] = { + bytes: Buffer.byteLength(chunk.code, 'utf8'), + inputs: newOutputInputs, + imports, + exports: chunk.exports ?? [], + entryPoint, + }; + } + + return newMetafile; +} + +/** + * Creates an esbuild message object for a chunk optimization failure. + * @param message The error message detailing the cause of the failure. + * @returns A partial esbuild message object. + */ +function createChunkOptimizationFailureMessage(message: string): Message { + // Most of these fields are not actually needed for printing the error + return { + id: '', + text: 'Chunk optimization failed', + detail: undefined, + pluginName: '', + location: null, + notes: [ + { + text: message, + location: null, + }, + ], + }; +} + +/** + * Optimizes the chunks of a build result using rolldown. + * + * This function takes the output of an esbuild build, identifies the main browser entry point, + * and uses rolldown to bundle and optimize the JavaScript chunks. The optimized chunks + * replace the original ones in the build result, and the metafile is updated to reflect + * the changes. + * + * @param original The original build result from esbuild. + * @param sourcemap A boolean or 'hidden' to control sourcemap generation. + * @returns A promise that resolves to the updated build result with optimized chunks. + */ +// eslint-disable-next-line max-lines-per-function +export async function optimizeChunks( + original: BundleMergedContextResult, + sourcemap: boolean | 'hidden', +): Promise { + // Failed builds cannot be optimized + if (original.errors) { + return original; + } + + // Find the main browser entrypoint + let mainFile; + for (const [file, record] of original.initialFiles) { + if ( + record.name === 'main' && + record.entrypoint && + !record.serverFile && + record.type === 'script' + ) { + mainFile = file; + break; + } + } + + // No action required if no browser main entrypoint or metafile for stats + if (!mainFile || !original.metafiles.browser) { + return original; + } + + const chunks: Record = {}; + const maps: Record = {}; + for (const originalFile of original.outputFiles) { + if (originalFile.type !== BuildOutputFileType.Browser) { + continue; + } + + if (originalFile.path.endsWith('.js')) { + chunks[originalFile.path] = originalFile; + } else if (originalFile.path.endsWith('.js.map')) { + // Create mapping of JS file to sourcemap content + maps[originalFile.path.slice(0, -4)] = originalFile; + } + } + + const usedChunks = new Set(); + + let bundle; + let optimizedOutput: (OutputChunk | OutputAsset)[]; + try { + const plugins = [ + { + name: 'angular-bundle', + resolveId(source: string) { + // Remove leading `./` if present + const file = source[0] === '.' && source[1] === '/' ? source.slice(2) : source; + + if (chunks[file]) { + return file; + } + + // All other identifiers are considered external to maintain behavior + return { id: source, external: true }; + }, + load(id: string) { + assert( + chunks[id], + `Angular chunk content should always be present in chunk optimizer [${id}].`, + ); + + usedChunks.add(id); + + const result = { + code: chunks[id].text, + map: maps[id]?.text, + }; + + return result; + }, + }, + ]; + + const chunkFileNames = (chunkInfo: { name: string }) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`; + + if (useRolldownChunks) { + const { rolldown } = await loadRolldown(); + bundle = await rolldown({ + input: mainFile, + // Allow Rolldown to extend the main entry chunk's exports when merging shared initial + // modules into `main` so that lazy chunks can import those symbols directly from `main`. + preserveEntrySignatures: 'allow-extension', + plugins, + }); + + let result = await bundle.generate({ + minify: { mangle: false, compress: false }, + sourcemap, + codeSplitting: { + // Consolidate all modules statically reachable from the initial entry point (`$initial`) + // back into the `main` chunk. This prevents multi-level initial chunk waterfalls caused + // by esbuild splitting out modules that are shared across `main` and subsets of lazy + // routes, while keeping shared chunks used exclusively by lazy routes separate. + groups: [{ name: 'main', tags: ['$initial'] }], + }, + chunkFileNames, + }); + + // If any module in `$initial` uses top-level `await`, Rolldown cannot fold the manual + // `main` group into the entry chunk and emits an empty entry facade instead. Fall back to + // Rolldown's default TLA-aware chunking in that case. + if ( + result.output.some( + (chunk) => + chunk.type === 'chunk' && chunk.isEntry && Object.keys(chunk.modules).length === 0, + ) + ) { + result = await bundle.generate({ + minify: { mangle: false, compress: false }, + sourcemap, + chunkFileNames, + }); + } + + optimizedOutput = result.output; + } else { + let rollup; + try { + rollup = (await import('rollup')).rollup; + } catch { + throw new Error( + `Rollup is required when 'NG_BUILD_CHUNKS_ROLLDOWN' is set to false. ` + + `Please install 'rollup' manually (e.g. 'npm install rollup --save-dev') to use this fallback.`, + ); + } + bundle = await rollup({ + input: mainFile, + plugins: plugins as Plugin[], + }); + + const result = await bundle.generate({ + compact: true, + sourcemap, + chunkFileNames, + }); + optimizedOutput = result.output; + } + } catch (e) { + assertIsError(e); + + return { + errors: [createChunkOptimizationFailureMessage(e.message)], + warnings: original.warnings, + }; + } finally { + await bundle?.close(); + } + + // Update metafile + const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafiles.browser); + // Add back the outputs that were not part of the optimization + for (const [path, output] of Object.entries(original.metafiles.browser.outputs)) { + if (usedChunks.has(path)) { + continue; + } + + newMetafile.outputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + if (!newMetafile.inputs[inputPath]) { + newMetafile.inputs[inputPath] = original.metafiles.browser.inputs[inputPath]; + } + } + } + original.metafiles.browser = newMetafile; + + // Remove used chunks and associated sourcemaps from the original result + original.outputFiles = original.outputFiles.filter( + (file) => + !usedChunks.has(file.path) && + !(file.path.endsWith('.map') && usedChunks.has(file.path.slice(0, -4))), + ); + + // Add new optimized chunks + for (const optimizedFile of optimizedOutput) { + if (optimizedFile.type !== 'chunk') { + continue; + } + + original.outputFiles.push( + createOutputFile(optimizedFile.fileName, optimizedFile.code, BuildOutputFileType.Browser), + ); + if (optimizedFile.map && optimizedFile.sourcemapFileName) { + original.outputFiles.push( + createOutputFile( + optimizedFile.sourcemapFileName, + optimizedFile.map.toString(), + BuildOutputFileType.Browser, + ), + ); + } + } + + // Update initial files to reflect optimized chunks + for (const usedFile of usedChunks) { + // Leave the main file since its information did not change + if (usedFile !== mainFile) { + original.initialFiles.delete(usedFile); + } + } + + // Analyze for transitive initial files + const mainRecord = original.initialFiles.get(mainFile); + assert(mainRecord, `Main file record should always be present in initialFiles [${mainFile}].`); + findTransitiveInitialFiles(newMetafile, original.initialFiles, [[mainFile, mainRecord]]); + + return original; +} diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts new file mode 100644 index 000000000000..7fc43de92b00 --- /dev/null +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; +import { createAngularCompilation } from '../../tools/angular/compilation'; +import { + AngularCompilationContext, + PrimaryCompilationContext, +} from '../../tools/esbuild/angular/compilation-state'; +import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; +import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; +import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; +import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; +import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin'; +import { extractLicenses } from '../../tools/esbuild/license-extractor'; +import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; +import { + calculateEstimatedTransferSizes, + isZonelessApp, + logBuildStats, +} from '../../tools/esbuild/utils'; +import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; +import { optimizeChunksThreshold } from '../../utils/environment-options'; +import { profileAsync } from '../../utils/profiling'; +import { resolveAssets } from '../../utils/resolve-assets'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest, +} from '../../utils/server-rendering/manifest'; +import { getSupportedBrowsers } from '../../utils/supported-browsers'; +import { executePostBundleSteps } from './execute-post-bundle'; +import { inlineI18n, loadActiveTranslations } from './i18n'; +import { NormalizedApplicationBuildOptions } from './options'; +import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; + +/** The esbuild error text prefix used to detect top-level await errors. */ +const TOP_LEVEL_AWAIT_ERROR_TEXT = + 'Top-level await is not available in the configured target environment'; + +/** Returns a shallow copy of a record with its keys sorted. */ +function sortObjectKeys>(value: T): T { + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = value[key]; + } + + return sorted as T; +} + +/** + * Serializes a metafile with its dynamic path keys sorted so the stats file is identical across + * builds. Only `inputs`, `outputs`, and each output's `inputs` have dynamic keys; the remaining + * fixed-schema objects are left untouched so V8 keeps its native `JSON.stringify` fast path. + */ +function stableStringify(metafile: Metafile): string { + const outputs: Metafile['outputs'] = {}; + for (const key of Object.keys(metafile.outputs).sort()) { + const output = metafile.outputs[key]; + outputs[key] = { + ...output, + inputs: sortObjectKeys(output.inputs), + }; + } + + return JSON.stringify( + { + inputs: sortObjectKeys(metafile.inputs), + outputs, + }, + null, + 2, + ); +} + +// eslint-disable-next-line max-lines-per-function +export async function executeBuild( + options: NormalizedApplicationBuildOptions, + context: BuilderContext, + rebuildState?: RebuildState, +): Promise { + const { + projectRoot, + workspaceRoot, + i18nOptions, + optimizationOptions, + assets, + cacheOptions, + serverEntryPoint, + baseHref, + ssrOptions, + verbose, + colors, + jsonLogs, + security, + } = options; + + // TODO: Consider integrating into watch mode. Would require full rebuild on target changes. + const browsers = getSupportedBrowsers(projectRoot, context.logger); + + // Load active translations if inlining + // TODO: Integrate into watch mode and only load changed translations + if (i18nOptions.shouldInline) { + await loadActiveTranslations(context, i18nOptions); + } + + // Reuse rebuild state or create new bundle contexts for code and global stylesheets + let bundlerContexts; + let componentStyleBundler; + let codeBundleCache; + let bundlingIndividualResults: BundleContextResult[]; + let templateUpdates: Map | undefined; + let angularCompilationContext: AngularCompilationContext | undefined; + let executionResult: ExecutionResult | undefined; + try { + if (rebuildState) { + bundlerContexts = rebuildState.rebuildContexts; + componentStyleBundler = rebuildState.componentStyleBundler; + codeBundleCache = rebuildState.codeBundleCache; + templateUpdates = rebuildState.templateUpdates; + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); + // Reset template updates for new rebuild + templateUpdates?.clear(); + + const allFileChanges = rebuildState.fileChanges.all; + + // Bundle all contexts that do not require TypeScript changed file checks. + // These will automatically use cached results based on the changed files. + bundlingIndividualResults = await BundlerContext.bundleAll( + bundlerContexts.otherContexts, + allFileChanges, + ); + + // Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of + // all TypeScript related contexts. + const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges); + const typescriptResults: BundleContextResult[] = []; + for (const typescriptContext of bundlerContexts.typescriptContexts) { + typescriptContext.invalidate(allFileChanges); + const result = await typescriptContext.bundle(forceTypeScriptRebuild); + typescriptResults.push(result); + } + + bundlingIndividualResults.push(...typescriptResults); + } else { + const target = transformSupportedBrowsersToTargets(browsers); + codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); + componentStyleBundler = createComponentStyleBundler(options, target); + if (options.templateUpdates) { + templateUpdates = new Map(); + } + const angularCompilation = await createAngularCompilation( + !!options.jit, + !options.serverEntryPoint, + ); + angularCompilationContext = new PrimaryCompilationContext(angularCompilation); + bundlerContexts = setupBundlerContexts( + options, + target, + codeBundleCache, + componentStyleBundler, + angularCompilationContext, + templateUpdates, + ); + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); + + // Bundle everything on initial build + bundlingIndividualResults = await BundlerContext.bundleAll([ + ...bundlerContexts.typescriptContexts, + ...bundlerContexts.otherContexts, + ]); + } + + // Update any external component styles if enabled and rebuilding. + // TODO: Only attempt rebundling of invalidated styles once incremental build results are supported. + if (rebuildState && options.externalRuntimeStyles) { + componentStyleBundler.invalidate(rebuildState.fileChanges.all); + + const componentResults = await componentStyleBundler.bundleAllFiles(true, true); + bundlingIndividualResults.push(...componentResults); + } + + let bundlingResult = BundlerContext.mergeResults(bundlingIndividualResults); + + executionResult.addWarnings(bundlingResult.warnings); + + // Add used external component style referenced files to be watched + if (options.externalRuntimeStyles) { + executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); + } + + // In non-watch mode, the bundler rebuild contexts and Angular compilation + // are no longer needed once bundling completes. Dispose them early to free + // worker processes, TypeScript ASTs, and caches before post-bundling steps + // execute or before returning bundling errors. + // This is fire-and-forget so worker teardown runs concurrently in the background + // without blocking the critical path of post-bundle optimization steps. + // Any in-flight disposal is awaited in the builder action's finally block. + if (!options.watch) { + void angularCompilationContext?.dispose(); + void executionResult.dispose(); + } + + // Return if the bundling has errors + if (bundlingResult.errors) { + // If Zone.js is used, augment top-level await errors with a more helpful message. + // esbuild's default error mentions "target environment" with browser versions, but + // the actual reason is that async/await is downleveled for Zone.js compatibility. + if (!isZonelessApp(options.polyfills)) { + for (const error of bundlingResult.errors) { + if (error.text?.startsWith(TOP_LEVEL_AWAIT_ERROR_TEXT)) { + error.notes ??= []; + error.notes.push({ + text: + 'Top-level await is not supported in applications that use Zone.js. ' + + 'Consider removing Zone.js or moving this code into an async function. \n' + + 'For more information about zoneless Angular applications, visit: https://angular.dev/guide/zoneless', + location: null, + }); + } + } + } + + executionResult.addErrors(bundlingResult.errors); + + return executionResult; + } + + // Optimize chunks if enabled and threshold is met. + // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. + if (options.optimizationOptions.scripts) { + // Count lazy chunks (files not needed for initial load). + // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. + const { metafiles, initialFiles } = bundlingResult; + const lazyChunksCount = Object.keys(metafiles.browser.outputs || {}).filter( + (path) => path.endsWith('.js') && !initialFiles.has(path), + ).length; + + // Only run if the number of lazy chunks meets the configured threshold. + // This avoids overhead for small projects with few chunks. + if (lazyChunksCount >= optimizeChunksThreshold) { + const { optimizeChunks } = await import('./chunk-optimizer'); + const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => + optimizeChunks( + bundlingResult, + options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, + ), + ); + + if (optimizationResult.errors) { + executionResult.addErrors(optimizationResult.errors); + + return executionResult; + } + + bundlingResult = optimizationResult; + } + } + + // Analyze external imports if external options are enabled + if (options.externalPackages || bundlingResult.externalConfiguration) { + const { + externalConfiguration = [], + externalImports: { browser = [], server = [] }, + } = bundlingResult; + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); + const exclusions = new Set(externalConfiguration); + const explicitExternal = new Set(); + + const isExplicitExternal = (dep: string): boolean => { + // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable + // even when the containing package is externalized by the user. + if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { + return false; + } + + if (exclusions.has(dep)) { + return true; + } + + for (const prefix of exclusionsPrefixes) { + if (dep.startsWith(prefix)) { + return true; + } + } + + return false; + }; + + const implicitBrowser: string[] = []; + for (const dep of browser) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitBrowser.push(dep); + } + } + + const implicitServer: string[] = []; + for (const dep of server) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitServer.push(dep); + } + } + + executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); + } + + const { + metafiles: { browser: browserMetafile, server: serverMetafile }, + initialFiles, + outputFiles, + } = bundlingResult; + const metafiles = [browserMetafile, serverMetafile]; + + executionResult.outputFiles.push(...outputFiles); + + // Analyze files for bundle budget failures if present + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (options.budgets) { + const compatStats = generateBudgetStats(browserMetafile, outputFiles, initialFiles); + budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; + for (const { message, severity } of budgetFailures) { + if (severity === 'error') { + executionResult.addError(message); + } else { + executionResult.addWarning(message); + } + } + } + + // Calculate estimated transfer size if scripts are optimized + let estimatedTransferSizes; + if (optimizationOptions.scripts || optimizationOptions.styles.minify) { + estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); + } + + // Check metafile for CommonJS module usage if optimizing scripts + if (optimizationOptions.scripts) { + const messages = checkCommonJSModules(browserMetafile, options.allowedCommonJsDependencies); + executionResult.addWarnings(messages); + } + + // Copy assets + if (assets) { + executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); + } + + // Extract and write licenses for used packages + if (options.extractLicenses) { + executionResult.addOutputFile( + '3rdpartylicenses.txt', + await extractLicenses(metafiles, workspaceRoot), + BuildOutputFileType.Root, + ); + } + + // Watch input index HTML file if configured + if (options.indexHtmlOptions) { + executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); + executionResult.htmlIndexPath = options.indexHtmlOptions.output; + executionResult.htmlBaseHref = options.baseHref; + } + + // Create server app engine manifest + if (serverEntryPoint) { + executionResult.addOutputFile( + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), + BuildOutputFileType.ServerRoot, + ); + } + + // Perform i18n translation inlining if enabled + if (i18nOptions.shouldInline) { + const result = await inlineI18n(browserMetafile, options, executionResult, initialFiles); + executionResult.addErrors(result.errors); + executionResult.addWarnings(result.warnings); + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + } else { + const result = await executePostBundleSteps( + browserMetafile, + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); + + // 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); + } + + if (serverEntryPoint) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), + BuildOutputFileType.Root, + ); + } + + // Write metafiles if stats option is enabled + if (options.stats) { + executionResult.addOutputFile( + 'browser-stats.json', + stableStringify(browserMetafile), + BuildOutputFileType.Root, + ); + + if (serverEntryPoint) { + executionResult.addOutputFile( + 'server-stats.json', + stableStringify(serverMetafile), + BuildOutputFileType.Root, + ); + } + } + + if (!jsonLogs && !options.quiet) { + const changedFiles = + rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); + executionResult.addLog( + logBuildStats( + metafiles, + outputFiles, + initialFiles, + budgetFailures, + colors, + changedFiles, + estimatedTransferSizes, + !!ssrOptions, + verbose, + ), + ); + } + + return executionResult; + } catch (error) { + await Promise.allSettled([angularCompilationContext?.dispose(), executionResult?.dispose()]); + + throw error; + } +} diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts new file mode 100644 index 000000000000..99e23bcb30c9 --- /dev/null +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { + type BuildOutputAsset, + type PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { + type BuildOutputFile, + BuildOutputFileType, + type InitialFileRecord, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { maxWorkers } from '../../utils/environment-options'; +import { + SERVER_APP_MANIFEST_FILENAME, + generateAngularServerAppManifest, +} from '../../utils/server-rendering/manifest'; +import { WritableSerializableRouteTreeNode } from '../../utils/server-rendering/models'; +import { prerenderPages } from '../../utils/server-rendering/prerender'; +import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; +import { generateIndexHtml } from './index-html-generator'; +import { injectDebugIds } from './inject-debug-ids'; +import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; +import { OutputMode } from './schema'; + +/** + * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. + * @param browserMetafile An esbuild metafile object. + * @param options The normalized application builder options used to create the build. + * @param outputFiles The output files of an executed build. + * @param assetFiles The assets of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + * @param locale A language locale to insert in the index.html. + */ +// eslint-disable-next-line max-lines-per-function +export async function executePostBundleSteps( + browserMetafile: Metafile, + options: NormalizedApplicationBuildOptions, + outputFiles: BuildOutputFile[], + assetFiles: BuildOutputAsset[], + initialFiles: Map, + locale: string | undefined, +): Promise<{ + errors: string[]; + warnings: string[]; + additionalOutputFiles: BuildOutputFile[]; + additionalAssets: BuildOutputAsset[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { + const additionalAssets: BuildOutputAsset[] = []; + const additionalOutputFiles: BuildOutputFile[] = []; + const allErrors: string[] = []; + const allWarnings: string[] = []; + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + + const { + baseHref = '/', + serviceWorker, + ssrOptions, + indexHtmlOptions, + optimizationOptions, + sourcemapOptions, + outputMode, + serverEntryPoint, + prerenderOptions, + appShellOptions, + publicPath, + workspaceRoot, + partialSSRBuild, + } = options; + + // Embed ECMA-426 Debug IDs into JS/source-map pairs before any consumer reads the bytes (in + // particular `generateIndexHtml` below, which computes SRI hashes from the on-disk content). + // Doing this here also covers the i18n path, where this function is invoked once per locale + // with locale-specific output files. Files without a source map sibling are skipped. + if (sourcemapOptions.scripts) { + injectDebugIds(outputFiles); + } + + // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). + // NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering. + // Additionally, when using prerendering or AppShell, the index HTML file may be regenerated. + // To prevent generating duplicate files with the same filename, a `Map` is used to store and manage the files. + const additionalHtmlOutputFiles = new Map(); + + // Generate index HTML file + // If localization is enabled, index generation is handled in the inlining process. + if (indexHtmlOptions) { + const { csrContent, ssrContent, errors, warnings } = await generateIndexHtml( + initialFiles, + outputFiles, + options, + locale, + ); + + allErrors.push(...errors); + allWarnings.push(...warnings); + + additionalHtmlOutputFiles.set( + indexHtmlOptions.output, + createOutputFile(indexHtmlOptions.output, csrContent, BuildOutputFileType.Browser), + ); + + if (ssrContent) { + additionalHtmlOutputFiles.set( + INDEX_HTML_SERVER, + createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.ServerApplication), + ); + } + } + + // Create server manifest + const initialFilesPaths = new Set(initialFiles.keys()); + if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) { + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + undefined, + locale, + baseHref, + initialFilesPaths, + browserMetafile, + publicPath, + ); + + additionalOutputFiles.push( + ...serverAssetsChunks, + createOutputFile( + SERVER_APP_MANIFEST_FILENAME, + manifestContent, + BuildOutputFileType.ServerApplication, + ), + ); + } + + // Pre-render (SSG) and App-shell + // If localization is enabled, prerendering is handled in the inlining process. + if ( + !partialSSRBuild && + (prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) && + !allErrors.length + ) { + assert( + indexHtmlOptions, + 'The "index" option is required when using the "ssg" or "appShell" options.', + ); + + const { + output, + warnings, + errors, + serializableRouteTreeNode, + prerenderedRoutes: generatedPrerenderedRoutes, + } = await prerenderPages( + workspaceRoot, + baseHref, + appShellOptions, + prerenderOptions, + [...outputFiles, ...additionalOutputFiles], + assetFiles, + outputMode, + sourcemapOptions.scripts, + maxWorkers, + ); + + allErrors.push(...errors); + allWarnings.push(...warnings); + Object.assign(prerenderedRoutes, generatedPrerenderedRoutes); + + const indexHasBeenPrerendered = output[indexHtmlOptions.output]; + 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) { + filePath = 'index.html'; + } else { + filePath = indexHtmlOptions.output; + } + } + + additionalHtmlOutputFiles.set( + filePath, + createOutputFile(filePath, content, BuildOutputFileType.Browser), + ); + } + + const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + serializableRouteTreeNodeForManifest.push(metadata); + } + + if (outputMode === OutputMode.Server) { + // Regenerate the manifest to append route tree. This is only needed if SSR is enabled. + const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME); + assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`); + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + serializableRouteTreeNodeForManifest, + locale, + baseHref, + initialFilesPaths, + browserMetafile, + publicPath, + ); + + for (const chunk of serverAssetsChunks) { + const idx = additionalOutputFiles.findIndex(({ path }) => path === chunk.path); + if (idx === -1) { + additionalOutputFiles.push(chunk); + } else { + additionalOutputFiles[idx] = chunk; + } + } + + manifest.contents = new TextEncoder().encode(manifestContent); + } + } + + additionalOutputFiles.push(...additionalHtmlOutputFiles.values()); + + // Augment the application with service worker support + // If localization is enabled, service worker is handled in the inlining process. + if (serviceWorker) { + try { + const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild( + workspaceRoot, + serviceWorker, + baseHref, + options.indexHtmlOptions?.output, + // Ensure additional files recently added are used + [...outputFiles, ...additionalOutputFiles], + assetFiles, + ); + + additionalOutputFiles.push( + createOutputFile('ngsw.json', serviceWorkerResult.manifest, BuildOutputFileType.Browser), + ); + additionalAssets.push(...serviceWorkerResult.assetFiles); + } catch (error) { + allErrors.push(error instanceof Error ? error.message : `${error}`); + } + } + + return { + errors: allErrors, + warnings: allWarnings, + additionalAssets, + prerenderedRoutes, + additionalOutputFiles, + }; +} diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts new file mode 100644 index 000000000000..839384421f1f --- /dev/null +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + ExecutionResult, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-files'; +import { I18nInliner } from '../../tools/i18n'; +import { maxInlinerWorkers } from '../../utils/environment-options'; +import { loadTranslations } from '../../utils/i18n-options'; +import { createTranslationLoader } from '../../utils/load-translations'; +import { createProjectResolver } from '../../utils/resolve-project'; +import type { WorkerPool } from '../../utils/worker-pool'; +import { executePostBundleSteps } from './execute-post-bundle'; +import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; + +/** + * Inlines all active locales as specified by the application build options into all + * application JavaScript files created during the build. + * @param metafile An esbuild metafile object. + * @param options The normalized application builder options used to create the build. + * @param executionResult The result of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + * @param workerPool An optional worker pool to use for running transformation tasks. + */ +export async function inlineI18n( + metafile: Metafile, + options: NormalizedApplicationBuildOptions, + executionResult: ExecutionResult, + initialFiles: Map, + workerPool?: WorkerPool, +): Promise<{ + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { + const { i18nOptions, baseHref, cacheOptions } = options; + + // Create the multi-threaded inliner with common options. + const inliner = new I18nInliner( + { + missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', + maxConcurrency: workerPool + ? Math.min(workerPool.maxThreads, maxInlinerWorkers) + : maxInlinerWorkers, + persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + localizeVersion: i18nOptions.localizeVersion, + }, + workerPool, + ); + + const inlineResult: { + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + } = { + errors: [], + warnings: [], + prerenderedRoutes: {}, + }; + + // For each active locale, use the inliner to process the output files of the build. + const updatedOutputFiles = []; + const updatedAssetFiles = []; + // Root and SSR entry files are not modified. + const unModifiedOutputFiles = executionResult.outputFiles.filter( + ({ type }) => type === BuildOutputFileType.Root || type === BuildOutputFileType.ServerRoot, + ); + + try { + const localesToInline = Array.from(i18nOptions.inlineLocales, (locale) => { + const localeDescription = i18nOptions.locales[locale]; + let translationIntegrity: string | undefined = ''; + for (const file of localeDescription.files) { + if (!file.integrity) { + translationIntegrity = undefined; + break; + } + translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity; + } + + return { + locale, + translation: localeDescription.translation, + translationIntegrity, + }; + }); + + const inlinedLocales = await inliner.inlineAll(executionResult.outputFiles, localesToInline); + + for (const locale of i18nOptions.inlineLocales) { + const localeInlineResult = inlinedLocales.get(locale); + assert(localeInlineResult !== undefined, 'Inlined result must exist for locale: ' + locale); + const localeOutputFiles = localeInlineResult.outputFiles; + inlineResult.errors.push(...localeInlineResult.errors); + inlineResult.warnings.push(...localeInlineResult.warnings); + + const { + errors, + warnings, + additionalAssets, + additionalOutputFiles, + prerenderedRoutes: generatedRoutes, + } = await executePostBundleSteps( + metafile, + { + ...options, + baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref, + }, + [...unModifiedOutputFiles, ...localeOutputFiles], + executionResult.assetFiles, + initialFiles, + locale, + ); + + localeOutputFiles.push(...additionalOutputFiles); + inlineResult.errors.push(...errors); + inlineResult.warnings.push(...warnings); + + // Update directory with locale base or subPath + const subPath = i18nOptions.locales[locale].subPath; + if (i18nOptions.flatOutput !== true) { + localeOutputFiles.forEach((file) => { + file.path = join(subPath, file.path); + }); + + for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) { + updatedAssetFiles.push({ + source: assetFile.source, + destination: join(subPath, assetFile.destination), + }); + } + } else { + executionResult.assetFiles.push(...additionalAssets); + } + + inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes }; + updatedOutputFiles.push(...localeOutputFiles); + } + + // Update the result with all localized files. + executionResult.outputFiles = [ + // Root and SSR entry files are not modified. + ...unModifiedOutputFiles, + // Updated files for each locale. + ...updatedOutputFiles, + ]; + + // Assets are only changed if not using the flat output option + 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 (localesToInline.length > 1) { + inlineResult.warnings.push( + `Component HMR updates can only be inlined with a single locale. The first locale will be used.`, + ); + } + const targetLocale = localesToInline[0]; + + for (const [id, content] of executionResult.templateUpdates) { + const templateUpdateResult = await inliner.inlineTemplateUpdate( + targetLocale.locale, + targetLocale.translation, + content, + id, + targetLocale.translationIntegrity, + ); + executionResult.templateUpdates.set(id, templateUpdateResult.code); + inlineResult.errors.push(...templateUpdateResult.errors); + inlineResult.warnings.push(...templateUpdateResult.warnings); + } + } + + return inlineResult; + } finally { + await inliner.close(); + } +} + +/** + * Loads all active translations using the translation loaders from the `@angular/localize` package. + * @param context The architect builder context for the current build. + * @param i18n The normalized i18n options to use. + */ +export async function loadActiveTranslations( + context: BuilderContext, + i18n: NormalizedApplicationBuildOptions['i18nOptions'], +) { + if (!i18n.localizeVersion) { + try { + const projectResolve = createProjectResolver(context.workspaceRoot); + const manifestPath = projectResolve('@angular/localize/package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version?: string }; + i18n.localizeVersion = manifest.version; + } catch {} + } + + // Load locale data and translations (if present) + let loader; + for (const [locale, desc] of Object.entries(i18n.locales)) { + if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) { + continue; + } + + if (!desc.files.length) { + continue; + } + + loader ??= await createTranslationLoader(); + + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + undefined, + i18n.duplicateTranslationBehavior, + ); + } +} diff --git a/packages/angular/build/src/builders/application/index-html-generator.ts b/packages/angular/build/src/builders/application/index-html-generator.ts new file mode 100644 index 000000000000..a1c9cbcc9b30 --- /dev/null +++ b/packages/angular/build/src/builders/application/index-html-generator.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { + BuildOutputFile, + BuildOutputFileType, + InitialFileRecord, +} from '../../tools/esbuild/bundler-files'; +import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; +import { NormalizedApplicationBuildOptions } from './options'; + +/** + * The maximum number of module preload link elements that should be added for + * initial scripts. + */ +const MODULE_PRELOAD_MAX = 10; + +export async function generateIndexHtml( + initialFiles: Map, + outputFiles: BuildOutputFile[], + buildOptions: NormalizedApplicationBuildOptions, + lang?: string, +): Promise<{ + csrContent: string; + ssrContent?: string; + warnings: string[]; + errors: string[]; +}> { + // Analyze metafile for initial link-based hints. + // Skip if the internal externalPackages option is enabled since this option requires + // dev server cooperation to properly resolve and fetch imports. + const hints = []; + const { + indexHtmlOptions, + externalPackages, + optimizationOptions, + crossOrigin, + subresourceIntegrity, + baseHref, + } = buildOptions; + + assert(indexHtmlOptions, 'indexHtmlOptions cannot be undefined.'); + + if (!externalPackages && indexHtmlOptions.preloadInitial) { + const modulePreloads = []; + for (const [key, value] of initialFiles) { + if (value.entrypoint || value.serverFile) { + // Entry points are already referenced in the HTML + continue; + } + + if (value.type === 'script') { + modulePreloads.push({ url: key, mode: 'modulepreload' as const, depth: value.depth }); + } else if (value.type === 'style') { + // Provide an "as" value of "style" to ensure external URLs which may not have a + // file extension are treated as stylesheets. + hints.push({ url: key, mode: 'preload' as const, as: 'style' }); + } + } + + // Limit the number of module preloads with smallest depth given priority + modulePreloads.sort((a, b) => a.depth - b.depth); + hints.push(...modulePreloads.slice(0, MODULE_PRELOAD_MAX)); + } + + /** Virtual output path to support reading in-memory files. */ + const browserOutputFiles = outputFiles.filter(({ type }) => type === BuildOutputFileType.Browser); + const virtualOutputPath = '/'; + const readAsset = async function (filePath: string): Promise { + // Remove leading directory separator + const relativefilePath = path.relative(virtualOutputPath, filePath); + const file = browserOutputFiles.find((file) => file.path === relativefilePath); + if (file) { + return file.text; + } + + throw new Error(`Output file does not exist: ${relativefilePath}`); + }; + + // When SRI is enabled, build an integrity map for every browser JavaScript + // chunk so an `', + ); + indexFileContent.toContain(' { + describe('Behavior: "Esbuild for-await"', () => { + it('should properly downlevel for-await loops with optimization enabled', async () => { + // Setup a for-await loop that triggers the esbuild minification bug when async/await is downleveled. + await harness.writeFile( + 'src/main.ts', + ` + async function test() { + const someAsyncIterable = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ done: true, value: undefined }); + } + }; + } + }; + for await(const item of someAsyncIterable) { + console.log(item); + } + } + test(); + `, + ); + + // Ensure target is ES2022 so that optional catch binding is supported natively. + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.compilerOptions.target = 'ES2022'; + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + polyfills: ['zone.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + // We expect the output to contain a catch block that captures the error in a variable, + // even if that variable is mangled. + // The pattern for the downleveled for-await catch block is roughly: + // } catch (temp) { error = [temp]; } + // + // With the bug, esbuild (when minifying) would optimize away the catch binding if it thought it was unused, + // resulting in: } catch { ... } which breaks the logic requiring the error object. + // + // The regex matches: + // catch \s* -> catch keyword and whitespace + // \( [a-zA-Z_$][\w$]* \) -> (variable) + // \s* { \s* -> { and whitespace + // [a-zA-Z_$][\w$]* -> error array variable + // \s* = \s* -> assignment + // \[ [a-zA-Z_$][\w$]* \] -> [variable] + harness + .expectFile('dist/browser/main.js') + .content.toMatch( + /catch\s*\([a-zA-Z_$][\w$]*\)\s*\{\s*[a-zA-Z_$][\w$]*\s*=\s*\[[a-zA-Z_$][\w$]*\]/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts new file mode 100644 index 000000000000..0f87dd78a289 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "index.csr.html"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' when ssr is enabled.`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' when 'output' is 'index.html' and ssr is enabled.`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + output: 'index.html', + }, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts new file mode 100644 index 000000000000..7f6b9711790b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Preload hints"', () => { + it('should add preload hints for transitive global style imports', async () => { + await harness.writeFile( + 'src/styles.css', + ` + @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@300;400;500;700&display=swap'); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/index.html') + .content.toContain( + '', + ); + }); + + it('should not add preload hints for ssr files', async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + + harness + .expectFile('dist/browser/index.csr.html') + .content.not.toMatch(//); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts new file mode 100644 index 000000000000..91c4cafc571a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "loader import attribute"', () => { + beforeEach(async () => { + await harness.modifyFile('tsconfig.json', (content) => { + return content.replace('"module": "ES2022"', '"module": "esnext"'); + }); + }); + + it('should inline text content for loader attribute set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "text" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline binary content for loader attribute set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "binary" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the binary encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('__toBinary("QUJD")'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline base64 content for file extension set to "base64"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "base64" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the base64 encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('QUJD'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline dataurl content for file extension set to "dataurl"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.svg', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.svg" with { loader: "dataurl" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the dataurl encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('data:image/svg+xml,ABC'); + }); + + it('should emit an output file for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + harness.expectFile('dist/browser/media/a.unknown').toExist(); + }); + + it('should emit an output file with hashing when enabled for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + expect(harness.hasFileMatch('dist/browser/media', /a-[0-9A-Z]{8}\.unknown$/)).toBeTrue(); + }); + + it('should allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.txt" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.txt'); + harness.expectFile('dist/browser/media/a.txt').toExist(); + }); + + it('should allow overriding default `.js` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.js', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.js" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.js'); + harness.expectFile('dist/browser/media/a.js').toExist(); + }); + + it('should fail with an error if an invalid loader attribute value is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "invalid" };\n console.log(contents);', + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unsupported loader import attribute'), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts new file mode 100644 index 000000000000..df58f1c833c0 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputMode, Platform } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * `@angular/platform-server/init` (bundled into the server polyfills) and + * `@angular/platform-server` (bundled into the main server bundle) both import this file. + */ +const DOMINO_BUNDLE = 'third_party/domino/bundled-domino.mjs'; + +/** The property on globalThis where the Domino instance is stored. */ +const DOMINO_GLOBAL_PROPERTY = /(?:ɵ|\\u0275)domino/; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Domino is bundled once for the server"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + + // The default template uses i18n which requires `@angular/localize` to prerender. + await harness.writeFile('src/app/app.component.html', `

Hello!

`); + }); + + it('shares the server polyfills copy of Domino with the main server bundle', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness + .expectFile('dist/server/polyfills.server.mjs') + .content.toMatch(DOMINO_GLOBAL_PROPERTY); + + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(DOMINO_GLOBAL_PROPERTY); + }); + + it('shares the server polyfills copy of Domino when "outputMode" is "server"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + outputMode: OutputMode.Server, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(DOMINO_GLOBAL_PROPERTY); + harness.expectFile('dist/server/server.mjs').content.not.toContain(DOMINO_BUNDLE); + }); + + it('shares the server polyfills copy of Domino when "ssr.platform" is "neutral"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts', platform: Platform.Neutral }, + outputMode: OutputMode.Server, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness + .expectFile('dist/server/polyfills.server.mjs') + .content.toMatch(DOMINO_GLOBAL_PROPERTY); + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(DOMINO_GLOBAL_PROPERTY); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts new file mode 100644 index 000000000000..7bfcca94d242 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when input asset changes"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + await harness.writeFile('public/asset.txt', 'foo'); + }); + + it('emits updated asset', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*', + input: 'public', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset.txt').content.toContain('foo'); + + await harness.writeFile('public/asset.txt', 'bar'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset.txt').content.toContain('bar'); + }, + ]); + }); + + it('remove deleted asset from output', async () => { + await Promise.all([ + harness.writeFile('public/asset-two.txt', 'bar'), + harness.writeFile('public/asset-one.txt', 'foo'), + ]); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*', + input: 'public', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset-one.txt').toExist(); + harness.expectFile('dist/browser/asset-two.txt').toExist(); + + await harness.removeFile('public/asset-two.txt'); + }, + + ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset-one.txt').toExist(); + harness.expectFile('dist/browser/asset-two.txt').toNotExist(); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts new file mode 100644 index 000000000000..08b683439684 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when component stylesheets change"', () => { + for (const aot of [true, false]) { + it(`updates component when imported sass changes with ${aot ? 'AOT' : 'JIT'}`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', 'app.component.scss'), + ); + await harness.writeFile('src/app/app.component.scss', "@import './a';"); + await harness.writeFile('src/app/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: blue'); + + await harness.writeFile('src/app/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + + await harness.writeFile('src/app/a.scss', '$primary: green;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: blue'); + harness.expectFile('dist/browser/main.js').content.toContain('color: green'); + }, + ]); + }); + } + + it('rebuilds component after error on rebuild from transitive import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', 'app.component.scss'), + ); + await harness.writeFile('src/app/app.component.scss', "@import './a';"); + await harness.writeFile('src/app/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + + // Introduce a syntax error + await harness.writeFile( + 'src/app/a.scss', + 'invalid-invalid-invalid\\nh1 { color: $primary; }', + ); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + // Fix the syntax error + await harness.writeFile('src/app/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + }, + ]); + }); + + it('rebuilds component after error on rebuild from deep transitive import with partials', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', 'app.component.scss'), + ); + await harness.writeFile('src/app/app.component.scss', "@import './intermediary';"); + await harness.writeFile('src/app/_intermediary.scss', "@import './partial';"); + await harness.writeFile('src/app/_partial.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + + // Introduce a syntax error deeply + await harness.writeFile( + 'src/app/_partial.scss', + 'invalid-invalid-invalid\\nh1 { color: $primary; }', + ); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + // Fix the syntax error deeply + await harness.writeFile( + 'src/app/_partial.scss', + '$primary: blue;\\nh1 { color: $primary; }', + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts new file mode 100644 index 000000000000..5e7bdeb3fd9c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -0,0 +1,470 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuild Error Detection"', () => { + it('detects template errors with no AOT codegen or TS emit differences', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const goodDirectiveContents = ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo!: number; + } + `; + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + // Create a directive and add to application + await harness.writeFile('src/app/dir.ts', goodDirectiveContents); + await harness.writeFile( + 'src/app/app.module.ts', + ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { Dir } from './dir'; + @NgModule({ + declarations: [ + AppComponent, + Dir, + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + ); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '', + }) + export class AppComponent { } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update directive to use a different input type for 'foo' (number -> string) + // Should cause a template error + await harness.writeFile( + 'src/app/dir.ts', + ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Revert the directive change that caused the error + // Should remove the error + await harness.writeFile('src/app/dir.ts', goodDirectiveContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache was updated + // Should continue showing no error + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects template errors across components on watch rebuild when isolatedModules is enabled', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions = { + ...tsconfig.compilerOptions, + isolatedModules: true, + }; + + return JSON.stringify(tsconfig); + }); + + const goodChildComponentContents = ` + import { Component, Input } from '@angular/core'; + @Component({ + selector: 'child', + standalone: true, + template: '

{{ value }}

', + }) + export class ChildComponent { + @Input() value!: number; + } + `; + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + await harness.writeFiles({ + 'src/app/child.component.ts': goodChildComponentContents, + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + @NgModule({ + imports: [ + BrowserModule, + AppComponent, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { ChildComponent } from './child.component'; + @Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: '', + }) + export class AppComponent {} + `, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update child component to change input type from number to string + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, Input } from '@angular/core'; + @Component({ + selector: 'child', + standalone: true, + template: '

{{ value }}

', + }) + export class ChildComponent { + @Input() value!: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Make an unrelated change to verify error persists + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Revert back to number + await harness.writeFile('src/app/child.component.ts', goodChildComponentContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache cleared + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects template errors when an input becomes required or a selector is renamed on watch rebuild', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions = { + ...tsconfig.compilerOptions, + isolatedModules: true, + }; + + return JSON.stringify(tsconfig); + }); + + const goodChildComponentContents = ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input(''); + } + `; + + const requiredInputErrorText = `Required input 'label' from component ChildComponent must be specified.`; + const unknownElementErrorText = `'app-child' is not a known element`; + + await harness.writeFiles({ + 'src/app/child.component.ts': goodChildComponentContents, + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + @NgModule({ + imports: [ + BrowserModule, + AppComponent, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { ChildComponent } from './child.component'; + @Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: '', + }) + export class AppComponent {} + `, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Make input required using signal input.required() (which has the same TS .d.ts type InputSignal) + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input.required(); + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, requiredInputErrorText); + + // Revert required input and rename selector instead + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child-renamed', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input(''); + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectNoLog(logs, requiredInputErrorText); + expectLog(logs, unknownElementErrorText); + + // Revert back to valid child component + await harness.writeFile('src/app/child.component.ts', goodChildComponentContents); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, requiredInputErrorText); + expectNoLog(logs, unknownElementErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects cumulative block syntax errors', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases( + [ + async () => { + // Add invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@if-one'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@if-two'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + expectLog(logs, '@if-two'); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@if-three'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + expectLog(logs, '@if-two'); + expectLog(logs, '@if-three'); + + // Revert the changes that caused the error + // Should remove the error + await harness.writeFile('src/app/app.component.html', '

GOOD

'); + }, + ({ logs }) => { + expectNoLog(logs, '@if-one'); + expectNoLog(logs, '@if-two'); + expectNoLog(logs, '@if-three'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('recovers from component stylesheet error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot: false, + }); + + await harness.executeWithCases( + [ + async () => { + await harness.writeFile('src/app/app.component.css', 'invalid-css-content'); + }, + async ({ logs }) => { + expectLog(logs, 'invalid-css-content'); + + await harness.writeFile('src/app/app.component.css', 'p { color: green }'); + }, + ({ logs }) => { + expectNoLog(logs, 'invalid-css-content'); + + harness + .expectFile('dist/browser/main.js') + .content.toContain('p {\\n color: green;\\n}'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('recovers from component template error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases( + [ + async () => { + // Missing ending `>` on the div will cause an error + await harness.appendToFile('src/app/app.component.html', '
Hello, world! { + expectLog(logs, 'Unexpected character "EOF"'); + + await harness.appendToFile('src/app/app.component.html', '>'); + }, + async ({ logs }) => { + expectNoLog(logs, 'Unexpected character "EOF"'); + + harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!'); + + // Make an additional valid change to ensure that rebuilds still trigger + await harness.appendToFile('src/app/app.component.html', '
Guten Tag
'); + }, + ({ logs }) => { + expectNoLog(logs, 'Unexpected character "EOF"'); + + harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!'); + harness.expectFile('dist/browser/main.js').content.toContain('Guten Tag'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts new file mode 100644 index 000000000000..d9ea8870f687 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuild updates in general cases"', () => { + it('detects changes after a file was deleted and recreated', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const fileAContent = ` + console.log('FILE-A'); + export {}; + `; + + // Create a file and add to application + await harness.writeFile('src/app/file-a.ts', fileAContent); + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + import './file-a'; + @Component({ + selector: 'app-root', + standalone: false, + template: 'App component', + }) + export class AppComponent { } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/main.js').content.toContain('FILE-A'); + + // Delete the imported file + await harness.removeFile('src/app/file-a.ts'); + }, + async ({ result }) => { + // Should fail from missing import + expect(result?.success).toBeFalse(); + + // Remove the failing import + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace(`import './file-a';`, ''), + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.not.toContain('FILE-A'); + + // Recreate the file and the import + await harness.writeFile('src/app/file-a.ts', fileAContent); + await harness.modifyFile( + 'src/app/app.component.ts', + (content) => `import './file-a';\n` + content, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-A'); + + // Change the imported file + await harness.modifyFile('src/app/file-a.ts', (content) => + content.replace('FILE-A', 'FILE-B'), + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-B'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts new file mode 100644 index 000000000000..2de5c5f229ca --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when global stylesheets change"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + it('rebuilds Sass stylesheet after error on rebuild from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.scss'], + }); + + await harness.writeFile('src/styles.scss', "@import './a';"); + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile( + 'src/a.scss', + 'invalid-invalid-invalid\\nh1 { color: $primary; }', + ); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds Sass stylesheet after error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.scss'], + }); + + await harness.writeFile('src/styles.scss', "@import './a';"); + await harness.writeFile('src/a.scss', 'invalid-invalid-invalid\\nh1 { color: $primary; }'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds dependent Sass stylesheets after error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: [ + { bundleName: 'styles', input: 'src/styles.scss' }, + { bundleName: 'other', input: 'src/other.scss' }, + ], + }); + + await harness.writeFile('src/styles.scss', "@import './a';"); + await harness.writeFile('src/other.scss', "@import './a'; h1 { color: green; }"); + await harness.writeFile('src/a.scss', 'invalid-invalid-invalid\\nh1 { color: $primary; }'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + harness.expectFile('dist/browser/other.css').content.toContain('color: green'); + harness.expectFile('dist/browser/other.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/other.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + + harness.expectFile('dist/browser/other.css').content.toContain('color: green'); + harness.expectFile('dist/browser/other.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/other.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds PostCSS stylesheet after error on rebuild from plugin dependency', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + await harness.writeFile( + 'test-plugin.js', + ` + const fs = require('fs'); + const path = require('path'); + module.exports = () => { + return { + postcssPlugin: 'test-plugin', + Once(root, { result }) { + const themePath = path.join(path.dirname(root.source.input.file), 'theme.json'); + result.messages.push({ + type: 'dependency', + file: themePath, + }); + const data = fs.readFileSync(themePath, 'utf-8'); + const json = JSON.parse(data); + root.append('body { color: ' + json.color + '; }'); + }, + }; + }; + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './test-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', '/* base */'); + await harness.writeFile('src/theme.json', '{"color": "aqua"}'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/theme.json', 'invalid-json'); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/theme.json', '{"color": "blue"}'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + await harness.writeFile( + 'noop-plugin.js', + ` + module.exports = () => ({ postcssPlugin: 'noop-plugin' }); + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './noop-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', "@import './a.css';"); + await harness.writeFile('src/a.css', "a { ' }"); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.css', 'body { color: aqua; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.css', 'body { color: blue; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts new file mode 100644 index 000000000000..99603bc98cee --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when input index HTML changes"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + it('rebuilds output index HTML', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + + await harness.modifyFile('src/index.html', (content) => + content.replace('charset="utf-8"', 'abc'), + ); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain('charset="utf-8"'); + + await harness.modifyFile('src/index.html', (content) => + content.replace('abc', 'charset="utf-8"'), + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts new file mode 100644 index 000000000000..2fdad10f8d8d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectLog, + expectNoLog, +} from '../setup'; + +/** + * A regular expression used to check if a built worker is correctly referenced in application code. + */ +const REFERENCED_WORKER_REGEXP = + /new Worker\(new URL\("worker-[A-Z0-9]{8}\.js", import\.meta\.url\)/; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when Web Worker files change"', () => { + it('Recovers from error when directly referenced worker file is changed', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const workerCodeFile = ` + console.log('WORKER FILE'); + `; + + const errorText = `Expected ";" but found "~"`; + + // Create a worker file + await harness.writeFile('src/app/worker.ts', workerCodeFile); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Ensure built worker is referenced in the application code + harness.expectFile('dist/browser/main.js').content.toMatch(REFERENCED_WORKER_REGEXP); + + // Update the worker file to be invalid syntax + await harness.writeFile('src/app/worker.ts', `asd;fj$3~kls;kd^(*fjlk;sdj---flk`); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, errorText); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ logs }) => { + expectLog(logs, errorText); + + // Revert the change that caused the error + // Should remove the error + await harness.writeFile('src/app/worker.ts', workerCodeFile); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, errorText); + + // Make an unrelated change to verify error cache was updated + // Should continue showing no error + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, errorText); + + // Ensure built worker is referenced in the application code + harness.expectFile('dist/browser/main.js').content.toMatch(REFERENCED_WORKER_REGEXP); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts new file mode 100644 index 000000000000..0adc77b5311a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts @@ -0,0 +1,458 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Stylesheet url() Resolution"', () => { + it('should show a note when using tilde prefix in a directly referenced stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Preprocessor stylesheets may not show the exact'), + }), + ); + }); + + it('should show a note when using tilde prefix in an imported CSS stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + @import "a.css"; + `, + ); + await harness.writeFile( + 'src/a.css', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + }); + + it('should show a note when using tilde prefix in an imported Sass stylesheet', async () => { + await harness.writeFile( + 'src/styles.scss', + ` + @import "a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Preprocessor stylesheets may not show the exact'), + }), + ); + }); + + it('should show a note when using caret prefix in a directly referenced stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + .a { + background-image: url("^image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the caret and'), + }), + ); + }); + + it('should show a note when using caret prefix in an imported Sass stylesheet', async () => { + await harness.writeFile( + 'src/styles.scss', + ` + @import "a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("^image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the caret and'), + }), + ); + }); + + it('should not rebase a URL with a namespaced Sass variable reference that points to an absolute asset', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named; + .a { + background-image: url(named.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "https://example.com/example.png";`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain('url(https://example.com/example.png)'); + }); + + it('should not rebase a URL with a Sass variable reference that points to an absolute asset', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url($my-var) + } + `, + 'src/theme/b.scss': `$my-var: "https://example.com/example.png";`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain('url(https://example.com/example.png)'); + }); + + it('should rebase a URL with a namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named; + .a { + background-image: url(named.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a hyphen-namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named-hyphen; + .a { + background-image: url(named-hyphen.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a underscore-namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named_underscore; + .a { + background-image: url(named_underscore.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url($my-var) + } + `, + 'src/theme/b.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with an leading interpolation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url(#{$my-var}logo.svg) + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with interpolation using concatenation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + $extra-var: "2"; + $postfix-var: "xyz"; + .a { + background-image: url("#{$my-var}logo#{$extra-var+ "-" + $postfix-var}.svg") + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo2-xyz.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain(`url("./media/logo2-xyz.svg")`); + harness.expectFile('dist/browser/media/logo2-xyz.svg').toExist(); + }); + + it('should rebase a URL with an non-leading interpolation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url(./#{$my-var}logo.svg) + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should not rebase Sass function definition with name ending in "url"', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + $asset: my-function-url('logo'); + background-image: url($asset) + } + `, + 'src/theme/b.scss': `@function my-function-url($name) { @return "./images/" + $name + ".svg"; }`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should not process a URL that has been marked as external', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + .a { + background-image: url("assets/logo.svg") + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + externalDependencies: ['assets/*'], + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url(assets/logo.svg)`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts new file mode 100644 index 000000000000..41ae225e2d3d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const styleBaseContent: Record = Object.freeze({ + 'css': ` + @import url(imported-styles.css); + div { hyphens: none; } + `, +}); + +const styleImportedContent: Record = Object.freeze({ + 'css': 'section { hyphens: none; }', +}); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Stylesheet autoprefixer"', () => { + for (const ext of ['css'] /* ['css', 'sass', 'scss', 'less'] */) { + it(`should add prefixes for listed browsers in global styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.writeFiles({ + [`src/styles.${ext}`]: styleBaseContent[ext], + [`src/imported-styles.${ext}`]: styleImportedContent[ext], + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [`src/styles.${ext}`], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/section\s*{\s*-webkit-hyphens:\s*none;\s*hyphens:\s*none;\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/div\s*{\s*-webkit-hyphens:\s*none;\s*hyphens:\s*none;\s*}/); + }); + + it(`should not add prefixes if not required by browsers in global styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.writeFiles({ + [`src/styles.${ext}`]: styleBaseContent[ext], + [`src/imported-styles.${ext}`]: styleImportedContent[ext], + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [`src/styles.${ext}`], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/section\s*{\s*hyphens:\s*none;\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/div\s*{\s*hyphens:\s*none;\s*}/); + }); + + it(`should add prefixes for listed browsers in external component styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.writeFiles({ + [`src/app/app.component.${ext}`]: styleBaseContent[ext], + [`src/app/imported-styles.${ext}`]: styleImportedContent[ext], + }); + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('./app.component.css', `./app.component.${ext}`), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it(`should not add prefixes if not required by browsers in external component styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.writeFiles({ + [`src/app/app.component.${ext}`]: styleBaseContent[ext], + [`src/app/imported-styles.${ext}`]: styleImportedContent[ext], + }); + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('./app.component.css', `./app.component.${ext}`), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + }); + } + + it('should add prefixes for listed browsers in inline component styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content + .replace('styleUrls', 'styles') + .replace('./app.component.css', 'div { hyphens: none; }'); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + // div[_ngcontent-%COMP%] {\n -webkit-hyphens: none;\n hyphens: none;\n}\n + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it('should not add prefixes if not required by browsers in inline component styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content + .replace('styleUrls', 'styles') + .replace('./app.component.css', 'div { hyphens: none; }'); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it('should add prefixes for listed browsers in inline template styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content.replace('styleUrls', 'styles').replace('./app.component.css', ''); + }); + await harness.modifyFile('src/app/app.component.html', (content) => { + return `\n${content}`; + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + // div[_ngcontent-%COMP%] {\n -webkit-hyphens: none;\n hyphens: none;\n}\n + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it('should not add prefixes if not required by browsers in inline template styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content.replace('styleUrls', 'styles').replace('./app.component.css', ''); + }); + await harness.modifyFile('src/app/app.component.html', (content) => { + return `\n${content}`; + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts new file mode 100644 index 000000000000..b0220529ee28 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Top-level await error message"', () => { + it('should show a Zone.js-specific error when top-level await is used with Zone.js', async () => { + await harness.writeFile( + 'src/main.ts', + ` + // The export makes this file a module, which is required for top-level await. + export const value = await Promise.resolve('test'); + console.log(value); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'Top-level await is not supported in applications that use Zone.js', + ), + }), + ); + }); + + it('should not show a Zone.js-specific error when top-level await is used without Zone.js', async () => { + await harness.writeFile( + 'src/main.ts', + ` + // The export makes this file a module, which is required for top-level await. + export const value = await Promise.resolve('test'); + console.log(value); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + 'Top-level await is not supported in applications that use Zone.js', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts new file mode 100644 index 000000000000..cc74b9549e9d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript explicit incremental option usage"', () => { + it('should successfully build with incremental disabled', async () => { + // Disable tsconfig incremental option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.incremental = false; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts new file mode 100644 index 000000000000..bef97e545ad9 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript isolated modules direct transpilation"', () => { + it('should successfully build with isolated modules enabled and disabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should successfully build with isolated modules enabled and enabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('supports TSX files with isolated modules enabled and enabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + tsconfig.compilerOptions.jsx = 'react-jsx'; + + // There's an implicit `any`, because we don't include the React typings. + tsconfig.compilerOptions.noImplicitAny = false; + + return JSON.stringify(tsconfig); + }); + + await harness.writeFile('src/types.d.ts', `declare module 'react/jsx-runtime' { jsx: any }`); + await harness.writeFile('src/abc.tsx', `export function hello() { return

Hello

; }`); + await harness.modifyFile( + 'src/main.ts', + (content) => content + `import { hello } from './abc'; console.log(hello());`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + externalDependencies: ['react'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts new file mode 100644 index 000000000000..416ee9c227bd --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript Path Mapping"', () => { + it('should resolve TS files when imported with a path mapping', async () => { + // Change main module import to use path mapping + await harness.modifyFile('src/main.ts', (content) => + content.replace(`'./app/app.module'`, `'@root/app.module'`), + ); + + // Add a path mapping for `@root` + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.paths = { + '@root/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should fail to resolve if no path mapping for an import is present', async () => { + // Change main module import to use path mapping + await harness.modifyFile('src/main.ts', (content) => + content.replace(`'./app/app.module'`, `'@root/app.module'`), + ); + + // Add a path mapping for `@not-root` + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.paths = { + '@not-root/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Could not resolve "@root/app.module"'), + }), + ); + }); + + it('should resolve JS files when imported with a path mapping', async () => { + // Change main module import to use path mapping + await harness.modifyFile('src/main.ts', (content) => + content.replace(`'./app/app.module'`, `'app-module'`), + ); + + await harness.writeFiles({ + 'a.js': `export * from './src/app/app.module';\n\nconsole.log('A');`, + 'a.d.ts': `export * from './src/app/app.module';`, + }); + + // Add a path mapping for `@root` + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.paths = { + 'app-module': ['./a.js'], + }; + tsconfig.compilerOptions.allowJs = true; + + return JSON.stringify(tsconfig); + }); + + // app.module needs to be manually included since it is not referenced via a TS file + // with the test path mapping in place. + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.files.push('app/app.module.ts'); + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain(`console.log("A")`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts new file mode 100644 index 000000000000..ba01e2a27dce --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectLog } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files = ['main.server.ts', 'main.ts']; + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles({ + 'src/lazy.ts': `export const foo: number = 1;`, + 'src/main.ts': `export async function fn () { + const lazy = await import('./lazy'); + return lazy.foo; + }`, + 'src/main.server.ts': `export { fn as default } from './main';`, + }); + }); + + describe('Behavior: "Rebuild both server and browser bundles when using lazy loading"', () => { + it('detect changes and errors when expected', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + namedChunks: true, + outputHashing: OutputHashing.None, + server: 'src/main.server.ts', + ssr: true, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Add valid code + await harness.appendToFile('src/lazy.ts', `console.log('foo');`); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update type of 'foo' to invalid (number -> string) + await harness.writeFile('src/lazy.ts', `export const foo: string = 1;`); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, `Type 'number' is not assignable to type 'string'.`); + + // Fix TS error + await harness.writeFile('src/lazy.ts', `export const foo: string = "1";`); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts new file mode 100644 index 000000000000..eeb160ebef47 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when touching file"', () => { + for (const aot of [true, false]) { + it(`Rebuild correctly when file is touched with ${aot ? 'AOT' : 'JIT'}`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + // Touch a file without doing any changes. + await harness.modifyFile('src/app/app.component.ts', (content) => content); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + await harness.removeFile('src/app/app.component.ts'); + }, + ({ result }) => { + expect(result?.success).toBeFalse(); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts new file mode 100644 index 000000000000..6ad5bbb3a9de --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript JSON module resolution"', () => { + it('should resolve JSON files when imported with resolveJsonModule enabled', async () => { + await harness.writeFiles({ + 'src/x.json': `{"a": 1}`, + 'src/main.ts': `import * as x from './x.json'; console.log(x);`, + }); + + // Enable tsconfig resolveJsonModule option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.moduleResolution = 'node16'; + tsconfig.compilerOptions.module = 'node16'; + tsconfig.compilerOptions.resolveJsonModule = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should fail to resolve with TS if resolveJsonModule is not present', async () => { + await harness.writeFiles({ + 'src/x.json': `{"a": 1}`, + 'src/main.ts': `import * as x from './x.json'; console.log(x);`, + }); + + // Enable tsconfig resolveJsonModule option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.moduleResolution = 'node16'; + tsconfig.compilerOptions.module = 'node16'; + tsconfig.compilerOptions.resolveJsonModule = undefined; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(`Cannot find module './x.json'`), + }), + ); + }); + + it('should fail to resolve with TS if resolveJsonModule is disabled', async () => { + await harness.writeFiles({ + 'src/x.json': `{"a": 1}`, + 'src/main.ts': `import * as x from './x.json'; console.log(x);`, + }); + + // Enable tsconfig resolveJsonModule option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.moduleResolution = 'node16'; + tsconfig.compilerOptions.module = 'node16'; + tsconfig.compilerOptions.resolveJsonModule = false; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(`Cannot find module './x.json'`), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts new file mode 100644 index 000000000000..5ae62f020c1c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (export "multiply" (func $multiply)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * ) + * ``` + */ +const exportWasmBase64 = + 'AGFzbQEAAAABBwFgAn9/AX8DAgEABwwBCG11bHRpcGx5AAAKCQEHACAAIAFsCwAXBG5hbWUBCwEACG11bHRpcGx5AgMBAAA='; +const exportWasmBytes = Buffer.from(exportWasmBase64, 'base64'); + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Supports WASM/ES module integration"', () => { + it('should inject initialization code and add an export', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should compile successfully with a provided type definition file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + await harness.writeFile( + 'src/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number;', + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should add WASM defined imports and include resolved TS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create TS file that is expect by WASM file + await harness.writeFile( + 'src/values.ts', + ` + export function getValue(): number { return 100; } + `, + ); + // The file is not imported into any actual TS files so it needs to be manually added to the TypeScript program + await harness.modifyFile('src/tsconfig.app.json', (content) => + content.replace('"main.ts",', '"main.ts","values.ts",'), + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should add WASM defined imports and include resolved JS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create JS file that is expect by WASM file + await harness.writeFile( + 'src/values.js', + ` + export function getValue() { return 100; } + `, + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should inline WASM files less than 10kb', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure WASM is present in output code + harness.expectFile('dist/browser/main.js').content.toContain(exportWasmBase64); + }); + + it('should show an error on invalid WASM file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', 'NOT_WASM'); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unable to analyze WASM file'), + }), + ); + }); + + it('should show an error if using Zone.js', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', importWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'WASM/ES module integration imports are not supported with Zone.js applications', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts new file mode 100644 index 000000000000..10112afd3b6b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * A regular expression used to check if a built worker is correctly referenced in application code. + */ +const REFERENCED_WORKER_REGEXP = + /new Worker\(new URL\("worker-[A-Z0-9]{8}\.js", import\.meta\.url\)/; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Bundles web worker files within application code"', () => { + it('should use the worker entry point when worker lazy chunks are present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const workerCodeFile = ` + addEventListener('message', () => { + import('./extra').then((m) => console.log(m.default)); + }); + `; + const extraWorkerCodeFile = ` + export default 'WORKER FILE'; + `; + + // Create a worker file + await harness.writeFile('src/app/worker.ts', workerCodeFile); + await harness.writeFile('src/app/extra.ts', extraWorkerCodeFile); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure built worker is referenced in the application code + harness.expectFile('dist/browser/main.js').content.toMatch(REFERENCED_WORKER_REGEXP); + }); + + it('should emit all chunks referenced by a web worker when chunk optimization is enabled', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + }); + + await harness.writeFile( + 'src/app/shared.ts', + `export function sharedHelper(value: string) { return [value, value.toUpperCase(), value.toLowerCase(), value.repeat(3)].join('-'); }`, + ); + await harness.writeFile( + 'src/app/lazy-a.ts', + `import { sharedHelper } from './shared'; export const a = () => sharedHelper('a');`, + ); + await harness.writeFile( + 'src/app/lazy-b.ts', + `import { sharedHelper } from './shared'; export const b = () => sharedHelper('b');`, + ); + await harness.writeFile('src/app/lazy-c.ts', `export const c = () => 'c';`); + + await harness.writeFile( + 'src/app/worker.ts', + ` + addEventListener('message', async () => { + const [{ a }, { b }] = await Promise.all([import('./lazy-a'), import('./lazy-b')]); + postMessage(a() + b()); + }); + `, + ); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + constructor() { + Promise.all([import('./lazy-a'), import('./lazy-b'), import('./lazy-c')]).then(console.log); + } + } + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /^worker-chunk-/)).toBeTrue(); + + const mainContent = harness.readFile('dist/browser/main.js'); + const workerMatch = mainContent.match(/(worker-[A-Z0-9]{8}\.js)/); + expect(workerMatch).not.toBeNull(); + + const filesToCheck = new Set(['main.js']); + if (workerMatch) { + filesToCheck.add(workerMatch[1]); + } + + for (const file of filesToCheck) { + expect(harness.hasFile(`dist/browser/${file}`)) + .withContext(`Expected dist/browser/${file} to exist`) + .toBeTrue(); + + const content = harness.readFile(`dist/browser/${file}`); + for (const match of content.matchAll(/(?:from|import)\s*\(?["']\.\/([^"']+\.js)["']\)?/g)) { + filesToCheck.add(match[1]); + } + } + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts new file mode 100644 index 000000000000..7521b1a1a495 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "allowedCommonJsDependencies"', () => { + describe('given option is not set', () => { + for (const aot of [true, false]) { + it(`should show warning when depending on a Common JS bundle in ${ + aot ? 'AOT' : 'JIT' + } Mode`, async () => { + // Add a Common JS dependency + await harness.appendToFile('src/app/app.component.ts', `import 'buffer';`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + aot, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectLog(logs, /Module 'buffer' used by 'src\/app\/app\.component\.ts' is not ESM/); + expectLog(logs, /CommonJS or AMD dependencies/); + expectNoLog( + logs, + 'base64-js', + 'Should not warn on transitive CommonJS packages which parent is also CommonJS.', + ); + }); + } + }); + + it('should not show warning when depending on a Common JS bundle which is allowed', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'buffer'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['buffer', 'base64-js', 'ieee754'], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning when all dependencies are allowed by wildcard', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'buffer'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['*'], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning when depending on zone.js', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'zone.js'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it(`should not show warning when importing non global local data '@angular/common/locale/fr'`, async () => { + await harness.appendToFile( + 'src/app/app.component.ts', + `import '@angular/common/locales/fr';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning in JIT for templateUrl and styleUrl when using paths', async () => { + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '@app/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.modifyFile('src/app/app.module.ts', (content) => + content.replace('./app.component', '@app/app.component'), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + aot: false, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning for relative imports', async () => { + await harness.appendToFile('src/main.ts', `import './abc';`); + await harness.writeFile('src/abc.ts', 'console.log("abc");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts new file mode 100644 index 000000000000..16f2a27b634c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const appShellRouteFiles: Record = { + 'src/styles.css': `p { color: #000 }`, + 'src/app/app-shell/app-shell.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-app-shell', + standalone: false, + styles: ['div { color: #fff; }'], + template: '

app-shell works!

', + }) + export class AppShellComponent {}`, + 'src/main.server.ts': ` + import { AppServerModule } from './app/app.module.server'; + export default AppServerModule; + `, + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppRoutingModule } from './app-routing.module'; + import { AppComponent } from './app.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + RouterModule + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.module.server.ts': ` + import { NgModule } from '@angular/core'; + import { ServerModule } from '@angular/platform-server'; + + import { AppModule } from './app.module'; + import { AppComponent } from './app.component'; + import { Routes, RouterModule } from '@angular/router'; + import { AppShellComponent } from './app-shell/app-shell.component'; + + const routes: Routes = [ { path: 'shell', component: AppShellComponent }]; + + @NgModule({ + imports: [ + AppModule, + ServerModule, + RouterModule.forRoot(routes), + ], + bootstrap: [AppComponent], + declarations: [AppShellComponent], + }) + export class AppServerModule {} + `, + 'src/main.ts': ` + import { platformBrowser } from '@angular/platform-browser'; + import { AppModule } from './app/app.module'; + + platformBrowser().bootstrapModule(AppModule).catch(err => console.log(err)); + `, + 'src/app/app-routing.module.ts': ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + + const routes: Routes = []; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] + }) + export class AppRoutingModule { } + `, + 'src/app/app.component.html': ``, +}; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles(appShellRouteFiles); + }); + + describe('Option: "appShell"', () => { + it('renders the application shell', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + appShell: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + const indexFileContent = harness.expectFile('dist/browser/index.html').content; + indexFileContent.toContain('app-shell works!'); + }); + + it('critical CSS is inlined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + appShell: true, + styles: ['src/styles.css'], + optimization: { + styles: { + minify: true, + inlineCritical: true, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const indexFileContent = harness.expectFile('dist/browser/index.html').content; + indexFileContent.toContain('app-shell works!'); + indexFileContent.toContain('p{color:#000}'); + indexFileContent.toContain( + ``, + ); + }); + + it('applies CSP nonce to critical CSS', async () => { + await harness.modifyFile('src/index.html', (content) => + content.replace(/`, + ); + indexFileContent.toContain('
Styled
', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from "@angular/core"; + + @Component({ + selector: "lib-html-style", + templateUrl: "./lib.component.html", + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Sass, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toMatch(/color:\s*#ff5722/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts new file mode 100644 index 000000000000..6518b459b023 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/watch-cleanup_spec.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Watch Mode Asset and Output Cleanup"', () => { + it('should emit newly created assets, respect ignore patterns, and clean up deleted assets in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/keep.txt': 'keep', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets', + ignore: ['**/ignored.txt'], + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + + // Create an ignored file and a matching nested asset file + await harness.writeFiles({ + 'projects/lib/assets-dir/sub/ignored.txt': 'ignored', + 'projects/lib/assets-dir/sub/added.txt': 'added', + }); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/sub/added.txt')).toBe('added'); + expect(harness.hasFile('dist/lib/assets/sub/ignored.txt')).toBeFalse(); + + // Remove the added asset file + await harness.removeFile('projects/lib/assets-dir/sub/added.txt'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/keep.txt')).toBe('keep'); + expect(harness.hasFile('dist/lib/assets/sub/added.txt')).toBeFalse(); + }, + ]); + }); + + it('should clean up output files when a secondary entry point is removed in watch mode', async () => { + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const secondary = 'secondary-value';`, + ); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + declarationMap: true, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs.map')).toBeTrue(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts')).toBeTrue(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts.map')).toBeTrue(); + expect(harness.hasFile('dist/lib/secondary/package.json')).toBeTrue(); + expect(harness.hasFile('dist/lib/.npmignore')).toBeTrue(); + + // Remove the secondary entry point from package.json exports + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeFalse(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs.map')).toBeFalse(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts')).toBeFalse(); + expect(harness.hasFile('dist/lib/types/lib-secondary.d.ts.map')).toBeFalse(); + expect(harness.hasFile('dist/lib/secondary/package.json')).toBeFalse(); + expect(harness.hasFile('dist/lib/.npmignore')).toBeFalse(); + + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.exports['./secondary']).toBeUndefined(); + }, + ]); + }); + + it('should prune removed nested secondary entry point files and update .npmignore in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/shared/src/public-api.ts': `export const SHARED = 'shared';\n`, + 'projects/lib/testing/sub/src/public-api.ts': `export const SUB = 'sub';\n`, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './testing/sub': './testing/sub/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib-shared.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-testing-sub.mjs').toExist(); + harness.expectFile('dist/lib/types/lib-testing-sub.d.ts').toExist(); + harness.expectFile('dist/lib/testing/sub/package.json').toExist(); + expect(harness.readFile('dist/lib/.npmignore')).toContain('/testing/sub/package.json'); + + // Remove './testing/sub' while keeping './shared' + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib-shared.mjs').toExist(); + harness.expectFile('dist/lib/fesm2022/lib-testing-sub.mjs').toNotExist(); + harness.expectFile('dist/lib/fesm2022/lib-testing-sub.mjs.map').toNotExist(); + harness.expectFile('dist/lib/types/lib-testing-sub.d.ts').toNotExist(); + harness.expectFile('dist/lib/testing/sub/package.json').toNotExist(); + + const npmignore = harness.readFile('dist/lib/.npmignore'); + expect(npmignore).toContain('/shared/package.json'); + expect(npmignore).not.toContain('/testing/sub/package.json'); + }, + ]); + }); + + it('should clean up all output destinations when a single asset source matches multiple patterns and is deleted in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/shared.txt': 'shared-content', + 'projects/lib/assets-dir/keep.txt': 'keep-content', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets-a', + }, + { + glob: '**/*.txt', + input: 'projects/lib/assets-dir', + output: 'assets-b', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets-a/shared.txt')).toBe('shared-content'); + expect(harness.readFile('dist/lib/assets-b/shared.txt')).toBe('shared-content'); + + await harness.removeFile('projects/lib/assets-dir/shared.txt'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/assets-a/shared.txt')).toBeFalse(); + expect(harness.hasFile('dist/lib/assets-b/shared.txt')).toBeFalse(); + expect(harness.readFile('dist/lib/assets-a/keep.txt')).toBe('keep-content'); + expect(harness.readFile('dist/lib/assets-b/keep.txt')).toBe('keep-content'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts new file mode 100644 index 000000000000..e117f8efe9f8 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -0,0 +1,1162 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { rm } from 'node:fs/promises'; +import { executeLibraryBuilder } from '../../builder'; +import { CompilationMode, InlineStyleLanguage } from '../../schema'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Behavior: "Watch Mode Rebuilding"', () => { + it('should rebuild library when a component file is modified', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('LibComponent'); + + // Trigger a change + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-rebuilt', + template: 'Rebuilt', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('lib-rebuilt'); + }, + ]); + }); + + it('should rebuild when external template or stylesheet file is modified', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/lib.component.html': '

Initial Template

', + 'projects/lib/src/lib/lib.component.css': 'h1 { color: blue; }', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-resources', + templateUrl: './lib.component.html', + styleUrl: './lib.component.css', + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('Initial Template'); + expect(content).toMatch(/color:\s*(?:blue|#00f)/); + + // Trigger change to external template + await harness.writeFile( + 'projects/lib/src/lib/lib.component.html', + '

Updated Template

', + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('Updated Template'); + + // Trigger change to external stylesheet + await harness.writeFile('projects/lib/src/lib/lib.component.css', 'h1 { color: green; }'); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toMatch(/color:\s*green/); + }, + ]); + }); + + it('should rebuild intra-dependent secondary entry points when upstream changes', async () => { + await harness.writeFiles({ + 'projects/lib/shared/src/public-api.ts': ` + export const SHARED_VERSION = '1.0.0'; + `, + 'projects/lib/feature/src/public-api.ts': ` + import { SHARED_VERSION } from 'lib/shared'; + export const FEATURE_INFO = \`Feature using \${SHARED_VERSION}\`; + `, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + './feature': './feature/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const featureFesm = harness.readFile('dist/lib/fesm2022/lib-feature.mjs'); + expect(featureFesm).toContain('FEATURE_INFO'); + + // Modify upstream shared entry point + await harness.writeFile( + 'projects/lib/shared/src/public-api.ts', + ` + export const SHARED_VERSION = '2.0.0'; + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const sharedFesm = harness.readFile('dist/lib/fesm2022/lib-shared.mjs'); + expect(sharedFesm).toContain('2.0.0'); + const featureFesm = harness.readFile('dist/lib/fesm2022/lib-feature.mjs'); + expect(featureFesm).toContain('FEATURE_INFO'); + }, + ]); + }); + + it('should re-copy assets when an asset file is modified in watch mode', async () => { + await harness.writeFile('projects/lib/assets/data.json', '{"version": 1}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*', + input: 'projects/lib/assets', + output: 'assets', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/data.json')).toBe('{"version": 1}'); + + // Modify asset file + await harness.writeFile('projects/lib/assets/data.json', '{"version": 2}'); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/assets/data.json')).toBe('{"version": 2}'); + }, + ]); + }); + + it('should set a watch version in package.json in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.version).toMatch(/^0\.0\.0-watch\+\d+$/); + }, + ]); + }); + + it('should not update package.json when only source files change in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + let initialVersion: string; + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + initialVersion = pkg.version; + expect(initialVersion).toMatch(/^0\.0\.0-watch\+\d+$/); + + // Wait a brief moment so Date.now() would differ if regenerated + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Modify source file + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-rebuilt', + template: 'Rebuilt', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('lib-rebuilt'); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.version).toBe(initialVersion); + }, + ]); + }); + + it('should update package.json when package.json is modified in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.description).toBeUndefined(); + + // Modify package.json + const originalPkg = JSON.parse(harness.readFile('projects/lib/package.json')); + originalPkg.description = 'Updated description'; + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify(originalPkg, null, 2), + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.description).toBe('Updated description'); + }, + ]); + }); + + it('should re-analyze exports and compile new entry point when package.json is modified in watch mode', async () => { + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const secondary = 'secondary-initial';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeFalse(); + + // Add secondary entry point to package.json exports + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib-secondary.mjs')).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'secondary-initial', + ); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.exports['./secondary']).toBeDefined(); + + // Update secondary entry point source file to ensure it is being watched + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const secondary = 'secondary-updated';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'secondary-updated', + ); + }, + ]); + }); + + it('should re-analyze exports and recompile when entry point path is changed in package.json in watch mode', async () => { + await harness.writeFile( + 'projects/lib/src/alternative-api.ts', + `export const value = 'alternative-api-content';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('LibComponent'); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).not.toContain( + 'alternative-api-content', + ); + + // Change primary entry point path in package.json + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.exports = { + '.': './src/alternative-api.ts', + }; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain( + 'alternative-api-content', + ); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).not.toContain('LibComponent'); + }, + ]); + }); + + it('should fail with error when name is removed from package.json in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Remove name from package.json + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + delete pkg.name; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + expect(result?.error).toContain("must contain a 'name'"); + + // Restore name in package.json + const pkg = JSON.parse(harness.readFile('projects/lib/package.json')); + pkg.name = 'lib'; + await harness.writeFile('projects/lib/package.json', JSON.stringify(pkg, null, 2)); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + }, + ]); + }); + + it('should recover from compilation errors in watch mode', async () => { + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title = 'hello world';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('hello world'); + + // Introduce a compilation error + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title: number = 'invalid type';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + + // Fix the compilation error + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title = 'fixed world';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('fixed world'); + }, + ]); + }); + + it('should rebuild secondary entry point when its file changes', async () => { + await harness.writeFiles({ + 'projects/lib/secondary/src/public-api.ts': `export const MSG = 'initial secondary';`, + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'initial secondary', + ); + + // Modify secondary entry point source + await harness.writeFile( + 'projects/lib/secondary/src/public-api.ts', + `export const MSG = 'updated secondary';`, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'updated secondary', + ); + const messages = logs.map((l) => l.message); + expect(messages.some((m) => m.includes('Compiling lib/secondary...'))).toBeTrue(); + expect(messages.some((m) => m.includes('Compiling lib...'))).toBeFalse(); + }, + ]); + }); + it('should recover when initial build fails with a compilation error', async () => { + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title: number = 'invalid type';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeFalse(); + + // Fix the compilation error + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export const title = 'fixed world';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('fixed world'); + }, + ]); + }); + + it('should rebuild when a new file is created in projectRoot', async () => { + await harness.writeFile('projects/lib/src/public-api.ts', `export * from './extra';`); + await harness.writeFile('projects/lib/src/extra.ts', `export const INITIAL = true;`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('INITIAL'); + + // Create a brand new file + await harness.writeFile( + 'projects/lib/src/lib/new-feature.ts', + `export const NEW_VAL = 123;`, + ); + await harness.writeFile( + 'projects/lib/src/public-api.ts', + `export * from './extra';\nexport * from './lib/new-feature';`, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('NEW_VAL'); + }, + ]); + }); + + it('should update package.json in watch mode when tslib import is added or removed', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.dependencies).toBeUndefined(); + + // Add a custom decorator that causes tslib to be imported + await harness.writeFile( + 'projects/lib/src/public-api.ts', + ` + function CustomClassDecorator(): ClassDecorator { + return () => {}; + } + + @CustomClassDecorator() + export class DecoratedService {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.dependencies).toEqual({ + tslib: jasmine.any(String), + }); + + // Remove the custom decorator so tslib is no longer imported + await harness.writeFile( + 'projects/lib/src/public-api.ts', + ` + export class PlainService {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const pkg = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkg.dependencies).toBeUndefined(); + }, + ]); + }); + + it('should recreate deleted output directories on subsequent watch rebuilds', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + + // Remove output subdirectory externally between rebuilds + await rm(harness.resolvePath('dist/lib/fesm2022'), { recursive: true, force: true }); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeFalse(); + + // Trigger a rebuild + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-recreated', + template: 'Recreated', + }) + export class LibComponent {} + `, + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('lib-recreated'); + }, + ]); + }); + + it('should rebuild when a Sass partial imported by an inline component stylesheet is modified in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/_vars.scss': '$primary-color: rgb(10, 20, 30);', + 'projects/lib/src/lib/_spacing.scss': '$pad: 12px;', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-inline-sass', + template: 'Inline Sass', + styles: [ + '@use "./vars"; :host { color: vars.$primary-color; }', + '@use "./spacing"; :host { padding: spacing.$pad; }', + ], + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Scss, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toMatch(/color:\s*(?:#0a141e|rgb\(10,\s*20,\s*30\))/); + expect(content).toMatch(/padding:\s*12px/); + + // Modify the Sass partial referenced by the first inline component style + await harness.writeFile( + 'projects/lib/src/lib/_vars.scss', + '$primary-color: rgb(200, 100, 50);', + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toMatch(/color:\s*(?:#c86432|rgb\(200,\s*100,\s*50\))/); + expect(content).toMatch(/padding:\s*12px/); + }, + ]); + }); + + it('should only rebuild affected entry point when an external template is modified with isolatedModules enabled', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/lib.component.html': '

Primary Initial

', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-primary', + templateUrl: './lib.component.html', + }) + export class LibComponent {} + `, + 'projects/lib/secondary/src/secondary.component.html': '

Secondary Initial

', + 'projects/lib/secondary/src/secondary.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-secondary', + templateUrl: './secondary.component.html', + }) + export class SecondaryComponent {} + `, + 'projects/lib/secondary/src/public-api.ts': ` + export { SecondaryComponent } from './secondary.component'; + `, + }); + + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './secondary': './secondary/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'Secondary Initial', + ); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('ɵcmp'); + expect(harness.readFile('dist/lib/types/lib-secondary.d.ts')).toContain('ɵcmp'); + + await harness.writeFile( + 'projects/lib/secondary/src/secondary.component.html', + '

Secondary Updated

', + ); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain( + 'Secondary Updated', + ); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('ɵcmp'); + expect(harness.readFile('dist/lib/types/lib-secondary.d.ts')).toContain('ɵcmp'); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('Compiling lib/secondary...'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('Compiling lib...'), + }), + ); + }, + ]); + }); + + it('should rebuild dependent entry point when a shared component input is added in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/shared/src/public-api.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-shared-icon', + template: 'icon', + }) + export class SharedIconComponent {} + `, + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + import { SharedIconComponent } from 'lib/shared'; + + @Component({ + selector: 'lib-primary', + imports: [SharedIconComponent], + template: '', + }) + export class LibComponent {} + `, + }); + + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './shared': './shared/src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + compilationMode: CompilationMode.Partial, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Trigger a rebuild of `lib` first so `lib/shared` undergoes a `.d.ts`-only check + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + import { SharedIconComponent } from 'lib/shared'; + + @Component({ + selector: 'lib-primary', + imports: [SharedIconComponent], + template: '', + }) + export class LibComponent {} + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('Compiling lib...'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('Compiling lib/shared...'), + }), + ); + + // Add a new @Input() to SharedIconComponent + await harness.writeFile( + 'projects/lib/shared/src/public-api.ts', + ` + import { Component, Input } from '@angular/core'; + + @Component({ + selector: 'lib-shared-icon', + template: '{{ name }}', + }) + export class SharedIconComponent { + @Input() name = 'default'; + } + `, + ); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('Compiling lib/shared...'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('Compiling lib...'), + }), + ); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('"name"'); + }, + ]); + }); + + it('should retry reloading broken package.json when another file changes and recover once fixed', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Break package.json with invalid JSON + await harness.writeFile('projects/lib/package.json', '{ invalid json'); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + expect(result?.error).toContain("Failed to reload 'package.json'"); + + // Modify a source file while package.json is still broken + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-after-pkg-error', + template: 'After Pkg Error', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + expect(result?.error).toContain("Failed to reload 'package.json'"); + + // Fix package.json + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify( + { + name: 'lib', + version: '0.0.1', + exports: { + '.': './src/public-api.ts', + }, + }, + null, + 2, + ), + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('lib-after-pkg-error'); + }, + ]); + }); + + it('should preserve and recover from TypeScript declaration emit diagnostics in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/internal.ts': ` + interface PrivateData { + value: number; + } + + export const internalData: PrivateData = { value: 42 }; + `, + 'projects/lib/src/lib/feature.ts': ` + import { internalData } from './internal'; + + export const data = internalData; + `, + 'projects/lib/src/public-api.ts': ` + export * from './lib/feature'; + export * from './lib/lib.service'; + export * from './lib/lib.component'; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases( + [ + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(/TS4023:.*PrivateData/), + }), + ); + + // Modify an unrelated file so feature.ts is not re-emitted + await harness.writeFile( + 'projects/lib/src/lib/lib.service.ts', + ` + import { Injectable } from '@angular/core'; + + @Injectable({ providedIn: 'root' }) + export class LibService { + updated = true; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(/TS4023:.*PrivateData/), + }), + ); + + // Fix the declaration error in feature.ts by adding an explicit type annotation + await harness.writeFile( + 'projects/lib/src/lib/feature.ts', + ` + import { internalData } from './internal'; + + export const data: { value: number } = internalData; + `, + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + const dtsContent = harness.readFile('dist/lib/types/lib.d.ts'); + expect(dtsContent).toContain('data'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('should rebuild when an external Sass partial or template is modified and recover from template errors', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/_vars.scss': '$primary-color: rgb(10, 20, 30);', + 'projects/lib/src/lib/lib.component.scss': + '@use "./vars"; :host { color: vars.$primary-color; }', + 'projects/lib/src/lib/lib.component.html': '

{{ title }}

', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-external-resources', + templateUrl: './lib.component.html', + styleUrl: './lib.component.scss', + }) + export class LibComponent { + title = 'Hello'; + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('h1'); + expect(content).toMatch(/color:\s*(?:#0a141e|rgb\(10,\s*20,\s*30\))/); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('LibComponent'); + + // Modify the imported Sass partial + await harness.writeFile( + 'projects/lib/src/lib/_vars.scss', + '$primary-color: rgb(200, 100, 50);', + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toMatch( + /color:\s*(?:#c86432|rgb\(200,\s*100,\s*50\))/, + ); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('LibComponent'); + + // Introduce a template type-checking error in the external HTML template + await harness.writeFile( + 'projects/lib/src/lib/lib.component.html', + '
{{ nonExistentProp }}
', + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('nonExistentProp'), + }), + ); + + // Fix the external HTML template error + await harness.writeFile( + 'projects/lib/src/lib/lib.component.html', + '
{{ title }}
', + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('footer'); + expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('LibComponent'); + }, + ]); + }); + + it('should preserve template diagnostics across watch rebuilds after a stylesheet error', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.angularCompilerOptions = { + ...tsconfig.angularCompilerOptions, + strictTemplates: true, + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.writeFiles({ + 'projects/lib/src/lib/other.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-other', + template: '{{ validProp }}', + }) + export class OtherComponent { + validProp = 'ok'; + } + `, + 'projects/lib/src/lib/lib.component.scss': '.valid { color: blue; }', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-primary', + template: 'Primary', + styleUrl: './lib.component.scss', + }) + export class LibComponent {} + `, + 'projects/lib/src/public-api.ts': ` + export * from './lib/lib.component'; + export * from './lib/other.component'; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Introduce both a stylesheet error in LibComponent and a template error in OtherComponent + await harness.writeFiles({ + 'projects/lib/src/lib/lib.component.scss': '.broken { color: $undefined_var; }', + 'projects/lib/src/lib/other.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-other', + template: '{{ unknownProp }}', + }) + export class OtherComponent {} + `, + }); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining('Undefined variable'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + "Property 'unknownProp' does not exist on type 'OtherComponent'", + ), + }), + ); + + // Fix only the stylesheet error in LibComponent while OtherComponent still has the template error + await harness.writeFile( + 'projects/lib/src/lib/lib.component.scss', + '.valid { color: green; }', + ); + }, + ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + "Property 'unknownProp' does not exist on type 'OtherComponent'", + ), + }), + ); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts b/packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts new file mode 100644 index 000000000000..d72923c85ba0 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/allowed-non-peer-dependencies_spec.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "allowedNonPeerDependencies"', () => { + it('should fail build when package.json has unallowed dependencies', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + 'lodash-es': '^4.17.21', + }; + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching( + /lodash-es.*allowedNonPeerDependencies.*projects\/lib\/package\.json/, + ), + }), + ); + }); + + it('should succeed build when dependency matches allowedNonPeerDependencies pattern', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + 'lodash-es': '^4.17.21', + }; + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedNonPeerDependencies: ['^lodash-.*'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow tslib by default in dependencies without configuration', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.dependencies = { + tslib: '^2.3.0', + }; + return JSON.stringify(pkg, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/assets_spec.ts b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts new file mode 100644 index 000000000000..32d2dc1ab507 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/assets_spec.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "assets"', () => { + it('should copy assets matching glob patterns with input, output, and ignore', async () => { + await harness.writeFiles({ + 'projects/lib/assets-dir/file-a.png': 'PNG_A', + 'projects/lib/assets-dir/file-b.png': 'PNG_B', + 'projects/lib/assets-dir/ignored.png': 'PNG_IGNORED', + 'projects/lib/assets-dir/file-c.svg': 'SVG_C', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*.png', + input: 'projects/lib/assets-dir', + output: 'assets', + ignore: ['ignored.png'], + }, + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/assets/file-a.png')).toBe('PNG_A'); + expect(harness.readFile('dist/lib/assets/file-b.png')).toBe('PNG_B'); + expect(harness.hasFile('dist/lib/assets/ignored.png')).toBeFalse(); + expect(harness.hasFile('dist/lib/assets/file-c.svg')).toBeFalse(); + }); + + it('should support string-based asset paths', async () => { + await harness.writeFile('projects/lib/docs/README.md', '# Library Docs'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['projects/lib/docs/README.md'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/docs/README.md')).toBe('# Library Docs'); + }); + + it('should not copy root README.md into secondary entry point output when secondary entry point is in projectRoot', async () => { + await harness.writeFiles({ + 'projects/lib/README.md': '# Root Library README', + 'projects/lib/testing.ts': 'export const TESTING = true;', + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './testing': './testing.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/README.md')).toBe('# Root Library README'); + expect(harness.hasFile('dist/lib/testing/README.md')).toBeFalse(); + }); + + it('should not copy README.md into secondary entry point output when sharing directory with primary entry point', async () => { + await harness.writeFiles({ + 'projects/lib/README.md': '# Root Library README', + 'projects/lib/src/README.md': '# Src README', + 'projects/lib/src/testing.ts': 'export const TESTING = true;', + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './testing': './src/testing.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/README.md')).toBe('# Root Library README'); + expect(harness.hasFile('dist/lib/testing/README.md')).toBeFalse(); + }); + + it('should only copy the root README.md by default and not secondary entry point README.md files', async () => { + await harness.writeFiles({ + 'projects/lib/README.md': '# Root Library README', + 'projects/lib/testing/README.md': '# Testing Entry Point README', + 'projects/lib/testing/public-api.ts': 'export const TESTING = true;', + }); + + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + './testing': './testing/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(harness.readFile('dist/lib/README.md')).toBe('# Root Library README'); + expect(harness.hasFile('dist/lib/testing/README.md')).toBeFalse(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts b/packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts new file mode 100644 index 000000000000..0efedef5708f --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/compilation-mode_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { CompilationMode } from '../../schema'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "compilationMode"', () => { + it('should emit partial declarations when compilationMode is "partial"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + compilationMode: CompilationMode.Partial, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('ɵɵngDeclareComponent'); + }); + + it('should emit full definitions when compilationMode is "full"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + compilationMode: CompilationMode.Full, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('ɵɵdefineComponent'); + }); + + it('should respect angularCompilerOptions.compilationMode from tsconfig when not set in builder options', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.angularCompilerOptions = { + ...tsconfig.angularCompilerOptions, + compilationMode: 'full', + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('ɵɵdefineComponent'); + + const pkgJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(pkgJson.scripts?.prepublishOnly).toContain('full compilation mode'); + }); + + it('should override angularCompilerOptions.compilationMode from tsconfig when set in builder options', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.angularCompilerOptions = { + ...tsconfig.angularCompilerOptions, + compilationMode: 'full', + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + compilationMode: CompilationMode.Partial, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toContain('ɵɵngDeclareComponent'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts b/packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts new file mode 100644 index 000000000000..2e30109d6a03 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/declaration-map_spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; +import { join } from 'node:path'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "declarationMap"', () => { + it('should not emit declaration sourcemaps by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // FESM sourcemaps are always enabled + harness.expectFile('dist/lib/fesm2022/lib.mjs.map').toExist(); + // DTS sourcemaps are disabled by default + harness.expectFile('dist/lib/types/lib.d.ts.map').toNotExist(); + }); + + it('should emit declaration sourcemaps when declarationMap is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + declarationMap: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // FESM sourcemaps are always enabled + harness.expectFile('dist/lib/fesm2022/lib.mjs.map').toExist(); + + // DTS sourcemaps should be generated + harness.expectFile('dist/lib/types/lib.d.ts.map').toExist(); + const dtsMap = JSON.parse(harness.readFile('dist/lib/types/lib.d.ts.map')); + expect(dtsMap.sources).toContain('../../../projects/lib/src/lib/lib.component.ts'); + harness.expectFile(join('dist/lib/types', dtsMap.sources[0])).toExist(); + }); + + it('should respect declarationMap from tsconfig when not set in builder options', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.declarationMap = true; + + return JSON.stringify(tsconfig, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/lib/types/lib.d.ts.map').toExist(); + }); + + it('should override declarationMap from tsconfig when declarationMap is false', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.declarationMap = true; + + return JSON.stringify(tsconfig, null, 2); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + declarationMap: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/lib/fesm2022/lib.mjs.map').toExist(); + harness.expectFile('dist/lib/types/lib.d.ts.map').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts b/packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts new file mode 100644 index 000000000000..e8001de35f33 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/delete-output-path_spec.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "deleteOutputPath"', () => { + beforeEach(async () => { + // Add pre-existing files in output directory + await harness.writeFile('dist/lib/extra.txt', 'EXTRA'); + }); + + it('should delete the output files when deleteOutputPath is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/extra.txt').toNotExist(); + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + }); + + it('should not delete existing output files when deleteOutputPath is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/extra.txt').toExist(); + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts new file mode 100644 index 000000000000..cf1bb1b6e7b7 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/entry-points_spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Package.json "exports" entry points', () => { + it('should succeed when entry point is a .ts file', async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should succeed when exports is a string shorthand', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = './src/public-api.ts'; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should succeed when entry point is a .mts file', async () => { + await harness.writeFiles({ + 'projects/lib/src/public-api.mts': 'export const VALUE = 42;\n', + }); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.mts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should fail when entry point is not a .ts or .mts file', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.cts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + expect(result).toBeUndefined(); + expect(error).toBeDefined(); + expect((error as Error).message).toMatch(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + }); + + it('should fail when entry point is a declaration file', async () => { + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.d.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + + const { result, error } = await harness.executeOnce({ + outputLogsOnException: false, + outputLogsOnFailure: false, + }); + expect(result).toBeUndefined(); + expect(error).toBeDefined(); + expect((error as Error).message).toMatch(/must be a TypeScript file \('\.ts' or '\.mts'\)/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts new file mode 100644 index 000000000000..4dc3de98cc87 --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/keep-lifecycle-scripts_spec.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "keepLifecycleScripts"', () => { + it('should remove scripts from package.json by default', async () => { + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + exports: { + '.': './src/public-api.ts', + }, + scripts: { + postinstall: 'echo postinstall', + }, + }), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const distPackageJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(distPackageJson.scripts).toBeUndefined(); + }); + + it('should preserve scripts in package.json when keepLifecycleScripts is true', async () => { + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify({ + name: 'my-lib', + version: '1.0.0', + exports: { + '.': './src/public-api.ts', + }, + scripts: { + postinstall: 'echo postinstall', + }, + }), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + keepLifecycleScripts: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const distPackageJson = JSON.parse(harness.readFile('dist/lib/package.json')); + expect(distPackageJson.scripts).toEqual({ + postinstall: 'echo postinstall', + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/options/output-path_spec.ts b/packages/angular/build/src/builders/library/tests/options/output-path_spec.ts new file mode 100644 index 000000000000..2969692fa6ac --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/options/output-path_spec.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeLibraryBuilder } from '../../builder'; +import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; + +describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { + describe('Option: "outputPath"', () => { + it('should default outputPath to dist/{projectName} when omitted', async () => { + const { outputPath: _, ...optionsWithoutOutputPath } = BASE_OPTIONS; + harness.useTarget('build', optionsWithoutOutputPath); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/lib/fesm2022/lib.mjs').toExist(); + harness.expectFile('dist/lib/package.json').toExist(); + }); + + it('should use custom outputPath when specified', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputPath: 'dist/custom-output', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/custom-output/fesm2022/lib.mjs').toExist(); + harness.expectFile('dist/custom-output/package.json').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/setup.ts b/packages/angular/build/src/builders/library/tests/setup.ts new file mode 100644 index 000000000000..53202094870e --- /dev/null +++ b/packages/angular/build/src/builders/library/tests/setup.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuilderHandlerFn } from '@angular-devkit/architect'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { json, normalize, join } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import { JasmineBuilderHarness } from '../../../../../../../modules/testing/builder/src'; +import { Schema } from '../schema'; + +export * from '../../../../../../../modules/testing/builder/src'; + +export const LIBRARY_BUILDER_INFO = Object.freeze({ + name: '@angular/build:library', + schemaPath: __dirname + '/../schema.json', +}); + +export const BASE_OPTIONS = Object.freeze({ + tsConfig: 'projects/lib/tsconfig.lib.json', + outputPath: 'dist/lib', + poll: 100, +}); + +const libWorkspaceRoot = join( + normalize(__dirname), + '../../../../../../../modules/testing/builder/projects/hello-world-lib/', +); +export const libHost = new TestProjectHost(libWorkspaceRoot); + +const optionSchemaCache = new Map(); + +function getCachedSchema(options: { schemaPath: string }): json.schema.JsonSchema { + let optionSchema = optionSchemaCache.get(options.schemaPath); + if (optionSchema === undefined) { + optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema; + optionSchemaCache.set(options.schemaPath, optionSchema); + } + return optionSchema; +} + +let counter = 0; + +export function describeLibraryBuilder( + builderHandler: BuilderHandlerFn, + options: { name?: string; schemaPath: string }, + specDefinitions: (harness: JasmineBuilderHarness) => void, +): void { + const optionSchema = getCachedSchema(options); + const harness = new JasmineBuilderHarness(builderHandler, libHost, { + builderName: options.name, + optionSchema, + }); + + describe((options.name || builderHandler.name) + ` (Suite: ${counter++})`, () => { + beforeEach(async () => { + harness.resetProjectMetadata(); + harness.useProject('lib', { + root: 'projects/lib', + sourceRoot: 'projects/lib/src', + }); + harness.useTarget('build', BASE_OPTIONS); + + await libHost.initialize().toPromise(); + await harness.modifyFile('projects/lib/package.json', (content) => { + const pkg = JSON.parse(content); + pkg.exports = { + '.': './src/public-api.ts', + }; + + return JSON.stringify(pkg, null, 2); + }); + }); + + afterEach(() => libHost.restore().toPromise()); + + specDefinitions(harness); + }); +} diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts new file mode 100644 index 000000000000..273f7066cb99 --- /dev/null +++ b/packages/angular/build/src/builders/library/types.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { AngularCompilation } from '../../tools/angular/compilation'; +import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory'; +import type { NormalizedAssetsPattern } from '../../utils'; +import type { NormalizedCachedOptions } from '../../utils/normalize-cache'; +import type { PostcssConfiguration } from '../../utils/postcss-configuration'; + +/** + * Normalized representation of an entry point in the library. + */ +export interface NormalizedEntryPoint { + /** The subpath in package.json exports (e.g. '.' or './testing'). */ + subpath: string; + + /** Subpath name without leading './' (e.g. '.' or 'testing'). */ + name: string; + + /** Display name of the entry point (e.g. '@my/lib' or '@my/lib/testing'). */ + displayName: string; + + /** Base name of the output bundle (e.g. 'my-lib' or 'my-lib-testing'). */ + bundleName: string; + + /** Absolute POSIX path to the entry file. */ + entryFilePath: string; + + /** Is this the primary entry point ('.')? */ + isPrimary: boolean; +} + +/** + * Partial package.json data structure for library packaging. + */ +export interface PackageJsonData { + name: string; + version?: string; + type?: string; + main?: string; + module?: string; + typings?: string; + types?: string; + sideEffects?: boolean | string[]; + exports?: string | unknown[] | Record; + scripts?: Record; + workspaces?: unknown; + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + [key: string]: unknown; +} + +/** + * Normalized options for the library builder. + * All file and directory paths are normalized to POSIX format. + */ +export interface NormalizedLibraryOptions { + /** Absolute POSIX path to the workspace root directory. */ + workspaceRoot: string; + + /** Absolute POSIX path to the library project root directory. */ + projectRoot: string; + + /** Name of the npm package from the library's `package.json`. */ + packageName: string; + + /** Parsed contents of the library's `package.json`. */ + packageJson: PackageJsonData; + + /** Absolute POSIX path to the build output directory. */ + outputPath: string; + + /** Whether to delete the output directory before building. */ + deleteOutputPath: boolean; + + /** Absolute POSIX path to the library's `package.json`. */ + packageJsonPath: string; + + /** Absolute POSIX path to the TypeScript configuration file. */ + tsConfigPath: string; + + /** Map of normalized entry points keyed by entry point name. */ + entryPoints: Map; + + /** Default stylesheet language for inline component styles. */ + inlineStyleLanguage: 'css' | 'less' | 'sass' | 'scss'; + + /** Absolute POSIX paths to style preprocessor include directories. */ + styleIncludePaths: string[]; + + /** Optional Sass preprocessor configuration. */ + sass?: StylesheetPluginsass; + + /** Normalized asset patterns with POSIX `input` and `output` paths. */ + assets: NormalizedAssetsPattern[]; + + /** Angular compilation mode (`'partial'` or `'full'`). */ + compilationMode?: 'partial' | 'full'; + + /** Whether to generate source maps for TypeScript declaration files. */ + declarationMap?: boolean; + + /** Regular expressions matching allowed non-peer dependencies. */ + allowedNonPeerDependencies: RegExp[]; + + /** Whether to preserve lifecycle scripts in the emitted `package.json`. */ + keepLifecycleScripts: boolean; + + /** Whether to run the build in watch mode. */ + watch: boolean; + + /** Optional file watching polling interval in milliseconds. */ + poll?: number; + + /** Whether to preserve symbolic links during resolution and bundling. */ + preserveSymlinks: boolean; + + /** Whether to display build progress in the terminal. */ + progress: boolean; + + /** Whether to clear the terminal screen before watch rebuilds. */ + clearScreen?: boolean; + + /** Normalized build cache configuration. */ + cacheOptions: NormalizedCachedOptions; + + /** Resolved PostCSS configuration, if present in the workspace. */ + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + + /** Resolved Tailwind CSS configuration, if applicable. */ + tailwindConfiguration?: { file: string; package: string }; + + /** Whether terminal color output is enabled. */ + colors: boolean; +} + +/** + * Cached module ID and output file sets for a bundled entry point. + */ +export interface BundleResult { + /** Exact set of virtual POSIX ESM module IDs bundled into this entry point. */ + esmModuleIds: ReadonlySet; + + /** Exact set of virtual POSIX DTS module IDs bundled into this entry point. */ + dtsModuleIds: ReadonlySet; + + /** Relative POSIX output paths of ESM bundle files and sourcemaps emitted for this entry point. */ + esmOutputFiles: ReadonlySet; + + /** Relative POSIX output paths of DTS bundle files and sourcemaps emitted for this entry point. */ + dtsOutputFiles: ReadonlySet; + + /** Whether this entry point bundle imports 'tslib'. */ + hasTslibImport: boolean; +} + +/** + * Cached state for the single unified library compilation. + */ +export interface SingleProgramCache { + /** The active Angular compilation instance. */ + readonly compilationInstance: AngularCompilation; + + /** In-memory map of emitted JavaScript files keyed by POSIX path. */ + readonly esmFiles: Map; + + /** In-memory map of emitted TypeScript declaration files keyed by POSIX path. */ + readonly dtsFiles: Map; + + /** Set of POSIX file paths that failed during stylesheet bundling. */ + readonly failedFiles: ReadonlySet; +} + +/** + * Output of the unified library compilation step. + */ +export interface LibraryCompilationOutput { + /** Read-only map of emitted JavaScript files keyed by POSIX path. */ + readonly esmFiles: ReadonlyMap; + + /** Read-only map of emitted TypeScript declaration files keyed by POSIX path. */ + readonly dtsFiles: ReadonlyMap; + + /** Set of POSIX JavaScript file paths that were added, modified, or deleted in this iteration. */ + readonly changedEsmFiles: ReadonlySet; + + /** Set of POSIX declaration file paths that were added, modified, or deleted in this iteration. */ + readonly changedDtsFiles: ReadonlySet; + + /** Set of all POSIX source and dependency file paths referenced during compilation. */ + readonly referencedFiles: ReadonlySet; + + /** Cached compilation state for subsequent incremental builds. */ + readonly cache: SingleProgramCache; + + /** Whether any component stylesheet bundling errors occurred. */ + readonly hasStylesheetErrors: boolean; + + /** Promise that resolves when compilation diagnostics have been formatted and recorded. */ + readonly diagnosePromise: Promise; + + /** Effective declarationMap setting resolved from builder options and tsconfig. */ + readonly declarationMap: boolean; + + /** Effective compilationMode setting resolved from builder options and tsconfig. */ + readonly compilationMode: 'partial' | 'full'; +} diff --git a/packages/angular/build/src/builders/ng-packagr/builder.ts b/packages/angular/build/src/builders/ng-packagr/builder.ts new file mode 100644 index 000000000000..9e16e5fe54fb --- /dev/null +++ b/packages/angular/build/src/builders/ng-packagr/builder.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { NgPackagrOptions } from 'ng-packagr'; +import { join, resolve } from 'node:path'; +import { assertIsError } from '../../utils/error'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { purgeStaleBuildCache } from '../../utils/purge-cache'; +import type { Schema as NgPackagrBuilderOptions } from './schema'; + +/** + * A Builder that executes the `ng-packagr` tool to build an Angular library. + * + * @param options The builder options as defined by the JSON schema. + * @param context A BuilderContext instance. + * @returns A BuilderOutput object. + * + * @experimental Direct usage of this function is considered experimental. + */ +export async function* execute( + options: NgPackagrBuilderOptions, + context: BuilderContext, +): AsyncIterableIterator { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + const workspaceRoot = context.workspaceRoot; + let packager; + try { + packager = (await import('ng-packagr')).ngPackagr(); + } catch (error) { + assertIsError(error); + if (error.code === 'MODULE_NOT_FOUND') { + return { + success: false, + error: + 'The "ng-packagr" package was not found. To correct this error, ensure this package is installed in the project.', + }; + } + + throw error; + } + + const projectName = context.target?.project; + if (!projectName) { + throw new Error('The builder requires a target.'); + } + + const metadata = await context.getProjectMetadata(projectName); + const ngPackagrConfig = options.project + ? join(workspaceRoot, options.project) + : join(workspaceRoot, (metadata.root as string | undefined) ?? '', 'ng-package.json'); + + packager.forProject(ngPackagrConfig); + + if (options.tsConfig) { + packager.withTsConfig(resolve(workspaceRoot, options.tsConfig)); + } + + const { enabled: cacheEnabled, path: cacheDirectory } = normalizeCacheOptions( + metadata, + context.workspaceRoot, + projectName, + context.builder.builderName, + ); + + const ngPackagrOptions: NgPackagrOptions = { + cacheEnabled, + poll: options.poll, + cacheDirectory: cacheDirectory, + }; + + try { + if (options.watch) { + await packager.watch(ngPackagrOptions).toPromise(); + } else { + await packager.build(ngPackagrOptions); + } + + yield { success: true }; + } catch (error) { + assertIsError(error); + + yield { success: false, error: error.message }; + } +} diff --git a/packages/angular/build/src/builders/ng-packagr/index.ts b/packages/angular/build/src/builders/ng-packagr/index.ts new file mode 100644 index 000000000000..94824ea5ecd8 --- /dev/null +++ b/packages/angular/build/src/builders/ng-packagr/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Builder, createBuilder } from '@angular-devkit/architect'; +import { execute } from './builder'; +import type { Schema as NgPackagrBuilderOptions } from './schema'; + +export { type NgPackagrBuilderOptions, execute }; + +const builder: Builder = createBuilder(execute); + +export default builder; diff --git a/packages/angular/build/src/builders/ng-packagr/schema.json b/packages/angular/build/src/builders/ng-packagr/schema.json new file mode 100644 index 000000000000..0aa80ed6fe5f --- /dev/null +++ b/packages/angular/build/src/builders/ng-packagr/schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "ng-packagr Target", + "description": "ng-packagr target options for Build Architect. Use to build library projects.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The file path for the ng-packagr configuration file, relative to the current workspace." + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "watch": { + "type": "boolean", + "description": "Run build when files change.", + "default": false + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + } + }, + "additionalProperties": false +} diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts new file mode 100644 index 000000000000..bfb4cae0dcdf --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -0,0 +1,423 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + type BuilderContext, + type BuilderOutput, + targetStringFromTarget, +} from '@angular-devkit/architect'; +import assert from 'node:assert'; +import { readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin'; +import { assertIsError } from '../../utils/error'; +import { writeTestFiles } from '../../utils/test-files'; +import { buildApplicationInternal } from '../application'; +import type { + ApplicationBuilderExtensions, + ApplicationBuilderInternalOptions, +} from '../application/options'; +import { ResultKind } from '../application/results'; +import { normalizeOptions } from './options'; +import type { TestRunner } from './runners/api'; +import { MissingDependenciesError } from './runners/dependency-checker'; +import type { Schema as UnitTestBuilderOptions } from './schema'; +import { findTests } from './test-discovery'; + +export type { UnitTestBuilderOptions }; + +async function loadTestRunner(runnerName: string): Promise { + // Harden against directory traversal + if (!/^[a-zA-Z0-9-]+$/.test(runnerName)) { + throw new Error( + `Invalid runner name "${runnerName}". Runner names can only contain alphanumeric characters and hyphens.`, + ); + } + + let runnerModule; + try { + runnerModule = await import(`./runners/${runnerName}/index`); + } catch (e) { + assertIsError(e); + if (e.code === 'ERR_MODULE_NOT_FOUND') { + throw new Error(`Unknown test runner "${runnerName}".`, { cause: e }); + } + throw new Error( + `Failed to load the '${runnerName}' test runner. The package may be corrupted or improperly installed.`, + { cause: e }, + ); + } + + const runner = runnerModule.default; + if ( + !runner || + typeof runner.getBuildOptions !== 'function' || + typeof runner.createExecutor !== 'function' + ) { + throw new Error( + `The loaded test runner '${runnerName}' does not appear to be a valid TestRunner implementation.`, + ); + } + + return runner; +} + +function prepareBuildExtensions( + virtualFiles: Record | undefined, + projectSourceRoot: string, + extensions?: ApplicationBuilderExtensions, +): ApplicationBuilderExtensions | undefined { + if (!virtualFiles) { + return extensions; + } + + extensions ??= {}; + extensions.codePlugins ??= []; + for (const [namespace, contents] of Object.entries(virtualFiles)) { + extensions.codePlugins.push( + createVirtualModulePlugin({ + namespace, + loadContent: () => { + return { + contents, + loader: 'js', + resolveDir: projectSourceRoot, + }; + }, + }), + ); + } + + return extensions; +} + +async function* runBuildAndTest( + executor: import('./runners/api').TestExecutor, + applicationBuildOptions: ApplicationBuilderInternalOptions, + context: BuilderContext, + dumpDirectory: string | undefined, + extensions: ApplicationBuilderExtensions | undefined, +): AsyncIterable { + let consecutiveErrorCount = 0; + for await (const buildResult of buildApplicationInternal( + applicationBuildOptions, + context, + extensions, + )) { + if (buildResult.kind === ResultKind.Failure) { + yield { success: false }; + continue; + } else if ( + buildResult.kind !== ResultKind.Full && + buildResult.kind !== ResultKind.Incremental + ) { + assert.fail( + 'A full and/or incremental build result is required from the application builder.', + ); + } + + assert(buildResult.files, 'Builder did not provide result files.'); + + if (dumpDirectory) { + if (buildResult.kind === ResultKind.Full) { + // Full build, so clean the directory + await rm(dumpDirectory, { recursive: true, force: true }); + } else { + // Incremental build, so delete removed files + for (const file of buildResult.removed) { + await rm(path.join(dumpDirectory, file.path), { force: true }); + } + } + await writeTestFiles(buildResult.files, dumpDirectory); + context.logger.info(`Build output files successfully dumped to '${dumpDirectory}'.`); + } + + // Pass the build artifacts to the executor + try { + yield* executor.execute(buildResult); + + // Successful execution resets the failure counter + consecutiveErrorCount = 0; + } catch (e) { + assertIsError(e); + context.logger.error(`An exception occurred during test execution:\n${e.stack ?? e.message}`); + if (e instanceof AggregateError) { + e.errors.forEach((inner) => { + assertIsError(inner); + context.logger.error(inner.stack ?? inner.message); + }); + } + + yield { success: false }; + consecutiveErrorCount++; + } + + if (consecutiveErrorCount >= 3) { + context.logger.error( + 'Test runner process has failed multiple times in a row. Please fix the configuration and restart the process.', + ); + + return; + } + } +} + +/** + * @experimental Direct usage of this function is considered experimental. + */ +export async function* execute( + options: UnitTestBuilderOptions, + context: BuilderContext, + extensions?: ApplicationBuilderExtensions, +): AsyncIterable { + // Determine project name from builder context target + const projectName = context.target?.project; + if (!projectName) { + context.logger.error(`The builder requires a target to be specified.`); + + return; + } + + // Initialize the test runner and normalize options + let runner; + let normalizedOptions; + try { + normalizedOptions = await normalizeOptions(context, projectName, options); + runner = await loadTestRunner(normalizedOptions.runnerName); + await runner.validateDependencies?.(normalizedOptions); + } catch (e) { + assertIsError(e); + if (e instanceof MissingDependenciesError) { + context.logger.error(e.message); + } else { + context.logger.error( + `An exception occurred during initialization of the test runner:\n${e.stack ?? e.message}`, + ); + } + yield { success: false }; + + return; + } + + if (normalizedOptions.listTests) { + const testFiles = await findTests( + normalizedOptions.include, + normalizedOptions.exclude ?? [], + normalizedOptions.workspaceRoot, + normalizedOptions.projectSourceRoot, + ); + + context.logger.info('Discovered test files:'); + for (const file of testFiles) { + context.logger.info(` ${path.relative(normalizedOptions.workspaceRoot, file)}`); + } + + yield { success: true }; + + return; + } + + if (runner.isStandalone) { + try { + await using executor = await runner.createExecutor(context, normalizedOptions, undefined); + yield* executor.execute({ + kind: ResultKind.Full, + files: [], + }); + } catch (e) { + assertIsError(e); + context.logger.error( + `An exception occurred during standalone test execution:\n${e.stack ?? e.message}`, + ); + yield { success: false }; + } + + return; + } + + // Get base build options from the buildTarget + let buildTargetOptions: ApplicationBuilderInternalOptions; + try { + const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget); + if (builderName === '@angular/build:application') { + buildTargetOptions = (await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + builderName, + )) as unknown as ApplicationBuilderInternalOptions; + } else if (builderName === '@angular/build:library') { + const libraryOptions = (await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + builderName, + )) as Record; + + buildTargetOptions = transformLibraryOptions(libraryOptions); + } else if (builderName === '@angular/build:ng-packagr') { + const ngPackagrOptions = await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + builderName, + ); + + buildTargetOptions = await transformNgPackagrOptions( + context, + ngPackagrOptions, + normalizedOptions.projectRoot, + ); + } else { + context.logger.warn( + `The 'buildTarget' is configured to use '${builderName}', which is not supported. ` + + `The 'unit-test' builder is designed to work with '@angular/build:application', ` + + `'@angular/build:library', or '@angular/build:ng-packagr'. ` + + 'Unexpected behavior or build failures may occur.', + ); + + buildTargetOptions = (await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + builderName, + )) as unknown as ApplicationBuilderInternalOptions; + } + } catch (e) { + assertIsError(e); + context.logger.error( + `Could not load build target options for "${targetStringFromTarget( + normalizedOptions.buildTarget, + )}".\n` + + `Please check your 'angular.json' configuration.\n` + + `Error: ${e.message}`, + ); + yield { success: false }; + + return; + } + + // Get runner-specific build options + let runnerBuildOptions; + let virtualFiles; + let testEntryPointMappings; + try { + ({ + buildOptions: runnerBuildOptions, + virtualFiles, + testEntryPointMappings, + } = await runner.getBuildOptions(normalizedOptions, buildTargetOptions, context.logger)); + } catch (e) { + assertIsError(e); + context.logger.error( + `An exception occurred while getting runner-specific build options:\n${e.stack ?? e.message}`, + ); + yield { success: false }; + + return; + } + + try { + await using executor = await runner.createExecutor( + context, + normalizedOptions, + testEntryPointMappings, + ); + + const finalExtensions = prepareBuildExtensions( + virtualFiles, + normalizedOptions.projectSourceRoot, + extensions, + ); + + // Prepare and run the application build + const applicationBuildOptions = { + ...buildTargetOptions, + ...runnerBuildOptions, + polyfills: runnerBuildOptions.polyfills ?? normalizedOptions.polyfills, + watch: normalizedOptions.watch, + progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress, + quiet: normalizedOptions.quiet, + ...(normalizedOptions.tsConfig ? { tsConfig: normalizedOptions.tsConfig } : {}), + preserveSymlinks: normalizedOptions.preserveSymlinks, + } satisfies ApplicationBuilderInternalOptions; + + const dumpDirectory = normalizedOptions.dumpVirtualFiles + ? path.join(normalizedOptions.cacheOptions.path, 'output-files') + : undefined; + + yield* runBuildAndTest( + executor, + applicationBuildOptions, + context, + dumpDirectory, + finalExtensions, + ); + } catch (e) { + assertIsError(e); + context.logger.error( + `An exception occurred while creating the test executor:\n${e.stack ?? e.message}`, + ); + yield { success: false }; + } +} + +async function transformNgPackagrOptions( + context: BuilderContext, + options: Record, + projectRoot: string, +): Promise { + const projectPath = options['project']; + + let ngPackagePath: string; + if (projectPath) { + if (typeof projectPath !== 'string') { + throw new Error('ng-packagr builder options "project" property must be a string.'); + } + ngPackagePath = path.join(context.workspaceRoot, projectPath); + } else { + ngPackagePath = path.join(projectRoot, 'ng-package.json'); + } + + let ngPackageJson; + try { + ngPackageJson = JSON.parse(await readFile(ngPackagePath, 'utf-8')); + } catch (e) { + assertIsError(e); + throw new Error(`Could not read ng-package.json at ${ngPackagePath}`, { + cause: e, + }); + } + + const { lib: { styleIncludePaths = [] } = {}, assets = [], inlineStyleLanguage } = ngPackageJson; + + const includePaths = styleIncludePaths.map((includePath: string) => + path.resolve(path.dirname(ngPackagePath), includePath), + ); + + return { + stylePreprocessorOptions: includePaths.length ? { includePaths } : undefined, + assets: assets.length ? assets : undefined, + inlineStyleLanguage, + } as ApplicationBuilderInternalOptions; +} + +/** + * Transforms library builder options into internal application builder options for testing. + * + * @param options The raw validated options from the library build target. + * @returns Application builder options suitable for running tests. + */ +function transformLibraryOptions( + options: Record, +): ApplicationBuilderInternalOptions { + const { stylePreprocessorOptions, assets, inlineStyleLanguage, preserveSymlinks, tsConfig } = + options; + + return { + stylePreprocessorOptions: + stylePreprocessorOptions as ApplicationBuilderInternalOptions['stylePreprocessorOptions'], + assets: Array.isArray(assets) && assets.length ? assets : undefined, + inlineStyleLanguage: + inlineStyleLanguage as ApplicationBuilderInternalOptions['inlineStyleLanguage'], + preserveSymlinks: typeof preserveSymlinks === 'boolean' ? preserveSymlinks : undefined, + tsConfig: typeof tsConfig === 'string' ? tsConfig : undefined, + } as ApplicationBuilderInternalOptions; +} diff --git a/packages/angular/build/src/builders/unit-test/index.ts b/packages/angular/build/src/builders/unit-test/index.ts new file mode 100644 index 000000000000..47666cdc1067 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/index.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { type Builder, createBuilder } from '@angular-devkit/architect'; +import { type UnitTestBuilderOptions, execute } from './builder'; + +export { type UnitTestBuilderOptions, execute }; + +const builder: Builder = createBuilder(execute); + +export default builder; diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts new file mode 100644 index 000000000000..f841670c072f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { type BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import { constants, promises as fs } from 'node:fs'; +import path from 'node:path'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { canonicalizePath, toPosixPath } from '../../utils/path'; +import { getProjectRootPaths } from '../../utils/project-metadata'; +import { isTTY } from '../../utils/tty'; +import { Runner, type Schema as UnitTestBuilderOptions } from './schema'; + +export type NormalizedUnitTestBuilderOptions = Awaited>; + +async function exists(path: string): Promise { + try { + await fs.access(path, constants.F_OK); + + return true; + } catch { + return false; + } +} + +function normalizeReporterOption( + reporters: unknown[] | undefined, +): [string, Record][] | undefined { + return reporters?.map((entry) => + typeof entry === 'string' + ? ([entry, {}] as [string, Record]) + : (entry as [string, Record]), + ); +} + +export async function normalizeOptions( + context: BuilderContext, + projectName: string, + options: UnitTestBuilderOptions, +) { + // Target specifier defaults to the current project's build target using a development configuration + const buildTargetSpecifier = options.buildTarget ?? `::development`; + const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); + + // Load target options to determine preserveSymlinks setting + let buildTargetOptions: Record | undefined; + try { + buildTargetOptions = await context.getTargetOptions(buildTarget); + } catch {} + + const preserveSymlinks = + typeof buildTargetOptions?.preserveSymlinks === 'boolean' + ? buildTargetOptions.preserveSymlinks + : process.execArgv.includes('--preserve-symlinks'); + + // Setup base paths based on workspace root and project information + const workspaceRoot = canonicalizePath(context.workspaceRoot); + + const projectMetadata = await context.getProjectMetadata(projectName); + const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); + + // Gather persistent caching option and provide a project specific cache location + const cacheOptions = normalizeCacheOptions( + projectMetadata, + workspaceRoot, + projectName, + context.builder.builderName, + ); + + const { + runner, + browsers, + progress, + filter, + browserViewport, + ui, + runnerConfig, + isolate, + splitting = true, + polyfills, + } = options; + + if (ui && runner !== Runner.Vitest) { + throw new Error('The "ui" option is only available for the "vitest" runner.'); + } + + if (isolate && runner !== Runner.Vitest) { + throw new Error('The "isolate" option is only available for the "vitest" runner.'); + } + + const [width, height] = browserViewport?.split('x').map(Number) ?? []; + + let tsConfig = options.tsConfig; + if (tsConfig) { + const fullTsConfigPath = path.join(workspaceRoot, tsConfig); + if (!(await exists(fullTsConfigPath))) { + throw new Error(`The specified tsConfig file '${tsConfig}' does not exist.`); + } + } else { + const tsconfigSpecPath = path.join(projectRoot, 'tsconfig.spec.json'); + if (await exists(tsconfigSpecPath)) { + // The application builder expects a path relative to the workspace root. + tsConfig = path.relative(workspaceRoot, tsconfigSpecPath); + } + } + + let watch = options.watch ?? isTTY(); + if (options.ui && options.watch === false) { + context.logger.warn( + `The '--ui' option requires watch mode. The '--no-watch' flag will be ignored.`, + ); + watch = true; + } + + return { + // Project/workspace information + workspaceRoot, + projectRoot, + projectSourceRoot, + cacheOptions, + // Target/configuration specified options + buildTarget, + include: options.include ?? ['**/*.spec.ts'], + exclude: options.exclude, + filter, + runnerName: runner ?? Runner.Vitest, + headless: options.headless, + coverage: { + enabled: options.coverage, + exclude: options.coverageExclude, + include: options.coverageInclude, + reporters: normalizeReporterOption(options.coverageReporters), + thresholds: options.coverageThresholds, + // The schema generation tool doesn't support tuple types for items, but the schema validation + // does ensure that the array has exactly two numbers. + watermarks: options.coverageWatermarks as { + statements?: [number, number]; + branches?: [number, number]; + functions?: [number, number]; + lines?: [number, number]; + }, + }, + tsConfig, + buildProgress: progress, + reporters: normalizeReporterOption(options.reporters), + outputFile: options.outputFile, + browsers: browsers?.length ? browsers : undefined, + browserViewport: width && height ? { width, height } : undefined, + watch, + debug: options.debug ?? false, + ui: process.env['CI'] ? false : ui, + isolate, + splitting, + quiet: options.quiet ?? (process.env['CI'] ? false : true), + providersFile: options.providersFile && path.join(workspaceRoot, options.providersFile), + setupFiles: options.setupFiles + ? options.setupFiles.map((setupFile) => toPosixPath(path.join(workspaceRoot, setupFile))) + : [], + dumpVirtualFiles: options.dumpVirtualFiles, + listTests: options.listTests, + preserveSymlinks, + polyfills, + runnerConfig: + typeof runnerConfig === 'string' + ? runnerConfig.length === 0 + ? true + : path.resolve(workspaceRoot, runnerConfig) + : runnerConfig, + }; +} + +export function injectTestingPolyfills(polyfills: string[] = []): string[] { + return polyfills.includes('zone.js') ? [...polyfills, 'zone.js/testing'] : polyfills; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/api.ts b/packages/angular/build/src/builders/unit-test/runners/api.ts new file mode 100644 index 000000000000..4cd455472bfa --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/api.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { ApplicationBuilderInternalOptions } from '../../application/options'; +import type { FullResult, IncrementalResult } from '../../application/results'; +import type { NormalizedUnitTestBuilderOptions } from '../options'; + +/** + * Represents the options for a test runner. + */ +export interface RunnerOptions { + /** + * Partial options for the application builder. + * These will be merged with the options from the build target. + */ + buildOptions: Partial; + + /** + * A record of virtual files to be added to the build. + * The key is the file path and the value is the file content. + */ + virtualFiles?: Record; + + /** + * A map of test entry points to their corresponding test files. + * This is used to avoid re-discovering the test files in the executor. + */ + testEntryPointMappings?: Map; +} + +/** + * Represents a stateful test execution session. + * An instance of this is created for each `ng test` command. + */ +export interface TestExecutor { + /** + * Executes tests using the artifacts from a specific build. + * This method can be called multiple times in watch mode. + * + * @param buildResult The output from the application builder. + * @returns An async iterable builder output stream. + */ + execute(buildResult: FullResult | IncrementalResult): AsyncIterable; + + [Symbol.asyncDispose](): Promise; +} + +/** + * Represents the metadata and hooks for a specific test runner. + */ +export interface TestRunner { + readonly name: string; + readonly isStandalone?: boolean; + + validateDependencies?(options: NormalizedUnitTestBuilderOptions): void | Promise; + + getBuildOptions( + options: NormalizedUnitTestBuilderOptions, + baseBuildOptions: Partial, + logger: BuilderContext['logger'], + ): RunnerOptions | Promise; + + /** + * Creates a stateful executor for a test session. + * This is called once at the start of the `ng test` command. + * + * @param context The Architect builder context. + * @param options The normalized unit test options. + * @param testEntryPointMappings A map of test entry points to their corresponding test files. + * @returns A TestExecutor instance that will handle the test runs. + */ + createExecutor( + context: BuilderContext, + options: NormalizedUnitTestBuilderOptions, + testEntryPointMappings: Map | undefined, + ): Promise; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts b/packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts new file mode 100644 index 000000000000..ed774a55476a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createProjectResolver } from '../../../utils/resolve-project'; + +/** + * A custom error class to represent missing dependency errors. + * This is used to avoid printing a stack trace for this expected error. + */ +export class MissingDependenciesError extends Error { + constructor(message: string) { + super(message); + this.name = 'MissingDependenciesError'; + } +} + +type Resolver = (packageName: string) => string; + +export class DependencyChecker { + private readonly resolver: Resolver; + private readonly missingDependencies = new Set(); + + constructor(projectSourceRoot: string) { + this.resolver = createProjectResolver(projectSourceRoot); + } + + /** + * Checks if a package is installed. + * @param packageName The name of the package to check. + * @returns True if the package is found, false otherwise. + */ + private isInstalled(packageName: string): boolean { + try { + this.resolver(packageName); + + return true; + } catch { + return false; + } + } + + /** + * Verifies that a package is installed and adds it to a list of missing + * dependencies if it is not. + * @param packageName The name of the package to check. + */ + check(packageName: string): void { + if (!this.isInstalled(packageName)) { + this.missingDependencies.add(packageName); + } + } + + /** + * Verifies that at least one of a list of packages is installed. If none are + * installed, a custom error message is added to the list of errors. + * @param packageNames An array of package names to check. + * @param customErrorMessage The error message to use if none of the packages are found. + */ + checkAny(packageNames: string[], customErrorMessage: string): void { + if (packageNames.every((name) => !this.isInstalled(name))) { + // This is a custom error, so we add it directly. + // Using a Set avoids duplicate custom messages. + this.missingDependencies.add(customErrorMessage); + } + } + + /** + * Throws a `MissingDependenciesError` if any dependencies were found to be missing. + * The error message is a formatted list of all missing packages. + */ + report(): void { + if (this.missingDependencies.size === 0) { + return; + } + + let message = 'The following packages are required but were not found:\n'; + for (const name of this.missingDependencies) { + message += ` - ${name}\n`; + } + message += 'Please install the missing packages and rerun the test command.'; + + throw new MissingDependenciesError(message); + } +} diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts new file mode 100644 index 000000000000..4b06e6f7b81a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { ApplicationBuilderInternalOptions } from '../../../application/options'; +import type { KarmaBuilderOptions, KarmaBuilderTransformsOptions } from '../../../karma'; +import { type NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; +import type { TestExecutor } from '../api'; + +export class KarmaExecutor implements TestExecutor { + constructor( + private context: BuilderContext, + private options: NormalizedUnitTestBuilderOptions, + ) {} + + async *execute(): AsyncIterable { + const { context, options: unitTestOptions } = this; + + if (unitTestOptions.browserViewport) { + context.logger.warn( + 'The "karma" test runner does not support the "browserViewport" option. The option will be ignored.', + ); + } + + if (unitTestOptions.debug) { + context.logger.warn( + 'The "karma" test runner does not support the "debug" option. The option will be ignored.', + ); + } + + if (unitTestOptions.setupFiles.length) { + context.logger.warn( + 'The "karma" test runner does not support the "setupFiles" option. The option will be ignored.', + ); + } + + if (unitTestOptions.coverage?.include) { + context.logger.warn( + 'The "karma" test runner does not support the "coverageInclude" option. The option will be ignored.', + ); + } + + if (unitTestOptions.headless !== undefined) { + context.logger.warn( + 'The "karma" test runner does not support the "headless" option. The option will be ignored.', + ); + } + + const buildTargetOptions = (await context.validateOptions( + await context.getTargetOptions(unitTestOptions.buildTarget), + await context.getBuilderNameForTarget(unitTestOptions.buildTarget), + )) as unknown as ApplicationBuilderInternalOptions; + + let karmaConfig: string | undefined; + if (typeof unitTestOptions.runnerConfig === 'string') { + karmaConfig = unitTestOptions.runnerConfig; + context.logger.info(`Using Karma configuration file: ${karmaConfig}`); + } else if (unitTestOptions.runnerConfig) { + const potentialPath = path.join(unitTestOptions.projectRoot, 'karma.conf.js'); + try { + await fs.access(potentialPath); + karmaConfig = potentialPath; + context.logger.info(`Using Karma configuration file: ${karmaConfig}`); + } catch { + context.logger.info('No Karma configuration file found. Using default configuration.'); + } + } + + const karmaOptions: KarmaBuilderOptions = { + karmaConfig, + tsConfig: unitTestOptions.tsConfig ?? buildTargetOptions.tsConfig, + polyfills: injectTestingPolyfills(unitTestOptions.polyfills ?? buildTargetOptions.polyfills), + assets: buildTargetOptions.assets, + scripts: buildTargetOptions.scripts, + styles: buildTargetOptions.styles, + inlineStyleLanguage: buildTargetOptions.inlineStyleLanguage, + stylePreprocessorOptions: buildTargetOptions.stylePreprocessorOptions, + externalDependencies: buildTargetOptions.externalDependencies, + loader: buildTargetOptions.loader, + define: buildTargetOptions.define, + include: unitTestOptions.include, + exclude: unitTestOptions.exclude, + sourceMap: buildTargetOptions.sourceMap, + progress: unitTestOptions.buildProgress ?? buildTargetOptions.progress, + watch: unitTestOptions.watch, + poll: buildTargetOptions.poll, + preserveSymlinks: unitTestOptions.preserveSymlinks ?? buildTargetOptions.preserveSymlinks, + browsers: unitTestOptions.browsers?.join(','), + codeCoverage: unitTestOptions.coverage.enabled, + codeCoverageExclude: unitTestOptions.coverage.exclude, + fileReplacements: buildTargetOptions.fileReplacements, + reporters: unitTestOptions.reporters?.map((reporter) => { + // Karma only supports string reporters. + if (Object.keys(reporter[1]).length > 0) { + context.logger.warn( + `The "karma" test runner does not support options for the "${reporter[0]}" reporter. The options will be ignored.`, + ); + } + + return reporter[0]; + }), + webWorkerTsConfig: buildTargetOptions.webWorkerTsConfig, + aot: buildTargetOptions.aot, + }; + + const transformOptions = { + karmaOptions: (options) => { + if (unitTestOptions.filter) { + let filter = unitTestOptions.filter; + if (filter[0] === '/' && filter.at(-1) === '/') { + this.context.logger.warn( + 'The `--filter` option is always a regular expression. ' + + 'Leading and trailing `/` are not required and will be ignored.', + ); + } else { + filter = `/${filter}/`; + } + + options.client ??= {}; + options.client.args ??= []; + options.client.args.push('--grep', filter); + } + + // Add coverage options + if (unitTestOptions.coverage.enabled) { + const { thresholds, watermarks } = unitTestOptions.coverage; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const coverageReporter = ((options as any).coverageReporter ??= {}); + + if (thresholds) { + coverageReporter.check = thresholds.perFile + ? { each: thresholds } + : { global: thresholds }; + } + + if (watermarks) { + coverageReporter.watermarks = watermarks; + } + } + + return options; + }, + } satisfies KarmaBuilderTransformsOptions; + + const { execute } = await import('../../../karma'); + + yield* execute(karmaOptions, context, transformOptions); + } + + async [Symbol.asyncDispose](): Promise { + // The Karma builder handles its own teardown + } +} diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/index.ts b/packages/angular/build/src/builders/unit-test/runners/karma/index.ts new file mode 100644 index 000000000000..b4720e91f350 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/karma/index.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { TestRunner } from '../api'; +import { DependencyChecker } from '../dependency-checker'; +import { KarmaExecutor } from './executor'; + +/** + * A declarative definition of the Karma test runner. + */ +const KarmaTestRunner: TestRunner = { + name: 'karma', + isStandalone: true, + + validateDependencies(options) { + const checker = new DependencyChecker(options.projectSourceRoot); + checker.check('karma'); + checker.check('karma-jasmine'); + + // Check for browser launchers + if (options.browsers?.length) { + for (const browser of options.browsers) { + const launcherName = `karma-${browser.toLowerCase().split('headless')[0]}-launcher`; + checker.check(launcherName); + } + } + + if (options.coverage.enabled) { + checker.check('karma-coverage'); + } + + checker.report(); + }, + + getBuildOptions() { + return { + buildOptions: {}, + }; + }, + + async createExecutor(context, options) { + return new KarmaExecutor(context, options); + }, +}; + +export default KarmaTestRunner; diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/index_spec.ts b/packages/angular/build/src/builders/unit-test/runners/karma/index_spec.ts new file mode 100644 index 000000000000..8d291f03775e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/karma/index_spec.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { NormalizedUnitTestBuilderOptions } from '../../options'; +import KarmaTestRunner from './index'; + +describe('KarmaTestRunner', () => { + describe('validateDependencies', () => { + function createOptions(coverageEnabled: boolean): NormalizedUnitTestBuilderOptions { + return { + projectSourceRoot: '/non-existent', + coverage: { enabled: coverageEnabled }, + } as NormalizedUnitTestBuilderOptions; + } + + it('does not require karma-coverage when coverage is disabled', () => { + expect(() => KarmaTestRunner.validateDependencies?.(createOptions(false))).toThrowError( + /^(?!.*karma-coverage)/s, + ); + }); + + it('requires karma-coverage when coverage is enabled', () => { + expect(() => KarmaTestRunner.validateDependencies?.(createOptions(true))).toThrowError( + /karma-coverage/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts new file mode 100644 index 000000000000..25813e371707 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { + BrowserBuiltinProvider, + BrowserConfigOptions, + BrowserProviderOption, +} from 'vitest/node'; +import { assertIsError } from '../../../../utils/error'; +import { createProjectResolver } from '../../../../utils/resolve-project'; + +export interface BrowserConfiguration { + browser?: BrowserConfigOptions; + errors?: string[]; + messages?: string[]; +} + +function findBrowserProvider( + projectResolver: (packageName: string) => string, +): BrowserBuiltinProvider | undefined { + const requiresPreview = !!process.versions.webcontainer; + + // One of these must be installed in the project to use browser testing + const vitestBuiltinProviders = requiresPreview + ? (['preview'] as const) + : (['playwright', 'webdriverio', 'preview'] as const); + + for (const providerName of vitestBuiltinProviders) { + try { + projectResolver(`@vitest/browser-${providerName}`); + + return providerName; + } catch {} + } + + return undefined; +} + +export interface BrowserInstanceConfiguration { + browser: string; + headless: boolean; + provider?: BrowserProviderOption; +} + +export function normalizeBrowserName(browserName: string): BrowserInstanceConfiguration { + // Normalize browser names to match Vitest's expectations for headless but also supports karma's names + // e.g., 'ChromeHeadless' -> 'chrome', 'FirefoxHeadless' -> 'firefox' + // and 'Chrome' -> 'chrome', 'Firefox' -> 'firefox'. + const normalized = browserName.toLowerCase(); + const headless = normalized.endsWith('headless'); + + return { + browser: headless ? normalized.slice(0, -8) : normalized, + headless: headless, + }; +} + +/** + * Mutates the provided browser instances to apply standard headless execution + * constraints based on the chosen provider, user options, and CI environment presence. + * + * @param instances The normalized browser instances to mutate. + * @param providerName The identifier for the chosen Vitest browser provider. + * @param headless The user-provided headless configuration option. + * @param isCI Whether the current environment is running in CI. + * @returns An array of informational messages generated during evaluation. + */ +export function applyHeadlessConfiguration( + instances: BrowserInstanceConfiguration[], + providerName: BrowserBuiltinProvider | undefined, + headless: boolean | undefined, + isCI: boolean, +): string[] { + const messages: string[] = []; + + if (providerName === 'preview') { + instances.forEach((instance) => { + // Preview mode only supports headed execution + instance.headless = false; + }); + + if (headless) { + messages.push('The "headless" option is ignored when using the "preview" provider.'); + } + } else if (headless !== undefined) { + if (headless) { + const allHeadlessByDefault = isCI || instances.every((i) => i.headless); + if (allHeadlessByDefault) { + messages.push( + 'The "headless" option is unnecessary as all browsers are already configured to run in headless mode.', + ); + } + } + + instances.forEach((instance) => { + instance.headless = headless; + }); + } else if (isCI) { + instances.forEach((instance) => { + instance.headless = true; + }); + } + + return messages; +} + +/** + * Resolves and configures the Vitest browser provider for the unit test builder. + * Dynamically discovers and imports the necessary provider (Playwright, WebdriverIO, or Preview), + * maps the requested browser instances, and applies environment-specific execution logic. + * + * @param browsers An array of requested browser names (e.g., 'chrome', 'firefox'). + * @param headless User-provided configuration for headless execution. + * @param debug Whether the builder is running in watch or debug mode. + * @param projectSourceRoot The root directory of the project being tested for resolving installed packages. + * @param viewport Optional viewport dimensions to apply to the launched browser instances. + * @returns A fully resolved Vitest browser configuration object alongside any generated warning or error messages. + */ +export async function setupBrowserConfiguration( + browsers: string[] | undefined, + headless: boolean | undefined, + debug: boolean, + projectSourceRoot: string, + viewport: { width: number; height: number } | undefined, +): Promise { + if (browsers === undefined) { + if (headless !== undefined) { + return { + messages: ['The "headless" option is ignored when no browsers are configured.'], + }; + } + + return {}; + } + + const projectResolver = createProjectResolver(projectSourceRoot); + let errors: string[] | undefined; + + const providerName = findBrowserProvider(projectResolver); + if (!providerName) { + errors ??= []; + errors.push( + 'The "browsers" option requires either "@vitest/browser-playwright", "@vitest/browser-webdriverio", ' + + 'or "@vitest/browser-preview" to be installed within the project.' + + ' Please install one of these packages and rerun the test command.', + ); + } + + const instances = browsers.map(normalizeBrowserName); + + let provider: import('vitest/node').BrowserProviderOption | undefined; + if (providerName) { + const providerPackage = `@vitest/browser-${providerName}`; + try { + const providerModule = await import(projectResolver(providerPackage)); + + // Validate that the imported module has the expected structure + const providerFactory = providerModule[providerName]; + if (typeof providerFactory === 'function') { + if (providerName === 'playwright') { + const executablePath = process.env['CHROME_BIN']; + const baseOptions = { + contextOptions: { + // Enables `prefer-color-scheme` for Vitest browser instead of `light` + colorScheme: null, + }, + }; + + provider = providerFactory(baseOptions); + + if (executablePath) { + for (const instance of instances) { + if (instance.browser === 'chrome' || instance.browser === 'chromium') { + instance.provider = providerFactory({ + ...baseOptions, + launchOptions: { executablePath }, + }); + } + } + } + } else { + provider = providerFactory(); + } + } else { + errors ??= []; + errors.push( + `The "${providerPackage}" package does not have a valid browser provider export.`, + ); + } + } catch (e) { + assertIsError(e); + errors ??= []; + // Check for a module not found error to provide a more specific message + if (e.code === 'ERR_MODULE_NOT_FOUND') { + errors.push( + `The "browsers" option with "${providerName}" requires the "${providerPackage}" package.` + + ' Please install this package and rerun the test command.', + ); + } else { + // Handle other potential errors during import + errors.push( + `An error occurred while loading the "${providerPackage}" browser provider:\n ${e.message}`, + ); + } + } + } + + if (errors) { + return { errors }; + } + + const isCI = !!process.env['CI']; + const messages = applyHeadlessConfiguration(instances, providerName, headless, isCI); + + const browser = { + enabled: true, + provider, + ui: !isCI && instances.some((instance) => !instance.headless), + viewport, + instances, + } satisfies BrowserConfigOptions; + + return { browser, messages }; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.ts new file mode 100644 index 000000000000..0dd0778420bd --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider_spec.ts @@ -0,0 +1,320 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { applyHeadlessConfiguration, setupBrowserConfiguration } from './browser-provider'; + +describe('setupBrowserConfiguration', () => { + let workspaceRoot: string; + + beforeEach(async () => { + // Create a temporary workspace root + workspaceRoot = await mkdtemp(join(tmpdir(), 'angular-cli-test-')); + await writeFile(join(workspaceRoot, 'package.json'), '{}'); + + // Create a mock @vitest/browser-playwright package + const playwrightPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-playwright'); + await mkdir(playwrightPkgPath, { recursive: true }); + await writeFile( + join(playwrightPkgPath, 'package.json'), + JSON.stringify({ name: '@vitest/browser-playwright', main: 'index.js' }), + ); + await writeFile( + join(playwrightPkgPath, 'index.js'), + 'module.exports = { playwright: (options) => ({ name: "playwright", options }) };', + ); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it('should configure headless mode for specific browsers based on name', async () => { + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless', 'Firefox'], + undefined, + false, + workspaceRoot, + undefined, + ); + + expect(browser?.enabled).toBeTrue(); + expect(browser?.instances).toEqual([ + jasmine.objectContaining({ browser: 'chrome', headless: true }), + jasmine.objectContaining({ browser: 'firefox', headless: false }), + ]); + }); + + it('should force headless mode in CI environment', async () => { + const originalCI = process.env['CI']; + process.env['CI'] = 'true'; + + try { + const { browser } = await setupBrowserConfiguration( + ['Chrome', 'FirefoxHeadless'], + undefined, + false, + workspaceRoot, + undefined, + ); + + expect(browser?.instances).toEqual([ + jasmine.objectContaining({ browser: 'chrome', headless: true }), + jasmine.objectContaining({ browser: 'firefox', headless: true }), + ]); + } finally { + if (originalCI === undefined) { + delete process.env['CI']; + } else { + process.env['CI'] = originalCI; + } + } + }); + + it('should set ui property based on headless instances (local)', async () => { + // Local run (not CI) + const originalCI = process.env['CI']; + delete process.env['CI']; + + try { + // Case 1: All headless -> UI false + let result = await setupBrowserConfiguration( + ['ChromeHeadless'], + undefined, + false, + workspaceRoot, + undefined, + ); + expect(result.browser?.ui).toBeFalse(); + + // Case 2: Mixed -> UI true + result = await setupBrowserConfiguration( + ['ChromeHeadless', 'Firefox'], + undefined, + false, + workspaceRoot, + undefined, + ); + expect(result.browser?.ui).toBeTrue(); + } finally { + if (originalCI !== undefined) { + process.env['CI'] = originalCI; + } + } + }); + + it('should disable UI in CI even if headed browsers are requested', async () => { + const originalCI = process.env['CI']; + process.env['CI'] = 'true'; + + try { + const { browser } = await setupBrowserConfiguration( + ['Chrome'], + undefined, + false, + workspaceRoot, + undefined, + ); + + expect(browser?.ui).toBeFalse(); + // And verify instances are forced to headless + expect(browser?.instances?.[0].headless).toBeTrue(); + } finally { + if (originalCI === undefined) { + delete process.env['CI']; + } else { + process.env['CI'] = originalCI; + } + } + }); + + // See: https://github.com/angular/angular-cli/issues/32469 + it('should pass colorScheme=null to Playwright provider', async () => { + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless'], + undefined, + false, + workspaceRoot, + undefined, + ); + + expect(browser?.provider?.options).toEqual( + jasmine.objectContaining({ + contextOptions: jasmine.objectContaining({ + colorScheme: null, + }), + }), + ); + }); + + it('should support Preview provider forcing headless false', async () => { + // Create mock preview package + const previewPkgPath = join(workspaceRoot, 'node_modules/@vitest/browser-preview'); + await mkdir(previewPkgPath, { recursive: true }); + await writeFile( + join(previewPkgPath, 'package.json'), + JSON.stringify({ name: '@vitest/browser-preview', main: 'index.js' }), + ); + await writeFile( + join(previewPkgPath, 'index.js'), + 'module.exports = { preview: () => ({ name: "preview" }) };', + ); + + // Remove playwright mock for this test to force usage of preview + await rm(join(workspaceRoot, 'node_modules/@vitest/browser-playwright'), { + recursive: true, + force: true, + }); + + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless'], + undefined, + false, + workspaceRoot, + undefined, + ); + + expect(browser?.provider).toBeDefined(); + // Preview forces headless false + expect(browser?.instances?.[0].headless).toBeFalse(); + }); + + it('should force headless mode when headless option is true', async () => { + const { browser, messages } = await setupBrowserConfiguration( + ['Chrome', 'Firefox'], + true, + false, + workspaceRoot, + undefined, + ); + + expect(browser?.instances).toEqual([ + jasmine.objectContaining({ browser: 'chrome', headless: true }), + jasmine.objectContaining({ browser: 'firefox', headless: true }), + ]); + expect(messages).toEqual([]); + }); + + it('should return information message when headless option is redundant', async () => { + const { messages } = await setupBrowserConfiguration( + ['ChromeHeadless'], + true, + false, + workspaceRoot, + undefined, + ); + + expect(messages).toEqual([ + 'The "headless" option is unnecessary as all browsers are already configured to run in headless mode.', + ]); + }); + + describe('CHROME_BIN usage', () => { + let originalChromeBin: string | undefined; + + beforeEach(() => { + originalChromeBin = process.env['CHROME_BIN']; + process.env['CHROME_BIN'] = '/custom/path/to/chrome'; + }); + + afterEach(() => { + if (originalChromeBin === undefined) { + delete process.env['CHROME_BIN']; + } else { + process.env['CHROME_BIN'] = originalChromeBin; + } + }); + + it('should set executablePath on the individual chrome instance', async () => { + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless', 'Chromium'], + undefined, + false, + workspaceRoot, + undefined, + ); + + // Verify the global provider does NOT have executablePath + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((browser?.provider as any)?.options?.launchOptions?.executablePath).toBeUndefined(); + + // Verify the individual instances have executablePath + expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (browser?.instances?.[0]?.provider as any)?.options?.launchOptions?.executablePath, + ).toBe('/custom/path/to/chrome'); + expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (browser?.instances?.[1]?.provider as any)?.options?.launchOptions?.executablePath, + ).toBe('/custom/path/to/chrome'); + }); + + it('should set executablePath for chrome instances but not for others when mixed browsers are requested', async () => { + const { browser } = await setupBrowserConfiguration( + ['ChromeHeadless', 'Firefox'], + undefined, + false, + workspaceRoot, + undefined, + ); + + // Verify the global provider does NOT have executablePath + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((browser?.provider as any)?.options?.launchOptions?.executablePath).toBeUndefined(); + + // Verify chrome gets it + expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (browser?.instances?.[0]?.provider as any)?.options?.launchOptions?.executablePath, + ).toBe('/custom/path/to/chrome'); + + // Verify firefox does not + expect(browser?.instances?.[1]?.provider).toBeUndefined(); + }); + }); + + describe('applyHeadlessConfiguration', () => { + it('should set headless false and issue warning when using preview provider with headless true', () => { + const instances = [{ browser: 'chrome', headless: true }]; + const messages = applyHeadlessConfiguration(instances, 'preview', true, false); + + expect(instances[0].headless).toBeFalse(); + expect(messages).toEqual([ + 'The "headless" option is ignored when using the "preview" provider.', + ]); + }); + + it('should force headless mode when headless option is true', () => { + const instances = [{ browser: 'chrome', headless: false }]; + const messages = applyHeadlessConfiguration(instances, 'playwright', true, false); + + expect(instances[0].headless).toBeTrue(); + expect(messages).toEqual([]); + }); + + it('should return information message when headless option is redundant', () => { + const instances = [{ browser: 'chrome', headless: true }]; + const messages = applyHeadlessConfiguration(instances, 'playwright', true, false); + + expect(instances[0].headless).toBeTrue(); + expect(messages).toEqual([ + 'The "headless" option is unnecessary as all browsers are already configured to run in headless mode.', + ]); + }); + + it('should force headless mode in CI environment when headless is undefined', () => { + const instances = [{ browser: 'chrome', headless: false }]; + const messages = applyHeadlessConfiguration(instances, 'playwright', undefined, true); + + expect(instances[0].headless).toBeTrue(); + expect(messages).toEqual([]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts new file mode 100644 index 000000000000..efff72e5d8bb --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview + * Provides Vitest-specific build options and virtual file contents for Angular unit testing. + */ + +import type { BuilderContext } from '@angular-devkit/architect'; +import path from 'node:path'; +import { toPosixPath } from '../../../../utils/path'; +import { createProjectResolver } from '../../../../utils/resolve-project'; +import type { ApplicationBuilderInternalOptions } from '../../../application/options'; +import { OutputHashing } from '../../../application/schema'; +import { type NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; +import { findTests, getTestEntrypoints } from '../../test-discovery'; +import { RunnerOptions } from '../api'; + +/** + * Creates the virtual file contents to initialize the Angular testing environment (TestBed). + * + * @param providersFile Optional path to a file that exports default providers. + * @param projectSourceRoot The root directory of the project source. + * @param teardown Whether to configure TestBed to destroy after each test. + * @returns The string content of the virtual initialization file. + */ +function createTestBedInitVirtualFile( + providersFile: string | undefined, + projectSourceRoot: string, + teardown: boolean, + hasLocalize: boolean, +): string { + let providersImport = 'const providers = [];'; + if (providersFile) { + const relativePath = path.relative(projectSourceRoot, providersFile); + const { dir, name } = path.parse(relativePath); + const importPath = toPosixPath(path.join(dir, name)); + providersImport = `import providers from './${importPath}';`; + } + + // The DynamicDOMTestComponentRenderer is used to avoid stale document references + // when running Vitest in non-isolated mode with JSDOM. It looks up the + // document dynamically on every operation instead of caching it. + return ` + ${hasLocalize ? "import '@angular/localize/init';" : ''} + // Initialize the Angular testing environment + import { NgModule, provideZoneChangeDetection } from '@angular/core'; + import { getTestBed, ɵgetCleanupHook as getCleanupHook, TestComponentRenderer } from '@angular/core/testing'; + import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; + import { ɵgetDOM } from '@angular/common'; + import { afterEach, beforeEach } from 'vitest'; + ${providersImport} + + // The beforeEach and afterEach hooks are registered outside the globalThis guard. + // This ensures that the hooks are always applied, even in non-isolated browser environments. + // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29 + beforeEach(getCleanupHook(false)); + afterEach(getCleanupHook(true)); + + class DynamicDOMTestComponentRenderer extends TestComponentRenderer { + insertRootElement(rootElId, tagName = 'div') { + this.removeAllRootElements(); + + const dom = ɵgetDOM(); + const doc = dom.getDefaultDocument(); + if (doc && doc.body) { + const rootElement = doc.createElement(tagName); + rootElement.setAttribute('id', rootElId); + doc.body.appendChild(rootElement); + } + } + + removeAllRootElements() { + const dom = ɵgetDOM(); + const doc = dom.getDefaultDocument(); + if (doc && typeof doc.querySelectorAll === 'function') { + const oldRoots = doc.querySelectorAll('[id^=root]'); + for (let i = 0; i < oldRoots.length; i++) { + dom.remove(oldRoots[i]); + } + } + } + } + + const ANGULAR_TESTBED_SETUP = Symbol.for('@angular/cli/testbed-setup'); + if (!globalThis[ANGULAR_TESTBED_SETUP]) { + globalThis[ANGULAR_TESTBED_SETUP] = true; + + // The Angular TestBed needs to be initialized before any tests are run. + // In a non-isolated environment, this setup file can be executed multiple times. + // The guard condition above ensures that the setup is only performed once. + + @NgModule({ + providers: [ + ...(typeof Zone !== 'undefined' ? [provideZoneChangeDetection()] : []), + ...providers, + { provide: TestComponentRenderer, useClass: DynamicDOMTestComponentRenderer }, + ], + }) + class TestModule {} + + getTestBed().initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting(), { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + ${teardown === false ? 'teardown: { destroyAfterEach: false },' : ''} + }); + } + `; +} + +/** + * Adjusts output hashing settings for testing purposes. For example, ensuring media + * is continued to be hashed to avoid overwriting assets, but turning off JavaScript hashing. + * + * @param hashing The original OutputHashing configuration. + * @returns The adjusted OutputHashing configuration. + */ +function adjustOutputHashing(hashing?: OutputHashing): OutputHashing { + switch (hashing) { + case OutputHashing.All: + case OutputHashing.Media: + // Ensure media is continued to be hashed to avoid overwriting of output media files + return OutputHashing.Media; + default: + return OutputHashing.None; + } +} + +/** + * Injects Zone.js and Zone.js testing polyfills into the build options based on the + * project configuration and `polyfills` option. + * + * @param polyfills The configured polyfills from the test or build target. + * @param projectSourceRoot The root directory of the project source. + * @param logger The logger instance for reporting deprecation warnings. + * @returns An array of polyfill specifiers to use for testing. + */ +function injectZoneJsTestingPolyfills( + polyfills: string[] | undefined, + projectSourceRoot: string, + logger: BuilderContext['logger'], +): string[] { + if (polyfills) { + return injectTestingPolyfills(polyfills); + } + + // If polyfills is undefined (e.g. library build target), attempt to load zone.js if installed. + try { + const projectResolve = createProjectResolver(projectSourceRoot); + projectResolve('zone.js'); + + logger.warn( + 'Zone.js polyfills are being automatically injected because "zone.js" was detected in the project dependencies. ' + + 'This behavior is deprecated. If your project is zoneless, set the "polyfills" option to an empty array ("[]") in the ' + + 'test configuration. Otherwise, explicitly add "zone.js" to the "polyfills" option.', + ); + + return ['zone.js', 'zone.js/testing']; + } catch { + return []; + } +} + +/** + * Generates options and virtual files for the Vitest test runner. + * + * Discovers specs matchers, creates entry points, decides polyfills strategy, and orchestrates + * internal ApplicationBuilder options. + * + * @param options The normalized unit test builder options. + * @param baseBuildOptions The base build config to derive testing config from. + * @param logger The logger instance for reporting deprecation warnings. + * @returns An async RunnerOptions configuration. + */ +export async function getVitestBuildOptions( + options: NormalizedUnitTestBuilderOptions, + baseBuildOptions: Partial, + logger: BuilderContext['logger'], +): Promise { + const { + workspaceRoot, + projectSourceRoot, + include, + polyfills, + exclude = [], + watch, + providersFile, + setupFiles, + } = options; + + // Find test files + const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot); + if (testFiles.length === 0) { + throw new Error( + 'No tests found matching the following patterns:\n' + + `- Included: ${include.join(', ')}\n` + + (exclude.length ? `- Excluded: ${exclude.join(', ')}\n` : '') + + `\nPlease check the 'test' target configuration in your project's 'angular.json' file.`, + ); + } + + const entryPoints = getTestEntrypoints(testFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension: true, + }); + + const rootFiles = [...testFiles]; + if (providersFile) { + rootFiles.push(providersFile); + } + + if (setupFiles?.length) { + rootFiles.push(...setupFiles); + + const setupEntryPoints = getTestEntrypoints(setupFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension: false, + prefix: 'setup', + }); + + for (const [entryPoint, setupFile] of setupEntryPoints) { + entryPoints.set(entryPoint, setupFile); + } + } + + // The Angular compiler facade must be loaded in a dedicated setup file before TestBed initialization. + // This ensures the compiler facade is published before 'init-testbed' or any shared code-split chunks + // (such as those from a configured 'providersFile') are evaluated. + entryPoints.set('init-compiler', 'angular:compiler-init'); + entryPoints.set('init-testbed', 'angular:test-bed-init'); + entryPoints.set('vitest-mock-patch', 'angular:vitest-mock-patch'); + + // The 'vitest' package is always external for testing purposes + const externalDependencies = ['vitest']; + if (baseBuildOptions.externalDependencies) { + externalDependencies.push(...baseBuildOptions.externalDependencies); + } + + const buildOptions: Partial = { + ...baseBuildOptions, + polyfills: injectZoneJsTestingPolyfills( + polyfills ?? baseBuildOptions.polyfills, + projectSourceRoot, + logger, + ), + watch, + incrementalResults: watch, + index: false, + browser: undefined, + server: undefined, + outputMode: undefined, + localize: false, + budgets: [], + serviceWorker: false, + appShell: false, + ssr: false, + prerender: false, + sourceMap: { scripts: true, vendor: false, styles: false }, + outputHashing: adjustOutputHashing(baseBuildOptions.outputHashing), + optimization: false, + namedChunks: false, + entryPoints, + rootFiles, + // Vitest's Node-based module loading emulation (vite-node) is not fully spec compliant and lacks + // live ESM bindings across chunk boundaries. This can cause uninitialized exports or break mocking. + // Disabling code splitting avoids shared chunks, but increases build and coverage memory/time. + disableCodeSplitting: !options.splitting, + // Enable support for vitest browser prebundling. Excludes can be controlled with a runnerConfig + // and the `optimizeDeps.exclude` option. + externalPackages: true, + externalDependencies, + }; + + let hasLocalize = false; + try { + const projectResolve = createProjectResolver(projectSourceRoot); + projectResolve('@angular/localize'); + hasLocalize = true; + } catch {} + + const testBedInitContents = createTestBedInitVirtualFile( + providersFile, + projectSourceRoot, + !options.debug, + hasLocalize, + ); + + const mockPatchContents = ` + import { vi } from 'vitest'; + + const ANGULAR_VITEST_MOCK_PATCH = Symbol.for('@angular/cli/vitest-mock-patch'); + if (!globalThis[ANGULAR_VITEST_MOCK_PATCH]) { + globalThis[ANGULAR_VITEST_MOCK_PATCH] = true; + + const error = new Error( + 'The "vi.mock" and related methods are not supported for relative imports with the Angular unit-test system. ' + + 'Please use Angular TestBed for mocking dependencies.' + ); + + // Store original implementations + const { mock, doMock, importMock, unmock, doUnmock } = vi; + + function patch(original) { + return (path, ...args) => { + // Check if the path is a string and starts with a character that indicates a relative path. + if (typeof path === 'string' && /^[./]/.test(path)) { + throw error; + } + + // Call the original function for non-relative paths. + return original(path, ...args); + }; + } + + vi.mock = patch(mock); + vi.doMock = patch(doMock); + vi.importMock = patch(importMock); + vi.unmock = patch(unmock); + vi.doUnmock = patch(doUnmock); + } + `; + + return { + buildOptions, + virtualFiles: { + 'angular:compiler-init': "import '@angular/compiler';", + 'angular:test-bed-init': testBedInitContents, + 'angular:vitest-mock-patch': mockPatchContents, + }, + testEntryPointMappings: entryPoints, + }; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/configuration.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/configuration.ts new file mode 100644 index 000000000000..6df583350e07 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/configuration.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview + * This file contains utility functions for finding the Vitest base configuration file. + */ + +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * A list of potential Vitest configuration filenames. + * The order of the files is important as the first one found will be used. + */ +const POTENTIAL_CONFIGS = [ + 'vitest-base.config.ts', + 'vitest-base.config.mts', + 'vitest-base.config.cts', + 'vitest-base.config.js', + 'vitest-base.config.mjs', + 'vitest-base.config.cjs', +]; + +/** + * Finds the Vitest configuration file in the given search directories. + * + * @param searchDirs An array of directories to search for the configuration file. + * @returns The path to the configuration file, or `false` if no file is found. + * Returning `false` is used to disable Vitest's default configuration file search. + */ +export async function findVitestBaseConfig(searchDirs: string[]): Promise { + const uniqueDirs = new Set(searchDirs); + for (const dir of uniqueDirs) { + try { + const entries = await readdir(dir, { withFileTypes: true }); + const files = new Set(entries.filter((e) => e.isFile()).map((e) => e.name)); + + for (const potential of POTENTIAL_CONFIGS) { + if (files.has(potential)) { + return path.join(dir, potential); + } + } + } catch { + // Ignore directories that cannot be read + } + } + + return false; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts new file mode 100644 index 000000000000..471f5dea467b --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -0,0 +1,426 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import assert from 'node:assert'; +import path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import type * as Vite from 'vite' with { + 'resolution-mode': 'import', +}; +import type { Vitest } from 'vitest/node'; +import { + DevServerExternalResultMetadata, + updateExternalMetadata, +} from '../../../../tools/vite/utils'; +import { assertIsError } from '../../../../utils/error'; +import { + type FullResult, + type IncrementalResult, + type ResultFile, + ResultKind, +} from '../../../application/results'; +import { NormalizedUnitTestBuilderOptions } from '../../options'; +import type { TestExecutor } from '../api'; +import { setupBrowserConfiguration } from './browser-provider'; +import { findVitestBaseConfig } from './configuration'; +import { createVitestConfigPlugin, createVitestPlugins } from './plugins'; + +enum DebugLogLevel { + Info = 1, + Verbose = 2, +} + +export class VitestExecutor implements TestExecutor { + private vitest: Vitest | undefined; + private normalizePath: ((id: string) => string) | undefined; + private readonly projectName: string; + private readonly options: NormalizedUnitTestBuilderOptions; + private readonly logger: BuilderContext['logger']; + private readonly buildResultFiles = new Map(); + private readonly externalMetadata: DevServerExternalResultMetadata = { + implicitBrowser: [], + implicitServer: [], + explicitBrowser: [], + explicitServer: [], + }; + private readonly debugLevel: number; + + // This is a reverse map of the entry points created in `build-options.ts`. + // It is used by the in-memory provider plugin to map the requested test file + // path back to its bundled output path. + // Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>` + private readonly testFileToEntryPoint = new Map(); + private readonly entryPointToTestFile = new Map(); + + constructor( + projectName: string, + options: NormalizedUnitTestBuilderOptions, + testEntryPointMappings: Map | undefined, + logger: BuilderContext['logger'], + ) { + const level = parseInt(process.env['NG_TEST_LOG'] ?? '0', 10); + this.debugLevel = isNaN(level) ? 0 : level; + + this.projectName = projectName; + this.options = options; + this.logger = logger; + + this.debugLog(DebugLogLevel.Info, 'VitestExecutor instantiated.'); + this.debugLog(DebugLogLevel.Verbose, 'NormalizedUnitTestBuilderOptions:', options); + + if (testEntryPointMappings) { + for (const [entryPoint, testFile] of testEntryPointMappings) { + this.testFileToEntryPoint.set(testFile, entryPoint); + this.entryPointToTestFile.set(entryPoint + '.js', testFile); + } + this.debugLog( + DebugLogLevel.Verbose, + 'Test entry point mappings:', + Object.fromEntries(testEntryPointMappings), + ); + } + } + + private debugLog(level: DebugLogLevel, message: string, data?: object) { + if (this.debugLevel < level) { + return; + } + + const formattedMessage = `[VitestExecutor:${DebugLogLevel[level]}] ${message}`; + // Custom formatting for data object to ensure it's readable + const logData = data ? JSON.stringify(data, null, 2) : ''; + this.logger.info(`${formattedMessage}${logData ? `\n${logData}` : ''}`); + } + + async *execute(buildResult: FullResult | IncrementalResult): AsyncIterable { + this.debugLog(DebugLogLevel.Info, `Executing test run (kind: ${buildResult.kind}).`); + this.normalizePath ??= ((await import('vite' as string)) as typeof Vite).normalizePath; + + if (buildResult.kind === ResultKind.Full) { + this.buildResultFiles.clear(); + for (const file of buildResult.files) { + this.buildResultFiles.set(this.normalizePath(file.path), file); + } + this.debugLog( + DebugLogLevel.Info, + `Full build results received. Total files: ${this.buildResultFiles.size}.`, + ); + } else { + this.debugLog( + DebugLogLevel.Info, + `Incremental build results received.` + + `Added: ${buildResult.added.length}, Modified: ${buildResult.modified.length}, Removed: ${buildResult.removed.length}.`, + ); + this.debugLog(DebugLogLevel.Verbose, 'Added files:', buildResult.added); + this.debugLog(DebugLogLevel.Verbose, 'Modified files:', buildResult.modified); + this.debugLog(DebugLogLevel.Verbose, 'Removed files:', buildResult.removed); + + for (const file of buildResult.removed) { + this.buildResultFiles.delete(this.normalizePath(file.path)); + } + for (const file of buildResult.files) { + this.buildResultFiles.set(this.normalizePath(file.path), file); + } + } + + updateExternalMetadata(buildResult, this.externalMetadata, undefined, true); + this.debugLog(DebugLogLevel.Verbose, 'Updated external metadata:', this.externalMetadata); + + // Reset the exit code to allow for a clean state. + // This is necessary because Vitest may set the exit code on failure, which can + // affect subsequent runs in watch mode or when running multiple builders. + process.exitCode = 0; + + // Initialize Vitest if not already present. + this.vitest ??= await this.initializeVitest(); + const vitest = this.vitest; + + let testResults; + if (buildResult.kind === ResultKind.Incremental) { + // To rerun tests, Vitest needs the original test file paths, not the output paths. + const modifiedSourceFiles = new Set(); + for (const modifiedFile of [...buildResult.modified, ...buildResult.added]) { + // The `modified` files in the build result are the output paths. + // We need to find the original source file path to pass to Vitest. + const source = this.entryPointToTestFile.get(modifiedFile); + if (source) { + this.debugLog( + DebugLogLevel.Verbose, + `Mapped output file '${modifiedFile}' to source file '${source}' for re-run.`, + ); + modifiedSourceFiles.add(source); + } else { + this.debugLog( + DebugLogLevel.Verbose, + `Could not map output file '${modifiedFile}' to a source file. It may not be a test file.`, + ); + } + vitest.invalidateFile( + this.normalizePath(path.join(this.options.workspaceRoot, modifiedFile)), + ); + } + + const specsToRerun = []; + for (const file of modifiedSourceFiles) { + vitest.invalidateFile(file); + const specs = vitest.getModuleSpecifications(file); + if (specs) { + specsToRerun.push(...specs); + } + } + + if (specsToRerun.length > 0) { + this.debugLog(DebugLogLevel.Info, `Re-running ${specsToRerun.length} test specifications.`); + this.debugLog(DebugLogLevel.Verbose, 'Specs to rerun:', specsToRerun); + testResults = await vitest.rerunTestSpecifications(specsToRerun); + } else { + this.debugLog(DebugLogLevel.Info, 'No test specifications to rerun.'); + } + } + + // Check if all the tests pass to calculate the result + const testModules = testResults?.testModules ?? this.vitest.state.getTestModules(); + + let success = testModules.every((testModule) => testModule.ok()); + let finalResultReason = 'All tests passed.'; + + // Vitest does not return a failure result when coverage thresholds are not met. + // Instead, it sets the process exit code to 1. + // We check this exit code to determine if the test run should be considered a failure. + if (success && process.exitCode === 1) { + success = false; + finalResultReason = 'Test run failed due to unmet coverage thresholds.'; + // Reset the exit code to prevent it from carrying over to subsequent runs/builds + process.exitCode = 0; + } + + this.debugLog( + DebugLogLevel.Info, + `Test run finished with success: ${success}. Reason: ${finalResultReason}`, + ); + yield { success }; + } + + async [Symbol.asyncDispose](): Promise { + if (!this.vitest) { + return; + } + + this.debugLog(DebugLogLevel.Info, 'Disposing VitestExecutor: Closing Vitest instance.'); + + const controller = new AbortController(); + const timeoutMs = 10_000; + + try { + await Promise.race([ + this.vitest.close(), + setTimeout(timeoutMs, undefined, { signal: controller.signal, ref: false }) + .then(() => { + this.logger.warn( + `Vitest instance failed to close cleanly within ${timeoutMs}ms. Continuing teardown...`, + ); + }) + .catch(() => { + // Suppress AbortError triggered by controller.abort() when close() resolves first + }), + ]); + } catch (error: unknown) { + assertIsError(error); + this.logger.error(`An error occurred while closing Vitest instance: ${error.message}`); + } finally { + controller.abort(); + } + + this.debugLog(DebugLogLevel.Info, 'Vitest instance closed.'); + } + + private prepareSetupFiles(): string[] { + const { setupFiles } = this.options; + // Add setup file entries for TestBed initialization and project polyfills + const testSetupFiles = [ + 'init-compiler.js', + 'init-testbed.js', + 'vitest-mock-patch.js', + ...setupFiles, + ]; + + // TODO: Provide additional result metadata to avoid needing to extract based on filename + if (this.buildResultFiles.has('polyfills.js')) { + testSetupFiles.unshift('polyfills.js'); + } + + this.debugLog(DebugLogLevel.Info, 'Prepared setup files:', testSetupFiles); + + return testSetupFiles; + } + + private async initializeVitest(): Promise { + this.debugLog(DebugLogLevel.Info, 'Initializing Vitest.'); + const { + coverage, + reporters, + outputFile, + workspaceRoot, + browsers, + debug, + watch, + browserViewport, + ui, + projectRoot, + runnerConfig, + projectSourceRoot, + cacheOptions, + } = this.options; + const projectName = this.projectName; + + let vitestNodeModule; + try { + vitestNodeModule = await import('vitest/node'); + } catch (error: unknown) { + assertIsError(error); + this.debugLog( + DebugLogLevel.Info, + `Failed to import 'vitest/node'. Error code: ${error.code}`, + ); + if (error.code !== 'ERR_MODULE_NOT_FOUND') { + throw error; + } + throw new Error( + 'The `vitest` package was not found. Please install the package and rerun the test command.', + { cause: error }, + ); + } + const { startVitest } = vitestNodeModule; + + // Setup vitest browser options if configured + const browserOptions = await setupBrowserConfiguration( + browsers, + this.options.headless, + debug, + projectSourceRoot, + browserViewport, + ); + if (browserOptions.errors?.length) { + this.debugLog(DebugLogLevel.Info, 'Browser configuration errors found.', { + errors: browserOptions.errors, + }); + throw new Error(browserOptions.errors.join('\n')); + } + + if (browserOptions.messages?.length) { + for (const message of browserOptions.messages) { + this.logger.info(message); + } + } + this.debugLog(DebugLogLevel.Info, 'Browser configuration complete.', { + config: browserOptions.browser, + }); + + this.debugLog( + DebugLogLevel.Info, + `Verifying build results. File count: ${this.buildResultFiles.size}.`, + ); + assert( + this.buildResultFiles.size > 0, + 'buildResult must be available before initializing vitest', + ); + + const testSetupFiles = this.prepareSetupFiles(); + const projectPlugins = createVitestPlugins({ + workspaceRoot, + projectSourceRoot, + projectName, + buildResultFiles: this.buildResultFiles, + testFileToEntryPoint: this.testFileToEntryPoint, + setupFiles: testSetupFiles, + }); + + const debugOptions = debug + ? { + inspectBrk: true, + isolate: false, + fileParallelism: false, + } + : {}; + + const externalConfigPath = + runnerConfig === true + ? await findVitestBaseConfig([projectRoot, workspaceRoot]) + : runnerConfig; + + this.debugLog(DebugLogLevel.Info, 'External Vitest configuration path:', { + externalConfigPath, + }); + + let project = projectName; + if (debug && browserOptions.browser?.instances) { + if (browserOptions.browser.instances.length > 1) { + this.logger.warn( + 'Multiple browsers are configured, but only the first browser will be used for debugging.', + ); + } + + // When running browser tests, Vitest appends the browser name to the project identifier. + // The project name must match this augmented name to ensure the correct project is targeted. + project = `${projectName} (${browserOptions.browser.instances[0].browser})`; + this.debugLog(DebugLogLevel.Info, 'Adjusted project name for debugging with browser:', { + project, + }); + } + + // Filter internal entries and setup files from the include list + const internalEntries = ['angular:']; + const setupFileSet = new Set(testSetupFiles); + const include = [...this.testFileToEntryPoint.keys()].filter((entry) => { + return ( + !internalEntries.some((internal) => entry.startsWith(internal)) && !setupFileSet.has(entry) + ); + }); + this.debugLog(DebugLogLevel.Verbose, 'Included test files (after filtering):', include); + + const vitestConfig = { + config: externalConfigPath, + root: workspaceRoot, + project, + watch, + ...(outputFile !== undefined ? { outputFile } : {}), + ...(cacheOptions.enabled ? {} : { cache: false as const }), + ...(this.options.filter !== undefined ? { testNamePattern: this.options.filter } : {}), + ...(typeof ui === 'boolean' ? { ui } : {}), + ...debugOptions, + }; + const vitestServerConfig = { + // Note `.vitest` is auto appended to the path. + cacheDir: cacheOptions.path, + plugins: [ + await createVitestConfigPlugin({ + browser: browserOptions.browser, + cacheOptions, + coverage, + projectName, + projectSourceRoot, + optimizeDepsInclude: this.externalMetadata.implicitBrowser, + reporters, + setupFiles: testSetupFiles, + projectPlugins, + include, + watch, + isolate: this.options.isolate, + preserveSymlinks: this.options.preserveSymlinks, + }), + ], + }; + + this.debugLog(DebugLogLevel.Info, 'Calling startVitest with final configuration.'); + this.debugLog(DebugLogLevel.Verbose, 'Vitest config:', vitestConfig); + this.debugLog(DebugLogLevel.Verbose, 'Vitest server config:', vitestServerConfig); + + return startVitest('test', undefined, vitestConfig, vitestServerConfig); + } +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts new file mode 100644 index 000000000000..f879628a2462 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import type { TestRunner } from '../api'; +import { DependencyChecker } from '../dependency-checker'; +import { getVitestBuildOptions } from './build-options'; +import { VitestExecutor } from './executor'; + +/** + * A declarative definition of the Vitest test runner. + */ +const VitestTestRunner: TestRunner = { + name: 'vitest', + + validateDependencies(options) { + const checker = new DependencyChecker(options.projectSourceRoot); + checker.check('vitest'); + + // If a runnerConfig is present, only check for 'vitest' itself. + // Custom configuration may include unknown browsers or other setup + // that doesn't follow the default dependency requirements. + if (options.runnerConfig) { + checker.report(); + + return; + } + + if (options.browsers?.length) { + if (process.versions.webcontainer) { + checker.check('@vitest/browser-preview'); + } else { + checker.checkAny( + ['@vitest/browser-playwright', '@vitest/browser-webdriverio', '@vitest/browser-preview'], + 'The "browsers" option requires either ' + + '"@vitest/browser-playwright", "@vitest/browser-webdriverio", or "@vitest/browser-preview" to be installed.', + ); + } + } else { + // DOM emulation is used when no browsers are specified + checker.checkAny( + ['jsdom', 'happy-dom'], + 'A DOM environment is required for non-browser tests. Please install either "jsdom" or "happy-dom".', + ); + } + + if (options.coverage.enabled) { + checker.checkAny( + ['@vitest/coverage-v8', '@vitest/coverage-istanbul'], + 'Code coverage requires either "@vitest/coverage-v8" or "@vitest/coverage-istanbul" to be installed.', + ); + } + + checker.report(); + }, + + getBuildOptions(options, baseBuildOptions, logger) { + return getVitestBuildOptions(options, baseBuildOptions, logger); + }, + + async createExecutor(context, options, testEntryPointMappings) { + const projectName = context.target?.project; + assert(projectName, 'The builder requires a target.'); + + if (!!process.versions.webcontainer && options.browsers?.length) { + context.logger.info( + `Webcontainer environment detected. Using '@vitest/browser-preview' for browser-based tests.`, + ); + } + + if (typeof options.runnerConfig === 'string') { + context.logger.info(`Using Vitest configuration file: ${options.runnerConfig}`); + } else if (options.runnerConfig) { + context.logger.info('Automatically searching for and using Vitest configuration file.'); + } + + return new VitestExecutor(projectName, options, testEntryPointMappings, context.logger); + }, +}; + +export default VitestTestRunner; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts new file mode 100644 index 000000000000..37aa4a9828a6 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -0,0 +1,659 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { platform } from 'node:os'; +import path from 'node:path'; +import type { + BrowserConfigOptions, + InlineConfig, + ResolvedConfig, + UserWorkspaceConfig, + Vite, + VitestPluginContext, +} from 'vitest/node'; +import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware'; +import type { NormalizedCachedOptions } from '../../../../utils/normalize-cache'; +import { toPosixPath } from '../../../../utils/path'; +import { createProjectResolver } from '../../../../utils/resolve-project'; +import type { ResultFile } from '../../../application/results'; +import type { NormalizedUnitTestBuilderOptions } from '../../options'; +import { normalizeBrowserName } from './browser-provider'; + +interface ExistingRawSourceMap { + sources?: string[]; + sourcesContent?: string[]; + mappings?: string; +} + +interface PluginOptions { + workspaceRoot: string; + projectSourceRoot: string; + projectName: string; + buildResultFiles: ReadonlyMap; + testFileToEntryPoint: ReadonlyMap; + setupFiles: readonly string[]; +} + +type VitestCoverageOption = Exclude; + +interface VitestConfigPluginOptions { + browser: BrowserConfigOptions | undefined; + cacheOptions: NormalizedCachedOptions; + coverage: NormalizedUnitTestBuilderOptions['coverage']; + projectName: string; + projectSourceRoot: string; + reporters?: string[] | [string, object][]; + setupFiles: string[]; + projectPlugins: Vite.PluginOption[]; + include: string[]; + optimizeDepsInclude: string[]; + watch: boolean; + isolate: boolean | undefined; + preserveSymlinks?: boolean; +} + +async function findTestEnvironment( + projectResolver: (packageName: string) => string, +): Promise<'jsdom' | 'happy-dom'> { + try { + projectResolver('happy-dom'); + + return 'happy-dom'; + } catch { + // happy-dom is not installed, fallback to jsdom + return 'jsdom'; + } +} + +function determineCoverageProvider( + browser: BrowserConfigOptions | undefined, + testConfig: InlineConfig | undefined, + optionsCoverageEnabled: boolean | undefined, + projectSourceRoot: string, +): 'istanbul' | 'v8' | 'custom' | undefined { + let determinedProvider = testConfig?.coverage?.provider; + if (!determinedProvider && (optionsCoverageEnabled || testConfig?.coverage?.enabled)) { + const browsersToCheck = getBrowsersToCheck(browser, testConfig?.browser); + + const hasNonChromium = browsersToCheck.some( + (b) => !['chrome', 'chromium', 'edge'].includes(normalizeBrowserName(b).browser), + ); + + if (hasNonChromium) { + determinedProvider = 'istanbul'; + } else { + const projectResolve = createProjectResolver(projectSourceRoot); + const checkInstalled = (pkg: string) => { + try { + projectResolve(pkg); + + return true; + } catch { + return false; + } + }; + const hasIstanbul = checkInstalled('@vitest/coverage-istanbul'); + const hasV8 = checkInstalled('@vitest/coverage-v8'); + + // Favor istanbul when browser testing is enabled (to avoid slow CDP V8 HTTP sourcemap remapping) + // or when only istanbul is installed. + if (hasIstanbul && (browsersToCheck.length > 0 || !hasV8)) { + determinedProvider = 'istanbul'; + } else { + determinedProvider = 'v8'; + } + } + } + + return determinedProvider; +} + +function getBrowsersToCheck( + browser: BrowserConfigOptions | undefined, + testConfigBrowser: BrowserConfigOptions | undefined, +): string[] { + const browsersToCheck: string[] = []; + + const cliBrowser = browser as CustomBrowserConfigOptions | undefined; + const userBrowser = testConfigBrowser as CustomBrowserConfigOptions | undefined; + + // 1. CLI options override the Vitest configuration completely. + if (cliBrowser) { + if (cliBrowser.instances) { + browsersToCheck.push(...cliBrowser.instances.map((i) => i.browser)); + } + if (cliBrowser.name) { + browsersToCheck.push(cliBrowser.name); + } + + return browsersToCheck; + } + + // 2. Fall back to Vitest configuration ONLY if browser testing is enabled. + if (userBrowser && userBrowser.enabled !== false) { + if (userBrowser.instances) { + browsersToCheck.push(...userBrowser.instances.map((i) => i.browser)); + } + if (userBrowser.name) { + browsersToCheck.push(userBrowser.name); + } + } + + return browsersToCheck; +} + +export async function createVitestConfigPlugin( + options: VitestConfigPluginOptions, +): Promise { + const { + include, + browser, + projectName, + reporters, + setupFiles, + projectPlugins, + projectSourceRoot, + } = options; + + const { mergeConfig } = await import('vitest/config'); + + return { + name: 'angular:vitest-configuration', + enforce: 'post', + configResolved: { + order: 'post', + handler(config: Vite.ResolvedConfig) { + // Vitest's internal post-configResolved hook resets `server.watch` to `{}` when + // `watch` is enabled (`server.watch ??= {}`). Force it back to `null` so Vite uses + // a no-op watcher and relies solely on the Angular CLI build watcher. + config.server.watch = null; + }, + }, + async config(config: UserWorkspaceConfig & Vite.UserConfig) { + const testConfig = config.test; + + const determinedProvider = determineCoverageProvider( + browser, + testConfig, + options.coverage.enabled, + projectSourceRoot, + ); + + if (reporters !== undefined) { + delete testConfig?.reporters; + } + + if ( + options.coverage.reporters !== undefined && + testConfig?.coverage && + 'reporter' in testConfig.coverage + ) { + delete testConfig.coverage.reporter; + } + + if (testConfig?.projects?.length) { + this.warn( + 'The "test.projects" option in the Vitest configuration file is not supported. ' + + 'The Angular CLI Test system will construct its own project configuration.', + ); + delete testConfig.projects; + } + + if (testConfig?.include) { + this.warn( + 'The "test.include" option in the Vitest configuration file is not supported. ' + + 'The Angular CLI Test system will manage test file discovery.', + ); + delete testConfig.include; + } + + if (testConfig?.watch !== undefined && testConfig.watch !== options.watch) { + this.warn( + `The "test.watch" option in the Vitest configuration file is overridden by the builder's ` + + `watch option. Please use the Angular CLI "--watch" option to enable or disable watch mode.`, + ); + delete testConfig.watch; + } + + if (testConfig?.exclude) { + this.warn( + 'The "test.exclude" option in the Vitest configuration file is evaluated after ' + + 'tests are compiled. For better build performance, please use the Angular CLI ' + + '"exclude" option instead.', + ); + } + + // Merge user-defined plugins from the Vitest config with the CLI's internal plugins. + if (config.plugins) { + const userPlugins = config.plugins.filter( + (plugin: Vite.PluginOption) => + // Only inspect objects with a `name` property as these would be the internal injected plugins + !plugin || + typeof plugin !== 'object' || + !('name' in plugin) || + (!plugin.name.startsWith('angular:') && !plugin.name.startsWith('vitest')), + ); + + if (userPlugins.length > 0) { + projectPlugins.push(...userPlugins); + } + delete config.plugins; + } + + // Validate browser coverage support if coverage is enabled + if ( + (browser || testConfig?.browser?.enabled) && + (options.coverage.enabled || testConfig?.coverage?.enabled) + ) { + validateBrowserCoverage(browser, testConfig?.browser, determinedProvider); + } + + const projectResolver = createProjectResolver(projectSourceRoot); + + // Vitest enables its file system module cache by default and stores it in `node_modules`, + // regardless of `cache` and `cacheDir`. Follow the Angular CLI cache settings instead, + // unless the Vitest configuration file sets them. + const fsModuleCache = testConfig?.fsModuleCache ?? options.cacheOptions.enabled; + const fsModuleCachePath = + testConfig?.fsModuleCachePath ?? + path.join(options.cacheOptions.path, 'vitest-module-cache'); + + const projectDefaults: Vite.UserConfig & UserWorkspaceConfig = { + extends: false, + test: { + setupFiles, + globals: true, + // Default to `false` to align with the Karma/Jasmine experience. + isolate: false, + sequence: { setupFiles: 'list' }, + fsModuleCache, + fsModuleCachePath, + }, + optimizeDeps: { + noDiscovery: true, + include: options.optimizeDepsInclude, + }, + resolve: { + mainFields: ['es2020', 'module', 'main'], + conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])], + preserveSymlinks: options.preserveSymlinks, + }, + }; + + const { optimizeDeps, resolve } = config; + const projectOverrides: Vite.UserConfig & UserWorkspaceConfig = { + extends: false, + test: { + name: projectName, + include, + // CLI provider browser options override, if present + ...(browser ? { browser } : {}), + // Only override if the user explicitly set it via CLI + ...(options.isolate !== undefined ? { isolate: options.isolate } : {}), + // If the user has not specified an environment, use a smart default. + ...(!testConfig?.environment + ? { environment: await findTestEnvironment(projectResolver) } + : {}), + }, + plugins: projectPlugins, + optimizeDeps, + resolve, + }; + + const projectBase = mergeConfig(projectDefaults, testConfig ? { test: testConfig } : {}); + const projectConfig = mergeConfig(projectBase, projectOverrides); + + return { + test: { + // The cache metadata is always read from the root configuration. + fsModuleCache, + fsModuleCachePath, + coverage: await generateCoverageOption( + options.coverage, + testConfig?.coverage, + projectName, + determinedProvider, + ), + ...(reporters ? { reporters } : {}), + projects: [projectConfig], + }, + }; + }, + }; +} + +const textDecoder = new TextDecoder('utf-8'); +async function loadResultFile(file: ResultFile): Promise { + if (file.origin === 'memory') { + return textDecoder.decode(file.contents); + } + + return readFile(file.source, 'utf-8'); +} + +export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] { + const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions; + const isWindows = platform() === 'win32'; + const setupFileSet = new Set( + setupFiles.map((file) => + toPosixPath(path.isAbsolute(file) ? file : path.join(workspaceRoot, file)), + ), + ); + let vitestConfig: ResolvedConfig; + + /** The build output path served for a module, whether it is a test entry point or a built artifact. */ + const getOutputPath = (id: string): string => { + const entryPoint = testFileToEntryPoint.get(id); + + return entryPoint ? entryPoint + '.js' : toPosixPath(path.relative(workspaceRoot, id)); + }; + + // Setup files must not be wrapped in a virtual import stub because Vitest only invalidates + // the setup file itself between test files; wrapping it would cause the underlying bundle + // to be cached, preventing per-test hooks from running on subsequent test files. + const isCoverageRedirect = (id: string): boolean => + !!vitestConfig?.coverage?.enabled && testFileToEntryPoint.has(id) && !setupFileSet.has(id); + + return [ + { + name: 'angular:test-in-memory-provider', + enforce: 'pre', + configureVitest(context: VitestPluginContext) { + vitestConfig = context.vitest.config; + + // Vitest keys its file system module cache on the file found on disk. A test entry point is + // served from its bundle instead, which also holds the application code it imports, so the + // key must follow the served build output, or the cache would return a stale bundle. + // Vitest 4.1 only has the experimental name, and 4.0 has no file system module cache at all. + const pluginContext: Partial = context; + const defineCacheKeyGenerator = + pluginContext.defineCacheKeyGenerator ?? + pluginContext.experimental_defineCacheKeyGenerator; + defineCacheKeyGenerator?.(({ id }) => { + const outputPath = getOutputPath(id); + // With coverage, `load` serves a redirect stub instead of the bundle, see below. + if (isCoverageRedirect(id)) { + return `coverage-redirect:${outputPath}`; + } + + const outputFile = buildResultFiles.get(outputPath); + if (!outputFile) { + return undefined; + } + if (outputFile.origin !== 'memory') { + return false; + } + + const sourceMapFile = buildResultFiles.get(outputPath + '.map'); + + return `${outputFile.hash}:${sourceMapFile?.origin === 'memory' ? sourceMapFile.hash : ''}`; + }); + }, + resolveId: (id: string, importer: string | undefined) => { + // Fast path for test entry points. + if (testFileToEntryPoint.has(id)) { + return id; + } + + // Workaround for Vitest in Windows when a fully qualified absolute path is provided with + // a superfluous leading slash. This can currently occur with the `@vitest/coverage-v8` provider + // when it uses `removeStartsWith(url, FILE_PROTOCOL)` to convert a file URL resulting in + // `/D:/tmp_dir/...` instead of `D:/tmp_dir/...`. + if (id[0] === '/' && isWindows) { + const slicedId = id.slice(1); + if (path.isAbsolute(slicedId)) { + return slicedId; + } + } + + // Determine the base directory for resolution. + let baseDir: string; + if (importer) { + // If the importer is a test entry point, resolve relative to the workspace root. + // Otherwise, resolve relative to the importer's directory. + baseDir = testFileToEntryPoint.has(importer) ? workspaceRoot : path.dirname(importer); + } else { + // If there's no importer, assume the id is relative to the workspace root. + baseDir = workspaceRoot; + } + + // Construct the full, absolute path and normalize it to POSIX format. + let fullPath: string; + if (path.isAbsolute(id)) { + const relativeId = path.relative(baseDir, id); + fullPath = + !relativeId.startsWith('..') && !path.isAbsolute(relativeId) + ? id + : path.join(baseDir, id); + } else { + fullPath = path.join(baseDir, id); + } + fullPath = toPosixPath(fullPath); + + if (testFileToEntryPoint.has(fullPath)) { + return fullPath; + } + + // Check if the resolved path corresponds to a known build artifact. + const relativePath = path.relative(workspaceRoot, fullPath); + if (buildResultFiles.has(toPosixPath(relativePath))) { + return fullPath; + } + + // If the module cannot be resolved from the build artifacts, let other plugins handle it. + return undefined; + }, + async load(id: string) { + assert(buildResultFiles.size > 0, 'buildResult must be available for in-memory loading.'); + + // Attempt to load as a source test file, or else as a built artifact. + const outputPath = getOutputPath(id); + if (isCoverageRedirect(id)) { + // To support coverage exclusion of the actual test file, the virtual + // test entry point only references the built and bundled intermediate file. + // If vitest supported an "excludeOnlyAfterRemap" option, this could be removed completely. + return { + code: `import "./${outputPath}";`, + }; + } + + const outputFile = buildResultFiles.get(outputPath); + if (outputFile) { + const code = await loadResultFile(outputFile); + const sourceMapPath = outputPath + '.map'; + const sourceMapFile = buildResultFiles.get(sourceMapPath); + const sourceMapText = sourceMapFile ? await loadResultFile(sourceMapFile) : undefined; + + const map = sourceMapText ? JSON.parse(sourceMapText) : undefined; + if (map) { + adjustSourcemapSources(map, true, workspaceRoot, id); + } + + return { + code, + map, + }; + } + }, + configureServer: (server: Vite.ViteDevServer) => { + server.middlewares.use(createBuildAssetsMiddleware(server.config.base, buildResultFiles)); + }, + }, + { + name: 'angular:html-index', + transformIndexHtml: () => { + // Add all global stylesheets + if (buildResultFiles.has('styles.css')) { + return [ + { + tag: 'link', + attrs: { href: 'styles.css', rel: 'stylesheet' }, + injectTo: 'head', + }, + ]; + } + + return []; + }, + }, + ]; +} + +/** + * Adjusts the sources field in a sourcemap to ensure correct source mapping and coverage reporting. + * + * @param map The raw sourcemap to adjust. + * @param rebaseSources Whether to rebase the source paths relative to the test file. + * @param workspaceRoot The root directory of the workspace. + * @param id The ID (path) of the file being loaded. + */ +function adjustSourcemapSources( + map: ExistingRawSourceMap, + rebaseSources: boolean, + workspaceRoot: string, + id: string, +): void { + if (!map.sources?.length && !map.sourcesContent?.length && !map.mappings) { + // Vitest will include files in the coverage report if the sourcemap contains no sources. + // For builder-internal generated code chunks, which are typically helper functions, + // a virtual source is added to the sourcemap to prevent them from being incorrectly + // included in the final coverage report. + map.sources = ['virtual:builder']; + } else if (rebaseSources && map.sources) { + map.sources = map.sources.map((source) => { + if (!source || source.startsWith('angular:')) { + return source; + } + + // source is relative to the workspace root because the output file is at the root of the output. + const absoluteSource = path.join(workspaceRoot, source); + + return toPosixPath(path.relative(path.dirname(id), absoluteSource)); + }); + } +} + +interface CustomBrowserConfigOptions { + enabled?: boolean; + instances?: { browser: string }[]; + name?: string; +} + +/** + * Validates that all enabled browsers support V8 coverage when coverage is enabled. + * Throws an error if an unsupported browser is detected. + */ +function validateBrowserCoverage( + browser: BrowserConfigOptions | undefined, + testConfigBrowser: BrowserConfigOptions | undefined, + provider?: string, +): void { + if (provider === 'istanbul') { + return; + } + const browsersToCheck = getBrowsersToCheck(browser, testConfigBrowser); + + // Normalize and filter unsupported browsers + const unsupportedBrowsers = browsersToCheck + .map((b) => normalizeBrowserName(b).browser) + .filter((b) => !['chrome', 'chromium', 'edge'].includes(b)); + + if (unsupportedBrowsers.length > 0) { + throw new Error( + `Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: ` + + `${unsupportedBrowsers.join(', ')}. ` + + `V8 coverage is only supported on Chromium-based browsers (e.g., Chrome, Chromium, Edge). ` + + `Please use the "istanbul" coverage provider (requires "@vitest/coverage-istanbul"), ` + + `remove the unsupported browsers, or disable coverage.`, + ); + } +} + +async function generateCoverageOption( + optionsCoverage: NormalizedUnitTestBuilderOptions['coverage'], + configCoverage: VitestCoverageOption | undefined, + projectName: string, + provider?: 'istanbul' | 'v8' | 'custom', +): Promise { + let defaultExcludes: string[] = []; + // When a coverage exclude option is provided, Vitest's default coverage excludes + // will be overridden. To retain them, we manually fetch the defaults to append to the + // user's provided exclusions. + if (optionsCoverage.exclude) { + try { + const vitestConfig = await import('vitest/config'); + defaultExcludes = vitestConfig.coverageConfigDefaults.exclude; + } catch {} + } + + return { + provider, + excludeAfterRemap: true, + reportsDirectory: + configCoverage?.reportsDirectory ?? toPosixPath(path.join('coverage', projectName)), + ...(optionsCoverage.enabled !== undefined ? { enabled: optionsCoverage.enabled } : {}), + // Vitest performs a pre-check and a post-check for sourcemaps. + // The pre-check uses the bundled files, so specific bundled entry points and chunks need to be included. + // The post-check uses the original source files, so the user's include is used. + ...(optionsCoverage.include + ? { include: ['spec-*.js', 'chunk-*.js', ...optionsCoverage.include] } + : {}), + // The 'in' operator is used here because 'configCoverage' is a union type and + // not all coverage providers support thresholds or watermarks. + thresholds: mergeCoverageObjects( + configCoverage && 'thresholds' in configCoverage ? configCoverage.thresholds : undefined, + optionsCoverage.thresholds, + ), + watermarks: mergeCoverageObjects( + configCoverage && 'watermarks' in configCoverage ? configCoverage.watermarks : undefined, + optionsCoverage.watermarks, + ), + // Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures + ...(optionsCoverage.exclude + ? { + exclude: Array.from( + new Set([ + // Augment the default exclude https://vitest.dev/config/#coverage-exclude + // with the user defined exclusions + ...(configCoverage?.exclude || []), + ...optionsCoverage.exclude, + ...defaultExcludes, + ]), + ), + } + : {}), + ...(optionsCoverage.reporters + ? ({ reporter: optionsCoverage.reporters } satisfies VitestCoverageOption) + : {}), + }; +} + +/** + * Merges coverage related objects while ignoring any `undefined` values. + * This ensures that Angular CLI options correctly override Vitest configuration + * only when explicitly provided. + */ +function mergeCoverageObjects( + configValue: T | undefined, + optionsValue: T | undefined, +): T | undefined { + if (optionsValue === undefined) { + return configValue; + } + + const result: Record = { ...(configValue ?? {}) }; + for (const [key, value] of Object.entries(optionsValue)) { + if (value !== undefined) { + result[key] = value; + } + } + + return Object.keys(result).length > 0 ? (result as T) : undefined; +} diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json new file mode 100644 index 000000000000..8c7cfcf686b4 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -0,0 +1,301 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Unit testing", + "description": "Unit testing options for Angular applications.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "Specifies the build target to use for the unit test build in the format `project:target[:configuration]`. This defaults to the `build` target of the current project with the `development` configuration. You can also pass a comma-separated list of configurations. Example: `project:target:production,staging`.", + "pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$" + }, + "tsConfig": { + "type": "string", + "description": "The path to the TypeScript configuration file, relative to the workspace root. Defaults to `tsconfig.spec.json` in the project root if it exists. If not specified and the default does not exist, the `tsConfig` from the specified `buildTarget` will be used." + }, + "runner": { + "type": "string", + "description": "Specifies the test runner to use for test execution.", + "default": "vitest", + "enum": ["karma", "vitest"] + }, + "runnerConfig": { + "type": ["string", "boolean"], + "description": "Specifies the configuration file for the selected test runner. If a string is provided, it will be used as the path to the configuration file. If `true`, the builder will search for a default configuration file (e.g., `vitest-base.config.mts` or `karma.conf.js`). If `false`, no external configuration file will be used.\\nFor Vitest, this enables advanced options and the use of custom plugins. Please note that while the file is loaded, the Angular team does not provide direct support for its specific contents or any third-party plugins used within it.", + "default": false + }, + "browsers": { + "description": "Specifies the browsers to use for test execution. When not specified, tests are run in a Node.js environment using jsdom. For both Vitest and Karma, browser names ending with 'Headless' (e.g., 'ChromeHeadless') will enable headless mode.", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "browserViewport": { + "description": "Specifies the browser viewport dimensions for browser-based tests in the format `widthxheight`.", + "type": "string", + "pattern": "^\\d+x\\d+$" + }, + "include": { + "type": "array", + "items": { + "type": "string" + }, + "default": ["**/*.spec.ts", "**/*.test.ts"], + "description": "Specifies glob patterns of files to include for testing, relative to the project source root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specifies glob patterns of files to exclude from testing, relative to the project source root." + }, + "filter": { + "type": "string", + "description": "Specifies a regular expression pattern to match against test suite and test names. Only tests with a name matching the pattern will be executed. For example, `^App` will run only tests in suites beginning with 'App'." + }, + "watch": { + "type": "boolean", + "description": "Enables watch mode, which re-runs tests when source files change. Defaults to `true` in TTY environments and `false` otherwise." + }, + "headless": { + "type": "boolean", + "description": "Forces all configured browsers to run in headless mode. When using the Vitest runner, this option is ignored if no browsers are configured. The Karma runner does not support this option." + }, + "debug": { + "type": "boolean", + "description": "Enables debugging mode for tests, allowing the use of the Node Inspector.", + "default": false + }, + "ui": { + "type": "boolean", + "description": "Enables the Vitest UI for interactive test execution. This option is only available for the Vitest runner." + }, + "isolate": { + "type": "boolean", + "description": "Enables isolation for test execution. When true, Vitest runs tests in separate threads or processes. This option is only available for the Vitest runner. Defaults to false to align with the Karma/Jasmine experience." + }, + "splitting": { + "type": "boolean", + "description": "Enables code splitting for test execution. When enabled, shared code between test files is split into separate chunks. Issues with live ESM bindings in Node.js environments (such as uninitialized exports or mocking failures) can be resolved by disabling splitting. This option is only available for the Vitest runner.", + "default": true, + "x-deprecated": "No longer needed with Vitest 5." + }, + "quiet": { + "type": "boolean", + "description": "Suppresses the verbose build summary and stats table on each rebuild. Defaults to `true` locally and `false` in CI environments." + }, + "coverage": { + "type": "boolean", + "description": "Enables coverage reporting for tests. If not specified, the coverage configuration from a runner configuration file will be used if present. Otherwise, coverage is disabled by default." + }, + "coverageInclude": { + "type": "array", + "description": "Specifies glob patterns of files to include in the coverage report.", + "items": { + "type": "string" + } + }, + "coverageExclude": { + "type": "array", + "description": "Specifies glob patterns of files to exclude from the coverage report.", + "items": { + "type": "string" + } + }, + "coverageReporters": { + "type": "array", + "description": "Specifies the reporters to use for coverage results. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'html', 'lcov', 'lcovonly', 'text', 'text-summary', 'cobertura', 'json', and 'json-summary'.", + "items": { + "oneOf": [ + { + "enum": [ + "html", + "lcov", + "lcovonly", + "text", + "text-summary", + "cobertura", + "json", + "json-summary" + ] + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "enum": [ + "html", + "lcov", + "lcovonly", + "text", + "text-summary", + "cobertura", + "json", + "json-summary" + ] + }, + { + "type": "object" + } + ] + } + ] + } + }, + "coverageThresholds": { + "type": "object", + "description": "Specifies minimum coverage thresholds that must be met. If thresholds are not met, the builder will exit with an error.", + "properties": { + "perFile": { + "type": "boolean", + "description": "When true, thresholds are enforced for each file individually." + }, + "statements": { + "type": "number", + "description": "Minimum percentage of statements covered." + }, + "branches": { + "type": "number", + "description": "Minimum percentage of branches covered." + }, + "functions": { + "type": "number", + "description": "Minimum percentage of functions covered." + }, + "lines": { + "type": "number", + "description": "Minimum percentage of lines covered." + } + }, + "additionalProperties": false + }, + "coverageWatermarks": { + "type": "object", + "description": "Specifies coverage watermarks for the HTML reporter. These determine the color coding for high, medium, and low coverage.", + "properties": { + "statements": { + "type": "array", + "description": "The high and low watermarks for statements coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "branches": { + "type": "array", + "description": "The high and low watermarks for branches coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "functions": { + "type": "array", + "description": "The high and low watermarks for functions coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "lines": { + "type": "array", + "description": "The high and low watermarks for lines coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + } + }, + "additionalProperties": false + }, + "reporters": { + "type": "array", + "description": "Specifies the reporters to use during test execution. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'default', 'verbose', 'dots', 'json', 'junit', 'tap', 'tap-flat', and 'html'. You can also provide a path to a custom reporter.", + "items": { + "oneOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "enum": ["default", "verbose", "dots", "json", "junit", "tap", "tap-flat", "html"] + } + ] + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "enum": [ + "default", + "verbose", + "dots", + "json", + "junit", + "tap", + "tap-flat", + "html" + ] + } + ] + }, + { + "type": "object" + } + ] + } + ] + } + }, + "outputFile": { + "type": "string", + "description": "Specifies a file path for the test report. This path is used by every configured reporter that writes to a file. To write a separate file for each reporter, use the tuple format `['reporter-name', { outputFile: '...' }]` within the `reporters` option. When not provided, output is written to the console." + }, + "providersFile": { + "type": "string", + "description": "Specifies the path to a TypeScript file that provides an array of Angular providers for the test environment. The file must contain a default export of the provider array.", + "minLength": 1 + }, + "polyfills": { + "description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'. If not specified, polyfills will be inherited from the build target.", + "type": "array", + "items": { + "type": "string", + "uniqueItems": true + } + }, + "setupFiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of paths to global setup files that are executed before the test files. The application's polyfills and the Angular TestBed are always initialized before these files." + }, + "progress": { + "type": "boolean", + "description": "Shows build progress information in the console. Defaults to the `progress` setting of the specified `buildTarget`." + }, + "listTests": { + "type": "boolean", + "description": "Lists all discovered test files and exits the process without building or executing the tests.", + "default": false + }, + "dumpVirtualFiles": { + "type": "boolean", + "description": "Dumps build output files to the `.angular/cache` directory for debugging purposes.", + "default": false, + "visible": false + } + }, + "additionalProperties": false, + "required": [] +} diff --git a/packages/angular/build/src/builders/unit-test/test-discovery.ts b/packages/angular/build/src/builders/unit-test/test-discovery.ts new file mode 100644 index 000000000000..2452675a9abf --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -0,0 +1,342 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { type PathLike, constants, promises as fs } from 'node:fs'; +import os from 'node:os'; +import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path'; +import { glob, isDynamicPattern } from 'tinyglobby'; +import { calculateHash, initializeHash } from '../../utils/hash'; +import { toPosixPath } from '../../utils/path'; + +/** + * An array of file infix notations that identify a file as a test file. + * For example, `.spec` in `app.component.spec.ts`. + */ +const TEST_FILE_INFIXES = ['.spec', '.test']; + +/** Maximum length for a generated test entrypoint name. */ +const MAX_FILENAME_LENGTH = 128; + +/** + * Finds all test files in the project. This function implements a special handling + * for static paths (non-globs) to improve developer experience. For example, if a + * user provides a path to a component, this function will find the corresponding + * test file. If a user provides a path to a directory, it will find all test + * files within that directory. + * + * @param include Glob patterns of files to include. + * @param exclude Glob patterns of files to exclude. + * @param workspaceRoot The absolute path to the workspace root. + * @param projectSourceRoot The absolute path to the project's source root. + * @returns A unique set of absolute paths to all test files. + */ +export async function findTests( + include: string[], + exclude: string[], + workspaceRoot: string, + projectSourceRoot: string, +): Promise { + await initializeHash(); + const resolvedTestFiles = new Set(); + const dynamicPatterns: string[] = []; + + const projectRootPrefix = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/'); + const normalizedExcludes = exclude.map((p) => normalizePattern(p, projectRootPrefix)); + + // 1. Separate static and dynamic patterns + for (const pattern of include) { + const normalized = normalizePattern(pattern, projectRootPrefix); + if (isDynamicPattern(pattern)) { + dynamicPatterns.push(normalized); + } else { + const { resolved, unresolved } = await resolveStaticPattern(normalized, projectSourceRoot); + resolved.forEach((file) => resolvedTestFiles.add(file)); + unresolved.forEach((p) => dynamicPatterns.push(p)); + } + } + + // 2. Execute a single glob for all dynamic patterns + if (dynamicPatterns.length > 0) { + const globMatches = await glob(dynamicPatterns, { + cwd: projectSourceRoot, + absolute: true, + expandDirectories: false, + ignore: ['**/node_modules/**', ...normalizedExcludes], + }); + + for (const match of globMatches) { + resolvedTestFiles.add(toPosixPath(match)); + } + } + + // 3. Combine, de-duplicate and sort the results. The glob crawls directories asynchronously and + // returns matches in a varying order; the order feeds the build entry points, so an unsorted list + // makes the output (chunk contents and names) differ between two builds of the same sources. + return [...resolvedTestFiles].sort(); +} + +interface TestEntrypointsOptions { + projectSourceRoot: string; + workspaceRoot: string; + removeTestExtension?: boolean; + prefix?: string; +} + +/** + * Generates unique, dash-delimited bundle names for a set of test files. + * This is used to create distinct output files for each test. + * + * @param testFiles An array of absolute paths to test files. + * @param options Configuration options for generating entry points. + * @returns A map where keys are the generated unique bundle names and values are the original file paths. + */ +export function getTestEntrypoints( + testFiles: string[], + { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix = 'spec', + }: TestEntrypointsOptions, +): Map { + const seen = new Set(); + const counters = new Map(); + const roots = [projectSourceRoot, workspaceRoot]; + const infixes = TEST_FILE_INFIXES.map((i) => i.slice(1)); + if (!infixes.includes(prefix)) { + infixes.push(prefix); + } + const infixesPattern = infixes.join('|'); + + return new Map( + Array.from(testFiles, (testFile) => { + const fileName = generateNameFromPath(testFile, roots, !!removeTestExtension); + const baseName = fileName === prefix ? prefix : `${prefix}-${fileName}`; + let uniqueName = baseName; + // Start at 2 for collisions as the first instance remains suffix-less. + let suffix = counters.get(baseName) ?? 2; + + while (seen.has(uniqueName)) { + uniqueName = `${baseName}-${suffix}`.replace( + new RegExp(`([^\\w](?:${infixesPattern}))-([\\d]+)$`), + '-$2$1', + ); + ++suffix; + } + seen.add(uniqueName); + counters.set(baseName, suffix); + + return [uniqueName, testFile]; + }), + ); +} + +/** + * Generates a unique, dash-delimited name from a file path. This is used to + * create a consistent and readable bundle name for a given test file. + * + * @param testFile The absolute path to the test file. + * @param roots An array of root paths to remove from the beginning of the test file path. + * @param removeTestExtension Whether to remove the test file infix and extension from the result. + * @returns A dash-cased name derived from the relative path of the test file. + */ +export function generateNameFromPath( + testFile: string, + roots: string[], + removeTestExtension: boolean, +): string { + const relativePath = removeRoots(testFile, roots.map(toPosixPath)); + + let startIndex = 0; + // Skip leading dots and slashes + while (startIndex < relativePath.length && /^[./\\]$/.test(relativePath[startIndex])) { + startIndex++; + } + + let endIndex = relativePath.length; + if (removeTestExtension) { + const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|'); + const match = relativePath.match(new RegExp(`\\.(${infixes})\\.[^.]+$`)); + + if (match?.index) { + endIndex = match.index; + } + } else { + const extIndex = relativePath.lastIndexOf('.'); + if (extIndex > startIndex) { + endIndex = extIndex; + } + } + + // Build the final string in a single pass + let result = ''; + for (let i = startIndex; i < endIndex; i++) { + const char = relativePath[i]; + result += char === '/' || char === '\\' ? '-' : char; + } + + return truncateName(result, relativePath); +} + +/** + * Truncates a generated name if it exceeds the maximum allowed filename length. + * If truncation occurs, the name will be shortened by replacing a middle segment + * with an 8-character SHA256 hash of the original full path to maintain uniqueness. + * + * @param name The generated name to potentially truncate. + * @param originalPath The original full path from which the name was derived. Used for hashing. + * @returns The original name if within limits, or a truncated name with a hash. + */ +function truncateName(name: string, originalPath: string): string { + if (name.length <= MAX_FILENAME_LENGTH) { + return name; + } + + const hash = calculateHash(originalPath).substring(0, 8); + const availableLength = MAX_FILENAME_LENGTH - hash.length - 2; // 2 for '-' separators + const prefixLength = Math.floor(availableLength / 2); + const suffixLength = availableLength - prefixLength; + + return `${name.substring(0, prefixLength)}-${hash}-${name.substring(name.length - suffixLength)}`; +} + +/** + * Whether the current operating system's filesystem is case-insensitive. + */ +const isCaseInsensitiveFilesystem = os.platform() === 'win32' || os.platform() === 'darwin'; + +/** + * Removes a prefix from the beginning of a string, with conditional case-insensitivity + * based on the operating system's filesystem characteristics. + * + * @param text The string to remove the prefix from. + * @param prefix The prefix to remove. + * @returns The string with the prefix removed, or the original string if the prefix was not found. + */ +function removePrefix(text: string, prefix: string): string { + if (isCaseInsensitiveFilesystem) { + if (text.toLowerCase().startsWith(prefix.toLowerCase())) { + return text.substring(prefix.length); + } + } else { + if (text.startsWith(prefix)) { + return text.substring(prefix.length); + } + } + + return text; +} + +/** + * Removes potential root paths from a file path, returning a relative path. + * If no root path matches, it returns the file's basename. + * + * @param path The file path to process. + * @param roots An array of root paths to attempt to remove. + * @returns A relative path. + */ +function removeRoots(path: string, roots: string[]): string { + for (const root of roots) { + const result = removePrefix(path, root); + // If the prefix was removed, the result will be a different string. + if (result !== path) { + return result; + } + } + + return basename(path); +} + +/** + * Normalizes a glob pattern by converting it to a POSIX path, removing leading + * slashes, and making it relative to the project source root. + * + * @param pattern The glob pattern to normalize. + * @param projectRootPrefix The POSIX-formatted prefix of the project's source root relative to the workspace root. + * @returns A normalized glob pattern. + */ +function normalizePattern(pattern: string, projectRootPrefix: string): string { + const posixPattern = toPosixPath(pattern); + + // Do not modify absolute paths. The globber will handle them correctly. + if (isAbsolute(posixPattern)) { + return posixPattern; + } + + // For relative paths, ensure they are correctly relative to the project source root. + // This involves removing the project root prefix if the user provided a workspace-relative path. + const normalizedRelative = removePrefix(posixPattern, projectRootPrefix); + + return normalizedRelative; +} + +/** + * Resolves a static (non-glob) path. + * + * If the path is a directory, it returns a glob pattern to find all test files + * within that directory. + * + * If the path is a file, it attempts to find a corresponding test file by + * checking for files with the same name and a test infix (e.g., `.spec.ts`). + * + * If no corresponding test file is found, the original path is returned as an + * unresolved pattern. + * + * @param pattern The static path pattern. + * @param projectSourceRoot The absolute path to the project's source root. + * @returns A promise that resolves to an object containing resolved spec files and unresolved patterns. + */ +async function resolveStaticPattern( + pattern: string, + projectSourceRoot: string, +): Promise<{ resolved: string[]; unresolved: string[] }> { + const fullPath = isAbsolute(pattern) ? pattern : join(projectSourceRoot, pattern); + if (await isDirectory(fullPath)) { + const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|'); + + return { resolved: [], unresolved: [`${pattern}/**/*.@(${infixes}).@(ts|tsx)`] }; + } + + const fileExt = extname(fullPath); + const baseName = basename(fullPath, fileExt); + + for (const infix of TEST_FILE_INFIXES) { + const potentialSpec = join(dirname(fullPath), `${baseName}${infix}${fileExt}`); + if (await exists(potentialSpec)) { + return { resolved: [toPosixPath(potentialSpec)], unresolved: [] }; + } + } + + if (await exists(fullPath)) { + return { resolved: [toPosixPath(fullPath)], unresolved: [] }; + } + + return { resolved: [], unresolved: [toPosixPath(pattern)] }; +} + +/** Checks if a path exists and is a directory. */ +async function isDirectory(path: PathLike): Promise { + try { + const stats = await fs.stat(path); + + return stats.isDirectory(); + } catch { + return false; + } +} + +/** Checks if a path exists on the file system. */ +async function exists(path: PathLike): Promise { + try { + await fs.access(path, constants.F_OK); + + return true; + } catch { + return false; + } +} diff --git a/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts new file mode 100644 index 000000000000..91c33fd0120a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/test-discovery_spec.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { initializeHash } from '../../utils/hash'; +import { toPosixPath } from '../../utils/path'; +import { findTests, generateNameFromPath, getTestEntrypoints } from './test-discovery'; + +describe('findTests', () => { + let projectSourceRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + projectSourceRoot = await fs.mkdtemp(join(baseTmpDir, 'angular-find-tests-')); + }); + + afterEach(async () => { + await fs.rm(projectSourceRoot, { recursive: true, force: true }); + }); + + it('should return the test files sorted, regardless of the crawl order', async () => { + const files = ['z/z.spec.ts', 'a/b/c.spec.ts', 'm.spec.ts', 'a/a.spec.ts', 'b/x/y.spec.ts']; + for (const file of files) { + const path = join(projectSourceRoot, file); + await fs.mkdir(join(path, '..'), { recursive: true }); + await fs.writeFile(path, ''); + } + + const result = await findTests(['**/*.spec.ts'], [], projectSourceRoot, projectSourceRoot); + + const expected = files.map((file) => toPosixPath(join(projectSourceRoot, file))).sort(); + expect(result).toEqual(expected); + }); +}); + +describe('getTestEntrypoints', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const workspaceRoot = '/project'; + const projectSourceRoot = '/project/src'; + const options = { workspaceRoot, projectSourceRoot }; + + it('should generate entry points for unique files', () => { + const files = ['/project/src/a.spec.ts', '/project/src/b.spec.ts']; + const result = getTestEntrypoints(files, { ...options, removeTestExtension: true }); + + expect(result.size).toBe(2); + expect(result.get('spec-a')).toBe(files[0]); + expect(result.get('spec-b')).toBe(files[1]); + }); + + it('should handle collisions with numeric suffixes correctly positioned', () => { + // To trigger the regex replacement, the name must end in 'spec', 'test', or the prefix. + const files = ['/project/src/sub-test.spec.ts', '/project/src/sub/test.spec.ts']; + + const result = getTestEntrypoints(files, { ...options, removeTestExtension: true }); + // Both map to 'sub-test' (relative to src root). + // baseName = 'spec-sub-test'. + // 1st: 'spec-sub-test'. + // 2nd: 'spec-sub-test-2'. Regex matches '-test-2'. -> 'spec-sub-2-test'. + expect(result.get('spec-sub-test')).toBe(files[0]); + expect(result.get('spec-sub-2-test')).toBe(files[1]); + }); + + it('should handle setup file naming with prefix setup', () => { + const files = ['/project/src/setup.ts']; + const result = getTestEntrypoints(files, { + ...options, + removeTestExtension: false, + prefix: 'setup', + }); + + // 'setup.ts' -> 'setup' (via generateNameFromPath). + // prefix='setup'. + // baseName = 'setup' === 'setup' ? 'setup' : 'setup-setup' -> 'setup'. + expect(result.get('setup')).toBe(files[0]); + }); + + it('should handle setup file collisions', () => { + const files = ['/project/src/sub-setup.ts', '/project/src/sub/setup.ts']; + const result = getTestEntrypoints(files, { + ...options, + removeTestExtension: false, + prefix: 'setup', + }); + + // Both map to 'sub-setup' (baseName: 'setup-sub-setup'). + // 1st: 'setup-sub-setup'. + // 2nd: 'setup-sub-setup-2'. Regex matches '-setup-2'. -> 'setup-sub-2-setup'. + expect(result.get('setup-sub-setup')).toBe(files[0]); + expect(result.get('setup-sub-2-setup')).toBe(files[1]); + }); + + it('should handle custom prefixes', () => { + const files = ['/project/src/sub-my-file.ts', '/project/src/sub/my-file.ts']; + const result = getTestEntrypoints(files, { + ...options, + removeTestExtension: false, + prefix: 'custom', + }); + + // 'sub-my-file.ts' -> 'sub-my-file'. + // baseName: 'custom-sub-my-file'. + expect(result.get('custom-sub-my-file')).toBe(files[0]); + // 'custom-sub-my-file-2'. Does not match regex (no 'spec'/'test'/'custom' at end). + expect(result.get('custom-sub-my-file-2')).toBe(files[1]); + }); +}); + +describe('generateNameFromPath', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const roots = ['/project/src/', '/project/']; + + it('should generate a dash-cased name from a simple path', () => { + const testFile = '/project/src/app/components/my-component.spec.ts'; + const result = generateNameFromPath(testFile, roots, true); + expect(result).toBe('app-components-my-component'); + }); + + it('should handle Windows-style paths', () => { + const testFile = 'C:\\project\\src\\app\\components\\my-component.spec.ts'; + const result = generateNameFromPath(testFile, ['C:\\project\\src\\'], true); + expect(result).toBe('app-components-my-component'); + }); + + it('should remove test extensions when removeTestExtension is true', () => { + const testFile = '/project/src/app/utils/helpers.test.ts'; + const result = generateNameFromPath(testFile, roots, true); + expect(result).toBe('app-utils-helpers'); + }); + + it('should not remove test extensions when removeTestExtension is false', () => { + const testFile = '/project/src/app/utils/helpers.test.ts'; + const result = generateNameFromPath(testFile, roots, false); + expect(result).toBe('app-utils-helpers.test'); + }); + + it('should handle paths with leading dots and slashes', () => { + const testFile = '/project/src/./app/services/api.service.spec.ts'; + const result = generateNameFromPath(testFile, roots, true); + expect(result).toBe('app-services-api.service'); + }); + + it('should return the basename if no root matches', () => { + const testFile = '/unrelated/path/to/some/file.spec.ts'; + const result = generateNameFromPath(testFile, roots, true); + expect(result).toBe('file'); + }); + + it('should truncate a long file name', () => { + const longPath = + 'a/very/long/path/that/definitely/exceeds/the/maximum/allowed/length/for/a/filename/in/order/to/trigger/the/truncation/logic/in/the/function.spec.ts'; // eslint-disable-line max-len + const testFile = `/project/src/${longPath}`; + const result = generateNameFromPath(testFile, roots, true); + + expect(result.length).toBeLessThanOrEqual(128); + expect(result).toBe( + 'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-4af8113d-me-in-order-to-trigger-the-truncation-logic-in-the-function', + ); // eslint-disable-line max-len + }); + + it('should generate different hashes for different paths with similar truncated names', () => { + const longPath1 = + 'a/very/long/path/that/definitely/exceeds/the/maximum/allowed/length/for/a/filename/in/order/to/trigger/the/truncation/logic/variant-a.spec.ts'; // eslint-disable-line max-len + const longPath2 = + 'a/very/long/path/that/definitely/exceeds/the/maximum/allowed/length/for/a/filename/in/order/to/trigger/the/truncation/logic/variant-b.spec.ts'; // eslint-disable-line max-len + + const testFile1 = `/project/src/${longPath1}`; + const testFile2 = `/project/src/${longPath2}`; + + const result1 = generateNameFromPath(testFile1, roots, true); + const result2 = generateNameFromPath(testFile2, roots, true); + + expect(result1).not.toBe(result2); + // The hash is always 8 characters long and is surrounded by hyphens. + const hashRegex = /-[a-f0-9]{8}-/; + const hash1 = result1.match(hashRegex)?.[0]; + const hash2 = result2.match(hashRegex)?.[0]; + + expect(hash1).toBeDefined(); + expect(hash2).toBeDefined(); + expect(hash1).not.toBe(hash2); + }); + + it('should not truncate a filename that is exactly the max length', () => { + const name = 'a'.repeat(128); + const testFile = `/project/src/${name}.spec.ts`; + const result = generateNameFromPath(testFile, roots, true); + expect(result).toBe(name); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/coverage-ignore-comments_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/coverage-ignore-comments_spec.ts new file mode 100644 index 000000000000..2c680ac08cf0 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/coverage-ignore-comments_spec.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + UNIT_TEST_BUILDER_INFO, + describeBuilder, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "coverage ignore comments"', () => { + beforeEach(async () => { + setupApplicationTarget(harness, { extractLicenses: false, optimization: false }); + }); + + function getSpecContent(extraTest = '') { + return ` +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', async () => { + const fixture = TestBed.createComponent(AppComponent); + await fixture.whenStable(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('hello'); + }); + + ${extraTest} +}); +`; + } + + async function assertNoUncoveredStatements(contextMessage: string) { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/coverage-final.json').toExist(); + + const coverageMap = JSON.parse(harness.readFile('coverage/test/coverage-final.json')); + const appComponentPath = Object.keys(coverageMap).find((p) => p.includes('app.component.ts')); + expect(appComponentPath).toBeDefined(); + + const appComponentCoverage = coverageMap[appComponentPath as string]; + + const statementCounts = Object.values(appComponentCoverage.s); + const hasUncoveredStatements = statementCounts.some((count) => count === 0); + expect(hasUncoveredStatements).withContext(contextMessage).toBeFalse(); + } + + for (const type of ['istanbul', 'v8']) { + it(`should respect ${type} ignore next comments when computing coverage`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coverageReporters: ['json'] as any, + }); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

hello

', + standalone: true, + }) + export class AppComponent { + title = 'app'; + + /* ${type} ignore next */ + untestedFunction() { + return false; + } + } + `, + ); + + await harness.writeFile('src/app/app.component.spec.ts', getSpecContent()); + + await assertNoUncoveredStatements( + 'There should be no uncovered statements as the uncalled function was ignored', + ); + }); + + it(`should respect ${type} ignore if comments when computing coverage`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coverageReporters: ['json'] as any, + }); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

hello

', + standalone: true, + }) + export class AppComponent { + checkValue(val: boolean) { + /* ${type} ignore if -- @preserve */ + if (val) { + return true; + } + return false; + } + } + `, + ); + + await harness.writeFile( + 'src/app/app.component.spec.ts', + getSpecContent(` + it('should exercise the function but not the if block', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + app.checkValue(false); + }); + `), + ); + + await assertNoUncoveredStatements( + 'There should be no uncovered statements as the uncalled branch was ignored', + ); + }); + + it(`should respect ${type} ignore else comments when computing coverage`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coverageReporters: ['json'] as any, + }); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

hello

', + standalone: true, + }) + export class AppComponent { + checkValue(val: boolean) { + /* ${type} ignore else -- @preserve */ + if (val) { + return true; + } else { + return false; + } + } + } + `, + ); + + await harness.writeFile( + 'src/app/app.component.spec.ts', + getSpecContent(` + it('should exercise the function but not the else block', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + app.checkValue(true); + }); + `), + ); + + await assertNoUncoveredStatements( + 'There should be no uncovered statements as the uncalled else branch was ignored', + ); + }); + } + + it('should respect v8 ignore start/stop comments when computing coverage', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coverageReporters: ['json'] as any, + }); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

hello

', + standalone: true, + }) + export class AppComponent { + title = 'app'; + + /* v8 ignore start */ + untestedFunction() { + return false; + } + /* v8 ignore stop */ + } + `, + ); + + await harness.writeFile('src/app/app.component.spec.ts', getSpecContent()); + + await assertNoUncoveredStatements( + 'There should be no uncovered statements as the uncalled function was ignored', + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts new file mode 100644 index 000000000000..d6d604d9f311 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/library-target_spec.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { BASE_OPTIONS, describeBuilder, UNIT_TEST_BUILDER_INFO } from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "@angular/build:library buildTarget"', () => { + it('should support library buildTarget with stylePreprocessorOptions and inlineStyleLanguage', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + tsConfig: 'src/tsconfig.lib.json', + inlineStyleLanguage: 'scss', + stylePreprocessorOptions: { + includePaths: ['src/styles'], + }, + }, + { + builderName: '@angular/build:library', + }, + ); + + await harness.writeFiles({ + 'src/styles/_vars.scss': '$primary-color: #123456;', + 'src/public-api.ts': `export * from './lib/lib.component';`, + 'src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-comp', + standalone: true, + template: '

lib

', + styles: [\` + @use 'vars'; + p { color: vars.$primary-color; } + \`], + }) + export class LibComponent {} + `, + 'src/lib/lib.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { describe, it, expect } from 'vitest'; + import { LibComponent } from './lib.component'; + + describe('LibComponent', () => { + it('creates component with scss styles', () => { + TestBed.configureTestingModule({ + imports: [LibComponent], + }); + const fixture = TestBed.createComponent(LibComponent); + expect(fixture).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/lib/**/*.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts new file mode 100644 index 000000000000..0893660a251e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/runner-config-vitest_spec.ts @@ -0,0 +1,750 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readdirSync } from 'node:fs'; +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +const VITEST_CONFIG_CONTENT = ` +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + reporters: [['junit', { outputFile: './vitest-results.xml' }]], + }, +}); +`; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "runnerConfig with Vitest runner"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + it('should use custom reporters defined in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile('vitest.config.mts', VITEST_CONFIG_CONTENT); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should use custom reporters and outputFile defined as an object in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + reporters: ['junit'], + outputFile: { + junit: './vitest-results-outputfile.xml', + }, + }, + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results-outputfile.xml').toExist(); + }); + + it('should preserve custom coverage options (e.g. clean: false) from runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + coverage: true, + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + clean: false, + }, + }, + }); + `, + ); + + // Create a placeholder file in the coverage directory before running the test + harness.writeFile('coverage/test/placeholder-file.txt', 'do-not-delete'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // The builder outputs to coverage/test by default. + // If clean: false is preserved, the placeholder-file.txt should still exist. + harness.expectFile('coverage/test/placeholder-file.txt').toExist(); + }); + + it('should override reporters defined in runnerConfig file when CLI option is present', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + reporters: ['default'], + }); + + harness.writeFile('vitest.config.mts', VITEST_CONFIG_CONTENT); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // The CLI option 'default' should override the 'junit' reporter in VITEST_CONFIG_CONTENT + harness.expectFile('vitest-results.xml').toNotExist(); + }); + + it('should use custom reportsDirectory defined in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + coverage: true, + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + reportsDirectory: './custom-coverage-reports', + }, + }, + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('custom-coverage-reports/coverage-final.json').toExist(); + }); + + it('should use default reportsDirectory when not defined in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: {}, + }, + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/coverage-final.json').toExist(); + }); + + it('should enable coverage when set in runnerConfig file without builder option', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + enabled: true, + }, + }, + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/coverage-final.json').toExist(); + }); + + it('should exclude test files based on runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + exclude: ['src/app/app.component.spec.ts'], + reporters: ['default', ['json', { outputFile: 'vitest-results.json' }]], + }, + }); + `, + ); + + // Create a second test file that should be executed + harness.writeFile( + 'src/app/app-second.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { AppComponent } from './app.component'; + + describe('AppComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [AppComponent], + })); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const results = JSON.parse(harness.readFile('vitest-results.json')); + expect(results.numPassedTests).toBe(1); + }); + + it('should correctly merge coverage.exclude arrays from builder and runner options', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'vitest.config.mts', + coverageExclude: ['src/app/cli-excluded.ts'], + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + exclude: ['src/app/config-excluded.ts'], + }, + }, + }); + `, + ); + + // Create two files that would normally be covered + harness.writeFile('src/app/cli-excluded.ts', 'export const cliExcluded = true;'); + harness.writeFile('src/app/config-excluded.ts', 'export const configExcluded = true;'); + + // Update the test file to import them so they're picked up by coverage + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { test, expect } from 'vitest'; + import './cli-excluded'; + import './config-excluded'; + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/coverage-final.json').toExist(); + + const coverageMap = JSON.parse(harness.readFile('coverage/test/coverage-final.json')); + const coveredFiles = Object.keys(coverageMap); + + const hasCliExcluded = coveredFiles.some((f) => f.includes('cli-excluded.ts')); + const hasConfigExcluded = coveredFiles.some((f) => f.includes('config-excluded.ts')); + + expect(hasCliExcluded).withContext('CLI target should be excluded').toBeFalse(); + expect(hasConfigExcluded).withContext('Config file target should be excluded').toBeFalse(); + }); + + it('should allow overriding globals to false via runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + globals: false, + }, + }); + `, + ); + + // This test will fail if globals are enabled, because `test` will not be defined. + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { expect } from 'vitest'; + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should initialize environment even when globals are disabled in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + globals: false, + }, + }); + `, + ); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { test, expect } from 'vitest'; + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should fail when a DOM-dependent test is run in a node environment', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + environment: 'node', + }, + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should warn and ignore "test.projects" option from runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + projects: ['./foo.config.ts'], + }, + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // TODO: Re-enable once Vite logs are remapped through build system + // expect(logs).toContain( + // jasmine.objectContaining({ + // level: 'warn', + // message: jasmine.stringMatching( + // 'The "test.projects" option in the Vitest configuration file is not supported.', + // ), + // }), + // ); + }); + + it('should warn and ignore "test.include" option from runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + include: ['src/app/non-existent.spec.ts'], + }, + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // TODO: Re-enable once Vite logs are remapped through build system + // expect(logs).toContain( + // jasmine.objectContaining({ + // level: 'warn', + // message: jasmine.stringMatching( + // 'The "test.include" option in the Vitest configuration file is not supported.', + // ), + // }), + // ); + }); + + it('should warn and ignore "test.watch" option from runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: false, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + watch: true, + }, + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // TODO: Re-enable once Vite logs are remapped through build system + // expect(logs).toContain( + // jasmine.objectContaining({ + // level: 'warn', + // message: jasmine.stringMatching( + // 'The "test.watch" option in the Vitest configuration file is overridden by the builder\\'s watch option.', + // ), + // }), + // ); + }); + + it('should warn about performance when "test.exclude" option is in runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + exclude: ['src/app/non-existent.spec.ts'], + }, + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // TODO: Re-enable once Vite logs are remapped through build system + // expect(logs).toContain( + // jasmine.objectContaining({ + // level: 'warn', + // message: jasmine.stringMatching( + // 'The "test.exclude" option in the Vitest configuration file is evaluated after', + // ), + // }), + // ); + }); + + it(`should append "test.setupFiles" (string) from runnerConfig to the CLI's setup`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + setupFiles: './src/app/custom-setup.ts', + }, + }); + `, + ); + + harness.writeFile('src/app/custom-setup.ts', `(globalThis as any).customSetupLoaded = true;`); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { test, expect } from 'vitest'; + test('should have custom setup loaded', () => { + expect((globalThis as any).customSetupLoaded).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it(`should append "test.setupFiles" (array) from runnerConfig to the CLI's setup`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + setupFiles: ['./src/app/custom-setup-1.ts', './src/app/custom-setup-2.ts'], + }, + }); + `, + ); + + harness.writeFile('src/app/custom-setup-1.ts', `(globalThis as any).customSetup1 = true;`); + harness.writeFile('src/app/custom-setup-2.ts', `(globalThis as any).customSetup2 = true;`); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { test, expect } from 'vitest'; + test('should have custom setups loaded', () => { + expect((globalThis as any).customSetup1).toBe(true); + expect((globalThis as any).customSetup2).toBe(true); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should merge and apply custom Vite plugins from runnerConfig file', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + plugins: [ + { + name: 'my-custom-transform-plugin', + transform(code, id) { + if (code.includes('__PLACEHOLDER__')) { + return code.replace('__PLACEHOLDER__', 'transformed by custom plugin'); + } + }, + }, + ], + }); + `, + ); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { test, expect } from 'vitest'; + test('should have been transformed by custom plugin', () => { + const placeholder = '__PLACEHOLDER__'; + expect(placeholder).toBe('transformed by custom plugin'); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should not serve stale cached modules across runs when an imported file changes', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + await harness.writeFiles({ + 'vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + fsModuleCache: true, + fsModuleCachePath: './.vitest-cache', + }, + }); + `, + 'src/app/value.ts': `export const VALUE = 'initial';`, + 'src/app/app.component.spec.ts': ` + import { test, expect } from 'vitest'; + import { VALUE } from './value'; + + test('should use the latest value from imported module', () => { + expect(VALUE).toBe('updated'); + }); + `, + }); + + const firstRun = await harness.executeOnce(); + expect(firstRun.result?.success).toBeFalse(); + + // Update the imported application file without modifying the .spec.ts file on disk + await harness.writeFile('src/app/value.ts', `export const VALUE = 'updated';`); + + const secondRun = await harness.executeOnce(); + expect(secondRun.result?.success).toBeTrue(); + harness.expectDirectory('.vitest-cache').toExist(); + }); + + it('should reuse the cached modules across runs when nothing changes', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + + await harness.writeFiles({ + 'vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + fsModuleCache: true, + fsModuleCachePath: './.vitest-cache', + }, + }); + `, + 'src/app/value.ts': `export const VALUE = 'initial';`, + 'src/app/app.component.spec.ts': ` + import { test, expect } from 'vitest'; + import { VALUE } from './value'; + + test('should use the value from imported module', () => { + expect(VALUE).toBe('initial'); + }); + `, + }); + const cacheEntries = () => + readdirSync(harness.resolvePath('.vitest-cache'), { recursive: true }).sort(); + + const firstRun = await harness.executeOnce(); + expect(firstRun.result?.success).toBeTrue(); + const entriesAfterFirstRun = cacheEntries(); + expect(entriesAfterFirstRun.length).toBeGreaterThan(0); + + // An unchanged build produces the same bundles, so no new cache entry is written. + const secondRun = await harness.executeOnce(); + expect(secondRun.result?.success).toBeTrue(); + expect(cacheEntries()).toEqual(entriesAfterFirstRun); + }); + + it('should not write the module cache when the Angular CLI cache is disabled', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('node_modules/.vitest-cache').toNotExist(); + }); + + it('should not serve the cached bundle of a test entry point once coverage is enabled', async () => { + await harness.writeFiles({ + 'vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + fsModuleCache: true, + fsModuleCachePath: './.vitest-cache', + }, + }); + `, + 'src/app/value.ts': `export const VALUE = 'initial';`, + 'src/app/app.component.spec.ts': ` + import { test, expect } from 'vitest'; + import { VALUE } from './value'; + + test('should use the value from imported module', () => { + expect(VALUE).toBe('initial'); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + }); + const firstRun = await harness.executeOnce(); + expect(firstRun.result?.success).toBeTrue(); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest.config.mts', + coverage: true, + coverageReporters: ['json'] as any, + }); + const secondRun = await harness.executeOnce(); + expect(secondRun.result?.success).toBeTrue(); + + // The application code is only reported when the spec is served as the coverage redirect stub. + const coverageMap = JSON.parse(harness.readFile('coverage/test/coverage-final.json')); + expect(Object.keys(coverageMap).some((file) => file.endsWith('value.ts'))).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-barrel-side-effects_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-barrel-side-effects_spec.ts new file mode 100644 index 000000000000..7a59f09eeaa0 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-barrel-side-effects_spec.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { BASE_OPTIONS, describeBuilder, UNIT_TEST_BUILDER_INFO } from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest barrel sideEffects"', () => { + // Regression test for https://github.com/angular/angular-cli/issues/33910. + it('should retain barrel-exported components when package.json has sideEffects: false', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + project: 'src/lib/ng-package.json', + }, + { + builderName: '@angular/build:ng-packagr', + }, + ); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '@lib': ['./src/lib/public-api.ts'], + '@lib/sub': ['./src/lib/sub/public-api.ts'], + }; + + return JSON.stringify(tsconfig); + }); + + await harness.writeFiles({ + 'src/lib/package.json': JSON.stringify({ + name: '@lib', + version: '0.0.1', + sideEffects: false, + }), + 'src/lib/ng-package.json': JSON.stringify({ + lib: { + entryFile: 'public-api.ts', + }, + }), + 'src/lib/public-api.ts': ` + export * from './components/public-api-components'; + export * from './other/other.component'; + `, + 'src/lib/components/public-api-components.ts': `export * from './foo/foo.component';`, + 'src/lib/components/foo/foo.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-foo', + standalone: true, + template: '

foo

', + }) + export class FooComponent {} + `, + 'src/lib/other/other.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-other', + standalone: true, + template: '

other

', + }) + export class OtherComponent {} + `, + 'src/lib/other/other.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { describe, it, expect } from 'vitest'; + import { OtherComponent } from '@lib'; + + describe('OtherComponent', () => { + it('creates', () => { + TestBed.configureTestingModule({ + imports: [OtherComponent], + }); + const fixture = TestBed.createComponent(OtherComponent); + expect(fixture).toBeTruthy(); + }); + }); + `, + 'src/lib/sub/ng-package.json': JSON.stringify({ + lib: { + entryFile: 'public-api.ts', + }, + }), + 'src/lib/sub/public-api.ts': `export * from './bar/bar.component';`, + 'src/lib/sub/bar/bar.component.ts': ` + import { Component } from '@angular/core'; + import { FooComponent } from '@lib'; + + @Component({ + selector: 'lib-bar', + standalone: true, + imports: [FooComponent], + template: '', + }) + export class BarComponent {} + `, + 'src/lib/sub/bar/bar.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { describe, it, expect } from 'vitest'; + import { BarComponent } from './bar.component'; + + describe('BarComponent', () => { + it('creates', () => { + TestBed.configureTestingModule({ + imports: [BarComponent], + }); + const fixture = TestBed.createComponent(BarComponent); + expect(fixture).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['lib/**/*.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-mock-unsupported_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-mock-unsupported_spec.ts new file mode 100644 index 000000000000..547d6528d86d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-mock-unsupported_spec.ts @@ -0,0 +1,68 @@ +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest mocking unsupported"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + it('should fail when vi.mock is used', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/mock-throw.spec.ts', + ` + import { vi } from 'vitest'; + vi.mock('./something', () => ({})); + `, + ); + + // Overwrite default to avoid noise + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should not fail when vi.mock is used with a non-relative path', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/mock-non-relative.spec.ts', + ` + import { vi } from 'vitest'; + vi.mock('@angular/cdk', () => ({})); + describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); }); + `, + ); + + // Overwrite default to avoid noise + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + describe('Ignored', () => { it('pass', () => expect(true).toBe(true)); }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts new file mode 100644 index 000000000000..1cddec79a14d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest shared chunk initialization"', () => { + it('should provide shared-module exports to class-field initializers in async specs', async () => { + setupApplicationTarget(harness); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + // Keep the default project's spec deterministic; a third spec entry that does not touch + // the shared module does not affect the reproduction (verified in a fresh workspace). + await harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('AppComponent placeholder', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); + }); + `, + ); + + // The shared `const`. Reached from both spec entry points, so it is hoisted into a chunk + // shared between them. + await harness.writeFile( + 'src/environments/env-config.ts', + ` + export interface DealerConfig { + dealerId: string; + clientKey: string; + } + + export const DEALERS: DealerConfig[] = [ + { dealerId: 'dealer-one', clientKey: 'KEY-ONE' }, + { dealerId: 'dealer-two', clientKey: 'KEY-TWO' }, + { dealerId: 'dealer-three', clientKey: 'KEY-THREE' }, + { dealerId: 'dealer-four', clientKey: 'KEY-FOUR' }, + ]; + `, + ); + + // Reached by both spec entries, so it and env-config.ts land in the shared chunk. Reads the + // const inside a method — after module initialization — and is the passing control. + await harness.writeFile( + 'src/app/features/lead-generator/services/lead.service.ts', + ` + import { Injectable } from '@angular/core'; + + import { DEALERS } from '../../../../environments/env-config'; + + @Injectable({ providedIn: 'root' }) + export class LeadService { + resolveClientKey(dealerId: string, clientKey?: string): string { + return clientKey ?? DEALERS.find((d) => d.dealerId === dealerId)?.clientKey ?? ''; + } + } + `, + ); + + await harness.writeFile( + 'src/app/features/lead-generator/services/index.ts', + `export * from './lead.service';\n`, + ); + + // Spec entry point 1 — the second importer that causes the chunk to be shared at all. + await harness.writeFile( + 'src/app/features/lead-generator/services/lead.service.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { TestBed } from '@angular/core/testing'; + + import { LeadService } from './lead.service'; + + describe('LeadService', () => { + it('reads DEALERS inside a method', () => { + TestBed.configureTestingModule({ providers: [LeadService] }); + + expect(TestBed.inject(LeadService).resolveClientKey('dealer-one')).toBe('KEY-ONE'); + }); + }); + `, + ); + + // In the other chunk; reads the shared export eagerly during class-field initialization. + await harness.writeFile( + 'src/app/features/lead-generator/lead-generator.container.ts', + ` + import { Component, inject } from '@angular/core'; + + import { LeadService } from './services'; + import { DEALERS, DealerConfig } from '../../../environments/env-config'; + + @Component({ + selector: 'app-lead-generator', + standalone: true, + template: '', + }) + export class LeadGeneratorContainer { + private readonly leadService = inject(LeadService); + + readonly dealers: DealerConfig[] = DEALERS; + readonly dealerOptions = this.dealers.map((d) => d.dealerId); + + hasService(): boolean { + return this.leadService != null; + } + } + `, + ); + + // Spec entry point 2 — the failing case without the fix. The `async` is load-bearing: + // zone.js makes the builder downlevel it, the spec then imports the `__async` helper, and + // esbuild emits this entry CommonJS-wrapped with the component module behind a lazy + // `__esm` initializer. No `await` is needed; a synchronous callback hides the defect. + await harness.writeFile( + 'src/app/features/lead-generator/lead-generator.container.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { TestBed } from '@angular/core/testing'; + + import { LeadGeneratorContainer } from './lead-generator.container'; + + describe('LeadGeneratorContainer', () => { + it('reads DEALERS in a class-field initialiser', async () => { + const fixture = TestBed.createComponent(LeadGeneratorContainer); + + expect(fixture.componentInstance.dealers.length).toBe(4); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts new file mode 100644 index 000000000000..e19ef884aef5 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts @@ -0,0 +1,250 @@ +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest Zone initialization"', () => { + // Zone.js does not current provide fakAsync support for Vitest + xit('should load Zone and Zone testing support by default', async () => { + setupApplicationTarget(harness); // Defaults include zone.js + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { fakeAsync, tick } from '@angular/core/testing'; + + describe('Zone Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + + it('should support fakeAsync', fakeAsync(() => { + let val = false; + setTimeout(() => { val = true; }, 100); + tick(100); + expect(val).toBeTrue(); + })); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should NOT load Zone when zoneless (no zone.js in polyfills)', async () => { + // Setup application target WITHOUT zone.js in polyfills + setupApplicationTarget(harness, { + polyfills: [], + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Zoneless Test', () => { + it('should NOT have Zone defined', () => { + expect((globalThis as any).Zone).toBeUndefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + + it('should NOT load Zone when test polyfills is empty even if zone.js is in build polyfills', async () => { + setupApplicationTarget(harness, { + polyfills: ['zone.js'], + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Zoneless Override Test', () => { + it('should NOT have Zone defined', () => { + expect((globalThis as any).Zone).toBeUndefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should load Zone when test polyfills includes zone.js even if build polyfills is empty', async () => { + setupApplicationTarget(harness, { + polyfills: [], + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Zone Forced Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should load Zone and emit a deprecation warning when testing a library and zone.js is installed', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + project: 'ng-package.json', + }, + { + builderName: '@angular/build:ng-packagr', + }, + ); + + await harness.writeFile( + 'ng-package.json', + JSON.stringify({ + lib: { + entryFile: 'src/public-api.ts', + }, + }), + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app.component.spec.ts'], + }); + + await harness.writeFile( + 'src/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Library Zone Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectLog(logs, /Zone\.js polyfills are being automatically injected/); + }); + + it('should NOT load Zone and not emit warning when testing a library with polyfills: []', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + project: 'ng-package.json', + }, + { + builderName: '@angular/build:ng-packagr', + }, + ); + + await harness.writeFile( + 'ng-package.json', + JSON.stringify({ + lib: { + entryFile: 'src/public-api.ts', + }, + }), + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + include: ['src/app.component.spec.ts'], + }); + + await harness.writeFile( + 'src/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Library Zoneless Test', () => { + it('should NOT have Zone defined', () => { + expect((globalThis as any).Zone).toBeUndefined(); + }); + }); + `, + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /Zone\.js polyfills are being automatically injected/); + }); + + it('should load Zone and Zone testing support when testing a library using @angular/build:library and zone.js is installed', async () => { + harness.withBuilderTarget( + 'build', + async () => ({ success: true }), + { + tsConfig: 'src/tsconfig.lib.json', + }, + { + builderName: '@angular/build:library', + }, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app.component.spec.ts'], + }); + + await harness.writeFile( + 'src/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Library Zone Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts new file mode 100644 index 000000000000..cab5afd48a2c --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Watch Mode Behavior', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should run tests when a compilation error is fixed and a test failure is introduced simultaneously', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + // 1. Initial success + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // 2. Introduce compilation error + await harness.writeFiles({ + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('AppComponent', () => { + test('should create the app', () => { + expect(true).toBe(true); // Syntax error incoming + const x: string = 1; // Type error + }); + });`, + }); + }, + // 3. Expect compilation error + async ({ result }) => { + expect(result?.success).toBeFalse(); + + // 4. Fix compilation error BUT introduce test failure + await harness.writeFiles({ + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('AppComponent', () => { + test('should create the app', () => { + expect(true).toBe(false); // Logic failure + }); + });`, + }); + }, + // 5. Expect test failure (NOT success, which would happen if the test was skipped) + ({ result }) => { + expect(result?.success).toBeFalse(); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/browsers_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/browsers_spec.ts new file mode 100644 index 000000000000..abd0a8d54e38 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/browsers_spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "browsers"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should use DOM emulation when browsers is not provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + browsers: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should fail when a browser is requested but no provider is installed', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + browsers: ['chrome'], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expectLog( + logs, + `The "browsers" option requires either "@vitest/browser-playwright", "@vitest/browser-webdriverio", or "@vitest/browser-preview" to be installed`, + ); + }); + + it('should name the provider packages when a runner config skips the dependency check', async () => { + await harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({}); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'vitest-base.config.mts', + browsers: ['chrome'], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expectLog( + logs, + `The "browsers" option requires either "@vitest/browser-playwright", "@vitest/browser-webdriverio", or "@vitest/browser-preview" to be installed within the project`, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/build-target_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/build-target_spec.ts new file mode 100644 index 000000000000..1af56e060e3d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/build-target_spec.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + xdescribe('Option: "buildTarget"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when buildTarget is not provided', async () => { + const { buildTarget, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest as any); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError(/"buildTarget" is required/); + }); + + it('should fail when buildTarget is empty', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + buildTarget: '', + }); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError( + /must match "\^\S+:\S+(:\S+)?\$"/, + ); + }); + + it('should fail when buildTarget does not have a project name', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + buildTarget: ':build', + }); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError( + /must match "\^\S+:\S+(:\S+)?\$"/, + ); + }); + + it('should fail when buildTarget does not have a target name', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + buildTarget: 'app:', + }); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError( + /must match "\^\S+:\S+(:\S+)?\$"/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-exclude_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-exclude_spec.ts new file mode 100644 index 000000000000..b92e709d2f12 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-exclude_spec.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "coverageExclude"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + await harness.writeFiles({ + 'src/app/error.ts': `export const a = 1;`, + }); + }); + + it('should not exclude any files from coverage when not provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + coverageInclude: ['**/*.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + const summary = harness.readFile('coverage/test/coverage-final.json'); + expect(summary).toContain('src/app/error.ts"'); + }); + + it('should exclude files from coverage that match the glob pattern', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + coverageInclude: ['**/*.ts'], + coverageExclude: ['**/error.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + const summary = harness.readFile('coverage/test/coverage-final.json'); + expect(summary).not.toContain('src/app/error.ts"'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-include_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-include_spec.ts new file mode 100644 index 000000000000..6cdbc8bfa23b --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-include_spec.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "coverageInclude"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + await harness.writeFiles({ + 'src/app/included.ts': `export const a = 1;`, + 'src/app/included.spec.ts': ` + import { a } from './included'; + describe('included', () => { + it('should work', () => { + expect(a).toBe(1); + }); + }); + `, + 'src/app/excluded.ts': `export const b = 2;`, + }); + }); + + it('should only include and report coverage for files that match the glob pattern', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + coverageInclude: ['**/included.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const summary = JSON.parse(harness.readFile('coverage/test/coverage-final.json')); + const summaryKeys = Object.keys(summary); + + const includedKey = summaryKeys.find((key) => key.endsWith('src/app/included.ts')); + const excludedKey = summaryKeys.find((key) => key.endsWith('src/app/excluded.ts')); + + // Check that the included file is in the report and the excluded one is not. + expect(includedKey).toBeDefined(); + expect(excludedKey).toBeUndefined(); + + // Check that the coverage data for the included file is valid. + const includedCoverage = summary[includedKey!]; + // The file has one statement, and it should have been executed once. + expect(includedCoverage.s['0']).toBe(1); + }); + + it('should only include referenced files when no include pattern is provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + // coverageInclude is not provided, so only referenced files should be included. + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + const summary = JSON.parse(harness.readFile('coverage/test/coverage-final.json')); + const summaryKeys = Object.keys(summary); + + const includedKey = summaryKeys.find((key) => key.endsWith('src/app/included.ts')); + const excludedKey = summaryKeys.find((key) => key.endsWith('src/app/excluded.ts')); + + // The included file is referenced by its spec and should be in the report. + expect(includedKey).toBeDefined(); + // The excluded file is not referenced and should NOT be in the report. + expect(excludedKey).toBeUndefined(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-reporters_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-reporters_spec.ts new file mode 100644 index 000000000000..edebb9e28167 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage-reporters_spec.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "coverageReporters"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should generate a json summary report when specified', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + coverageReporters: ['json-summary'] as any, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(harness.hasFile('coverage/test/coverage-summary.json')).toBeTrue(); + }); + + it('should generate multiple reports when specified', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + coverageReporters: ['json-summary', 'lcov'] as any, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(harness.hasFile('coverage/test/coverage-summary.json')).toBeTrue(); + expect(harness.hasFile('coverage/test/lcov.info')).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/code-coverage_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage_spec.ts new file mode 100644 index 000000000000..d0f51e731de9 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/code-coverage_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "coverage"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should not generate a code coverage report when coverage is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/index.html').toNotExist(); + }); + + it('should generate a code coverage report when coverage is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/index.html').toExist(); + }); + + it('should generate a code coverage report when coverage is true and coverageReporters is set to json', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + coverageReporters: ['json'] as any, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('coverage/test/coverage-final.json').content.toContain('app.component.ts'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/debug_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/debug_spec.ts new file mode 100644 index 000000000000..9028ee0eb5fd --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/debug_spec.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + xdescribe('Option: "debug"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should not enter debug mode when debug is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + debug: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /Node.js inspector/); + }); + + it('should enter debug mode when debug is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + debug: true, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectLog(logs, /Node.js inspector is active/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts new file mode 100644 index 000000000000..2c21f7680716 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "exclude"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + beforeEach(async () => { + await harness.writeFiles({ + 'src/app/error.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('Error spec', () => { + test('should error', () => { + expect(false).toBe(true); + }); + });`, + }); + }); + + it(`should not exclude any spec when exclude is not supplied`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it(`should exclude spec that matches the 'exclude' glob pattern`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + exclude: ['**/error.spec.ts'], + }); + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it(`should exclude spec that matches the 'exclude' pattern with a relative project root`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + exclude: ['src/app/error.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/filter_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/filter_spec.ts new file mode 100644 index 000000000000..abcfe5976f94 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/filter_spec.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../builder'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "filter"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + + await harness.writeFiles({ + 'src/app/pass.spec.ts': ` + describe('Passing Suite', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + }); + `, + 'src/app/fail.spec.ts': ` + describe('Failing Suite', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + }); + }); + + it('should only run tests that match the filter regex', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + // This filter should only match the 'should pass' test + filter: 'pass$', + }); + + const { result } = await harness.executeOnce(); + // The overall result should be success because the failing test was filtered out. + expect(result?.success).toBe(true); + }); + + it('should run all tests when no filter is provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + // The overall result should be failure because the failing test was included. + expect(result?.success).toBe(false); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts new file mode 100644 index 000000000000..4ba7c9eaa051 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts @@ -0,0 +1,281 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "include"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it(`should fail when includes doesn't match any files`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['abc.spec.ts', 'def.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + [ + { + test: 'relative path from workspace to spec', + input: ['src/app/app.component.spec.ts'], + }, + { + test: 'relative path from project root to spec', + input: ['app/services/test.service.spec.ts'], + }, + { + test: 'relative path from workspace to directory', + input: ['src/app/services'], + }, + { + test: 'relative path from project root to directory', + input: ['app/services'], + }, + { + test: 'glob with spec suffix', + input: ['**/*.pipe.spec.ts', '**/*.pipe.spec.ts', '**/*test.service.spec.ts'], + }, + ].forEach((options, index) => { + it(`should work with ${options.test} (${index})`, async () => { + await harness.writeFiles({ + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + 'src/app/failing.service.spec.ts': ` + describe('FailingService', () => { + it('should be ignored', () => { + expect(true).toBe(false); + }); + });`, + 'src/app/property.pipe.spec.ts': ` + describe('PropertyPipe', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: options.input, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); + + it('should ignore TypeScript compilation errors in non-included test files', async () => { + await harness.writeFiles({ + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + 'src/app/broken.service.spec.ts': ` + // This test has a TypeScript type error that would fail compilation if compiled + const invalidNumber: number = 'not a number'; + describe('BrokenService', () => { + it('should fail compilation', () => { + expect(invalidNumber).toBe(1); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/services/test.service.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should not compile unimported application files when testing standalone code', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['jasmine', '@angular/localize'], + }, + files: ['polyfills.ts'], + include: ['**/*.ts', '**/*.d.ts'], + }), + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + 'src/app/unused-broken.ts': ` + // This unimported file has a TypeScript error and should not be compiled for standalone tests + export const invalidNumber: number = 'not a number'; + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/services/test.service.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should resolve NgModule scope when testing a non-standalone component without importing the NgModule', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['jasmine', '@angular/localize'], + }, + files: ['polyfills.ts'], + include: ['**/*.ts', '**/*.d.ts'], + }), + 'src/app/app.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello, {{ title }}

', + standalone: false, + }) + export class App { + title = 'test'; + } + `, + 'src/app/app-module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + @NgModule({ + declarations: [App], + imports: [BrowserModule, RouterModule.forRoot([])], + bootstrap: [App], + }) + export class AppModule {} + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([])], + declarations: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/app.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should not compile unimported files when a non-standalone component NgModule is imported by the test', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['jasmine', '@angular/localize'], + }, + files: ['polyfills.ts'], + include: ['**/*.ts', '**/*.d.ts'], + }), + 'src/app/app.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello, {{ title }}

', + standalone: false, + }) + export class App { + title = 'test'; + } + `, + 'src/app/app-module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + @NgModule({ + declarations: [App], + imports: [BrowserModule, RouterModule.forRoot([])], + bootstrap: [App], + }) + export class AppModule {} + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; + import { AppModule } from './app-module'; + + describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppModule], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + 'src/app/unused-broken.ts': ` + // This unimported file has a TypeScript error and should not be compiled when AppModule is already imported + export const invalidNumber: number = 'not a number'; + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/app.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/isolate_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/isolate_spec.ts new file mode 100644 index 000000000000..28fbb855a91f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/isolate_spec.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "isolate"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when isolate is true and runner is karma', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'karma' as any, + isolate: true, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expectLog(logs, /The "isolate" option is only available for the "vitest" runner/); + }); + + it('should run tests successfully when isolate is true and runner is vitest', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'vitest' as any, + isolate: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when isolate is false and runner is vitest', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'vitest' as any, + isolate: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should override isolate from the Vitest config file when set to false', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + isolate: true, + setupFiles: ['./src/setup.ts'], + }, + }); + `, + ); + + harness.writeFile( + 'src/setup.ts', + ` + const global = globalThis as typeof globalThis & { setupCount?: number }; + global.setupCount = (global.setupCount ?? 0) + 1; + `, + ); + + harness.writeFile( + 'src/app/first.spec.ts', + ` + it('runs first', () => { + expect(true).toBe(true); + }); + `, + ); + + harness.writeFile( + 'src/app/second.spec.ts', + ` + it('shares the test environment with the first spec file', () => { + const global = globalThis as typeof globalThis & { setupCount?: number }; + expect(global.setupCount).toBeGreaterThan(1); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'vitest' as any, + runnerConfig: true, + isolate: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts new file mode 100644 index 000000000000..ade399d1936f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "listTests"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + + await harness.writeFiles({ + 'src/app/app.component.spec.ts': + 'describe("AppComponent", () => { it("should...", () => {}); });', + 'src/app/other.spec.ts': 'describe("Other", () => { it("should...", () => {}); });', + 'src/app/ignored.ts': 'export const a = 1;', + }); + }); + + it('should list all discovered tests and exit when true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + listTests: true, + }); + + const { result, logs } = await harness.executeOnce(); + + // Should succeed and exit without running tests + expect(result?.success).toBe(true); + + // Should log the discovered test files + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/Discovered test files:/) }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/.*app\.component\.spec\.ts/), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/.*other\.spec\.ts/) }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/.*ignore\.ts/) }), + ); + + // Should NOT log output from the test runner (since it shouldn't run) + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Application bundle generation complete/), + }), + ); + }); + + it('should not list tests and should run them as normal when false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + listTests: false, + }); + + const { result, logs } = await harness.executeOnce(); + + // Should succeed because the tests pass + expect(result?.success).toBe(true); + + // Should NOT log the discovered test files + expect(logs).not.toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/Discovered test files:/) }), + ); + + // Should log output from the test runner + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Application bundle generation complete/), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts new file mode 100644 index 000000000000..06bc3bd8fc98 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "polyfills"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should include polyfills specified on test target', async () => { + await harness.writeFiles({ + 'src/custom-polyfill.js': `globalThis['CUSTOM_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Polyfill Test', () => { + test('should have run test polyfill', () => { + expect((globalThis as any)['CUSTOM_POLYFILL_RAN']).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['src/custom-polyfill.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should override buildTarget polyfills when polyfills is specified on test target', async () => { + setupApplicationTarget(harness, { + polyfills: ['src/app-polyfill.js'], + }); + + await harness.writeFiles({ + 'src/app-polyfill.js': `globalThis['APP_POLYFILL_RAN'] = true;`, + 'src/test-polyfill.js': `globalThis['TEST_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Polyfill Override Test', () => { + test('should have run test polyfill and not app polyfill', () => { + expect((globalThis as any)['TEST_POLYFILL_RAN']).toBe(true); + expect((globalThis as any)['APP_POLYFILL_RAN']).toBeUndefined(); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['src/test-polyfill.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow overriding buildTarget polyfills with an empty array', async () => { + setupApplicationTarget(harness, { + polyfills: ['src/app-polyfill.js'], + }); + + await harness.writeFiles({ + 'src/app-polyfill.js': `globalThis['APP_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Empty Polyfill Override Test', () => { + test('should not have run app polyfill', () => { + expect((globalThis as any)['APP_POLYFILL_RAN']).toBeUndefined(); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/providers-file_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/providers-file_spec.ts new file mode 100644 index 000000000000..6485975270e8 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/providers-file_spec.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "providersFile"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when providersFile does not exist', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + providersFile: 'src/my.providers.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Could not resolve "./my.providers"'), + }), + ); + }); + + it('should use providers from the specified file', async () => { + await harness.writeFiles({ + 'src/my.providers.ts': ` + import { importProvidersFrom } from '@angular/core'; + import { CommonModule } from '@angular/common'; + export default [importProvidersFrom(CommonModule)]; + `, + }); + + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('my.providers.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + providersFile: 'src/my.providers.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should work with providers that inject services requiring JIT compilation', async () => { + await harness.writeFiles({ + 'src/test.service.ts': ` + import { Injectable, inject } from '@angular/core'; + import { Router } from '@angular/router'; + + @Injectable({ providedIn: 'root' }) + export class TestService { + router = inject(Router); + } + `, + 'src/test.providers.ts': ` + import { TestService } from './test.service'; + export default [TestService]; + `, + 'src/app/app.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { AppComponent } from './app.component'; + import { TestService } from '../test.service'; + import { describe, expect, it } from 'vitest'; + + describe('AppComponent', () => { + it('should create the app and inject TestService', () => { + TestBed.configureTestingModule({ + declarations: [AppComponent], + }); + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + const testService = TestBed.inject(TestService); + expect(app).toBeTruthy(); + expect(testService).toBeTruthy(); + expect(testService.router).toBeTruthy(); + }); + }); + `, + }); + + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('test.service.ts', 'test.providers.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + providersFile: 'src/test.providers.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/quiet_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/quiet_spec.ts new file mode 100644 index 000000000000..1ca8281db8b1 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/quiet_spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "quiet"', () => { + let originalCI: string | undefined; + + beforeEach(async () => { + setupApplicationTarget(harness); + originalCI = process.env['CI']; + }); + + afterEach(() => { + if (originalCI !== undefined) { + process.env['CI'] = originalCI; + } else { + delete process.env['CI']; + } + }); + + it('should default to true (quiet) when CI is not set', async () => { + delete process.env['CI']; + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // Should not contain the stats table headers + expectNoLog(logs, /Initial chunk files/); + }); + + it('should default to false (verbose) when CI is set', async () => { + process.env['CI'] = 'true'; + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // Should contain the stats table headers or file listing + expectLog(logs, /Application bundle generation complete/); + }); + + it('should respect quiet: true explicitly', async () => { + process.env['CI'] = 'false'; // Ensure CI doesn't interfere if it defaults to false + + harness.useTarget('test', { + ...BASE_OPTIONS, + quiet: true, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /Initial chunk files/); + }); + + it('should respect quiet: false explicitly', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // On initial build, it should print the file list + expectLog(logs, /Initial/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/reporter_and_output_file_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/reporter_and_output_file_spec.ts new file mode 100644 index 000000000000..31fc1a7bfe95 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/reporter_and_output_file_spec.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Options: "reporter" and "outputFile"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it(`should output a JSON report`, async () => { + await harness.removeFile('src/app/app.component.spec.ts'); + await harness.writeFiles({ + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: ['json'], + outputFile: 'test-report.json', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + const reportContent = await harness.readFile('test-report.json'); + expect(reportContent).toContain('TestService'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/reporters_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/reporters_spec.ts new file mode 100644 index 000000000000..89ac5804e37e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/reporters_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "reporters"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + it(`should support a single reporter`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: ['json'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it(`should support multiple reporters`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: ['json', 'verbose'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it(`should support a single reporter with options`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: [['json', { outputFile: 'a.json' }]], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('a.json').toExist(); + }); + + it(`should support multiple reporters with options`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: [ + ['json', { outputFile: 'a.json' }], + ['junit', { outputFile: 'a.xml' }], + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('a.json').toExist(); + harness.expectFile('a.xml').toExist(); + }); + + it(`should support multiple reporters with and without options`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: [['json', { outputFile: 'a.json' }], 'verbose', 'default'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('a.json').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner-config-coverage_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner-config-coverage_spec.ts new file mode 100644 index 000000000000..9998caea72e3 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner-config-coverage_spec.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "runnerConfig" Coverage Merging', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + describe('Vitest Runner', () => { + it('should preserve thresholds from Vitest config when not overridden by CLI', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + thresholds: { + branches: 100 + } + } + } + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + coverage: true, + }); + + const { result } = await harness.executeOnce(); + + // Should fail because branches are not 100% + expect(result?.success).toBeFalse(); + }); + + it('should override Vitest config thresholds with CLI thresholds', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + thresholds: { + branches: 100 + } + } + } + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + coverage: true, + coverageThresholds: { + branches: 0, + }, + }); + + const { result } = await harness.executeOnce(); + + // Should pass because CLI overrides threshold to 0 + expect(result?.success).toBeTrue(); + }); + + it('should merge partial CLI thresholds with Vitest config thresholds', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + thresholds: { + statements: 100, + branches: 100 + } + } + } + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + coverage: true, + coverageThresholds: { + statements: 0, + // branches is undefined here, should remain 100 from config + }, + }); + + const { result } = await harness.executeOnce(); + + // Should still fail because branches threshold (100) is not met + expect(result?.success).toBeFalse(); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts new file mode 100644 index 000000000000..57ec6be0f25d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +const VITEST_CONFIG_CONTENT = ` +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + reporters: [['junit', { outputFile: './vitest-results.xml' }]], + }, +}); +`; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "runnerConfig"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + describe('Vitest Runner', () => { + it('should use a specified config file path', async () => { + harness.writeFile('custom-vitest.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'custom-vitest.config.mts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should search for a config file when `true`', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should ignore config file when `false`', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + + it('should ignore config file by default', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + + it('should find and use a `vitest-base.config.mts` in the project root', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should find and use a `vitest-base.config.mjs` in the workspace root', async () => { + // This file should be ignored because the new logic looks for `vitest-base.config.*`. + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + // The workspace root is the directory containing the project root in the test harness. + harness.writeFile('vitest-base.config.mjs', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should fallback to in-memory config when no base config is found', async () => { + // This file should be ignored because the new logic looks for `vitest-base.config.*` + // and when `runnerConfig` is true, it should not fall back to the default search. + harness.writeFile('vitest.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner_spec.ts new file mode 100644 index 000000000000..77479149784c --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner_spec.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + xdescribe('Option: "runner"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when runner is not provided', async () => { + const { runner, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest as any); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError(/"runner" is required/); + }); + + it('should fail when runner is invalid', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'invalid' as any, + }); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError( + /must be one of the following values: "karma", "vitest"/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts new file mode 100644 index 000000000000..44241bbcfd8a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "setupFiles"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when a setup file does not exist', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + setupFiles: ['src/setup.ts'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + // Verify that the build failed due to resolution error (esbuild error) + expectLog(logs, /Could not resolve/); + expectLog(logs, /src\/setup\.ts/); + }); + + it('should include the setup files', async () => { + await harness.writeFiles({ + 'src/setup.ts': `(globalThis as any)['TEST_SETUP_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('AppComponent', () => { + test('should have run setup file', () => { + expect((globalThis as any)['TEST_SETUP_RAN']).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run setup file hooks for each spec file when coverage is enabled', async () => { + await harness.writeFiles({ + 'custom-vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + }, + }); + `, + 'src/setup.ts': ` + import { afterEach, beforeEach, expect } from 'vitest'; + const global = globalThis as typeof globalThis & { + setupHookCalls?: string[]; + }; + const setupHookCalls = (global.setupHookCalls ??= []); + beforeEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('beforeEach:' + testName); + }); + afterEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('afterEach:' + testName); + }); + `, + 'src/app/app.component.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in app.component.spec'); + }); + it('runs setup hooks for second test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in app.component.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in app.component.spec'); + }); + `, + 'src/app/second.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in second.spec'); + }); + it('runs setup hooks for second test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in second.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in second.spec'); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'custom-vitest.config.mts', + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts new file mode 100644 index 000000000000..133c54ca21e9 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "splitting"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should default to true and run tests successfully', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when splitting is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when splitting is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should split shared code into a separate chunk when splitting is true', async () => { + await harness.writeFile( + 'src/app/shared.ts', + `export const SHARED_DATA = 'shared-data-value';`, + ); + + await harness.writeFile( + 'src/app/first.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests first', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + await harness.writeFile( + 'src/app/second.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests second', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: true, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectLog(logs, /chunk-[a-z0-9]+\.js/i); + }); + + it('should not split shared code into a separate chunk when splitting is false', async () => { + await harness.writeFile( + 'src/app/shared.ts', + `export const SHARED_DATA = 'shared-data-value';`, + ); + + await harness.writeFile( + 'src/app/first.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests first', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + await harness.writeFile( + 'src/app/second.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests second', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: false, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /chunk-[a-z0-9]+\.js/i); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts new file mode 100644 index 000000000000..101f07c1d12e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "tsConfig"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should use "tsconfig.spec.json" by default when it exists', async () => { + const { tsConfig, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest); + + // Create tsconfig.spec.json + await harness.writeFile( + 'tsconfig.spec.json', + `{ "extends": "./tsconfig.json", "compilerOptions": { "types": ["jasmine"] }, "include": ["src/**/*.ts"] }`, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // TODO: Add expectation that the file was used. + }); + + it('should use build target tsConfig when "tsconfig.spec.json" does not exist', async () => { + const { tsConfig, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest); + + // The build target tsconfig is not setup to build the tests and should fail + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should fail when user specified tsConfig does not exist', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + tsConfig: 'random/tsconfig.spec.json', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expectLog(logs, `The specified tsConfig file 'random/tsconfig.spec.json' does not exist.`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/watch_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/watch_spec.ts new file mode 100644 index 000000000000..79e25ca84b27 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/watch_spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "watch"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should run tests once when watch is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should re-run tests when a file changes when watch is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + await harness.writeFiles({ + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('AppComponent', () => { + test('should create the app', () => { + expect(true).toBe(false); + }); + });`, + }); + }, + ({ result }) => { + expect(result?.success).toBeFalse(); + }, + ]); + }); + + it('should disable the Vite server file watcher when watch is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: true, + runnerConfig: 'vitest.config.mts', + }); + + await harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + plugins: [ + { + name: 'assert-watch-disabled', + configureVitest({ vitest, project }) { + if (vitest.vite.config.server.watch !== null || project.vite.config.server.watch !== null) { + throw new Error('Expected Vite server.watch to be null'); + } + }, + }, + ], + }); + `, + ); + + await harness.executeWithCases([ + ({ result }) => { + expect(result?.success).toBeTrue(); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/setup.ts b/packages/angular/build/src/builders/unit-test/tests/setup.ts new file mode 100644 index 000000000000..e6770e115789 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/setup.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { json } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { BuilderHarness } from '../../../../../../../modules/testing/builder/src'; +import { + ApplicationBuilderOptions as ApplicationSchema, + buildApplication, +} from '../../../builders/application'; +import { Runner, Schema } from '../schema'; + +// TODO: Consider using package.json imports field instead of relative path +// after the switch to rules_js. +export * from '../../../../../../../modules/testing/builder/src'; + +// TODO: Remove and use import after Vite-based dev server is moved to new package +export const APPLICATION_BUILDER_INFO = Object.freeze({ + name: '@angular/build:application', + schemaPath: path.join( + path.dirname(require.resolve('@angular/build/package.json')), + 'src/builders/application/schema.json', + ), +}); + +/** + * Contains all required application builder fields. + * Also disables progress reporting to minimize logging output. + */ +export const APPLICATION_BASE_OPTIONS = Object.freeze({ + index: 'src/index.html', + browser: 'src/main.ts', + outputPath: 'dist', + tsConfig: 'src/tsconfig.app.json', + progress: false, + + // Disable optimizations + optimization: false, + + // Enable polling (if a test enables watch mode). + // This is a workaround for bazel isolation file watch not triggering in tests. + poll: 100, +}); + +export const UNIT_TEST_BUILDER_INFO = Object.freeze({ + name: '@angular/build:unit-test', + schemaPath: __dirname + '/../schema.json', +}); + +/** + * Contains all required dev-server builder fields. + * The port is also set to zero to ensure a free port is used for each test which + * supports parallel test execution. + */ +export const BASE_OPTIONS = Object.freeze({ + buildTarget: 'test:build', + tsConfig: 'src/tsconfig.spec.json', + runner: Runner.Vitest, +}); + +/** + * Maximum time for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 25_000; + +/** + * Cached application builder option schema + */ +let applicationSchema: json.schema.JsonSchema | undefined; + +/** + * Adds a `build` target to a builder test harness for the application builder with the base options + * used by the application builder tests. + * + * @param harness The builder harness to use when setting up the application builder target + * @param extraOptions The additional options that should be used when executing the target. + */ +export function setupApplicationTarget( + harness: BuilderHarness, + extraOptions?: Partial, +): void { + applicationSchema ??= JSON.parse( + readFileSync(APPLICATION_BUILDER_INFO.schemaPath, 'utf8'), + ) as json.schema.JsonSchema; + + harness.withBuilderTarget( + 'build', + buildApplication, + { + ...APPLICATION_BASE_OPTIONS, + polyfills: ['zone.js', '@angular/localize/init'], + ...extraOptions, + }, + { + builderName: APPLICATION_BUILDER_INFO.name, + optionSchema: applicationSchema, + }, + ); +} diff --git a/packages/angular/build/src/index.ts b/packages/angular/build/src/index.ts new file mode 100644 index 000000000000..b6a547921db8 --- /dev/null +++ b/packages/angular/build/src/index.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export { buildApplication, type ApplicationBuilderOptions } from './builders/application'; +export type { ApplicationBuilderExtensions } from './builders/application/options'; +export { type BuildOutputFile, BuildOutputFileType } from './tools/esbuild/bundler-files'; +export type { BuildOutputAsset } from './tools/esbuild/bundler-execution-result'; + +export { + executeDevServerBuilder, + type DevServerBuilderOptions, + type DevServerBuilderOutput, +} from './builders/dev-server'; + +export { + execute as executeExtractI18nBuilder, + type ExtractI18nBuilderOptions, +} from './builders/extract-i18n'; + +export { + execute as executeNgPackagrBuilder, + type NgPackagrBuilderOptions, +} from './builders/ng-packagr'; +export { + execute as executeUnitTestBuilder, + type UnitTestBuilderOptions, +} from './builders/unit-test'; + +export { execute as executeKarmaBuilder, type KarmaBuilderOptions } from './builders/karma'; diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts new file mode 100644 index 000000000000..e95ad26af3fc --- /dev/null +++ b/packages/angular/build/src/private.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview + * Private exports intended only for use with the @angular-devkit/build-angular package. + * All exports are not supported for external use, do not provide SemVer guarantees, and + * their existence may change in any future version. + */ + +import { createAngularCompilation } from './tools/angular/compilation'; +import { SecondaryCompilationContext } from './tools/esbuild/angular/compilation-state'; +import { + CompilerPluginOptions, + createCompilerPlugin as internalCreateCompilerPlugin, +} from './tools/esbuild/angular/compiler-plugin'; +import { ComponentStylesheetBundler } from './tools/esbuild/angular/component-stylesheets'; +import { BundleStylesheetOptions } from './tools/esbuild/stylesheets/bundle-options'; + +// Builders +export { buildApplicationInternal } from './builders/application'; +export type { ApplicationBuilderInternalOptions } from './builders/application/options'; +export { type Result, type ResultFile, ResultKind } from './builders/application/results'; +export { serveWithVite } from './builders/dev-server/vite'; +export { + normalizeOptions as normalizeDevServerOptions, + type NormalizedDevServerOptions, +} from './builders/dev-server/options'; + +// Tools +export type { ExternalResultMetadata } from './tools/esbuild/bundler-execution-result'; +export { transformSupportedBrowsersToTargets } from './tools/esbuild/target'; +export { SassCompiler } from './tools/sass/sass-service'; + +export { SourceFileCache } from './tools/esbuild/angular/source-file-cache'; +export { Cache } from './utils/cache'; +export { createJitResourceTransformer } from './tools/angular/transformers/jit-resource-transformer'; +export { JavaScriptTransformer } from './tools/javascript-transformer'; + +export function createCompilerPlugin( + pluginOptions: CompilerPluginOptions & { + browserOnlyBuild?: boolean; + noopTypeScriptCompilation?: boolean; + }, + styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string }, +): import('esbuild').Plugin { + return internalCreateCompilerPlugin( + pluginOptions, + pluginOptions.noopTypeScriptCompilation + ? new SecondaryCompilationContext() + : () => createAngularCompilation(!!pluginOptions.jit, !!pluginOptions.browserOnlyBuild), + new ComponentStylesheetBundler( + styleOptions, + styleOptions.inlineStyleLanguage, + pluginOptions.incremental, + ), + ); +} + +export type { CompilerPluginOptions, BundleStylesheetOptions }; +export type { AngularCompilation } from './tools/angular/compilation'; +export { DiagnosticModes } from './tools/angular/compilation'; +export { createAngularCompilation }; +export { ComponentStylesheetBundler } from './tools/esbuild/angular/component-stylesheets'; + +// Utilities +export * from './utils/bundle-calculator'; +export { checkPort } from './utils/check-port'; +export { deleteOutputDir } from './utils/delete-output-dir'; +export { emitFilesToDisk } from './utils/disk-files'; +export { type I18nOptions, createI18nOptions, loadTranslations } from './utils/i18n-options'; +export { + IndexHtmlGenerator, + type IndexHtmlGeneratorOptions, + type IndexHtmlGeneratorProcessOptions, + type IndexHtmlTransform, +} from './utils/index-file/index-html-generator'; +export type { FileInfo } from './utils/index-file/augment-index-html'; +export { loadProxyConfiguration } from './utils/load-proxy-config'; +export { type TranslationLoader, createTranslationLoader } from './utils/load-translations'; +export { purgeStaleBuildCache } from './utils/purge-cache'; +export { augmentAppWithServiceWorker } from './utils/service-worker'; +export { type BundleStats, generateBuildStatsTable } from './utils/stats-table'; +export { getSupportedBrowsers } from './utils/supported-browsers'; +export { assertCompatibleAngularVersion } from './utils/version'; +export { findTests, getTestEntrypoints } from './builders/karma/find-tests'; +export { + findTailwindConfiguration, + generateSearchDirectories, + loadPostcssConfiguration, +} from './utils/postcss-configuration'; diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts new file mode 100644 index 000000000000..214666908ddf --- /dev/null +++ b/packages/angular/build/src/tools/angular/angular-host.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import nodePath from 'node:path'; +import type ts from 'typescript'; +import { calculateHash } from '../../utils/hash'; + +export type AngularCompilerOptions = ng.CompilerOptions; +export type AngularCompilerHost = ng.CompilerHost; + +export interface AngularHostOptions { + fileReplacements?: Record; + modifiedFiles?: Set; + externalStylesheets?: Map; + transformStylesheet( + data: string, + containingFile: string, + stylesheetFile?: string, + order?: number, + className?: string, + ): Promise; + processWebWorker(workerFile: string, containingFile: string): string; +} + +/** + * Patches in-place the `getSourceFiles` function on an instance of a TypeScript + * `Program` to ensure that all returned SourceFile instances have a `version` + * field. The `version` field is required when used with a TypeScript BuilderProgram. + * @param program The TypeScript Program instance to patch. + */ +export function ensureSourceFileVersions( + program: ts.Program, + previousProgram?: ts.BuilderProgram, +): void { + const baseGetSourceFiles = program.getSourceFiles; + // TODO: Update Angular compiler to add versions to all internal files and remove this + program.getSourceFiles = function (...parameters) { + const files: readonly (ts.SourceFile & { version?: string })[] = baseGetSourceFiles( + ...parameters, + ); + + for (const file of files) { + if (file.version === undefined) { + const previousFile: (ts.SourceFile & { version?: string }) | undefined = + previousProgram && !file.fileName.endsWith('.ngtypecheck.ts') + ? previousProgram.getSourceFile(file.fileName) + : undefined; + file.version = previousFile?.version ?? calculateHash(file.text); + } + } + + return files; + }; +} + +function augmentHostWithCaching(host: ts.CompilerHost, cache: Map): void { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function ( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + ...parameters + ) { + if (!shouldCreateNewSourceFile && cache.has(fileName)) { + return cache.get(fileName); + } + + const file = baseGetSourceFile.call( + host, + fileName, + languageVersion, + onError, + true, + ...parameters, + ); + + if (file) { + cache.set(fileName, file); + } + + return file; + }; +} + +function augmentResolveModuleNames( + typescript: typeof ts, + host: ts.CompilerHost, + resolvedModuleModifier: ( + resolvedModule: ts.ResolvedModule | undefined, + moduleName: string, + ) => ts.ResolvedModule | undefined, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (host.resolveModuleNames) { + const baseResolveModuleNames = host.resolveModuleNames; + host.resolveModuleNames = function (moduleNames: string[], ...parameters) { + return moduleNames.map((name) => { + const result = baseResolveModuleNames.call(host, [name], ...parameters); + + return resolvedModuleModifier(result[0], name); + }); + }; + } else { + host.resolveModuleNames = function ( + moduleNames: string[], + containingFile: string, + _reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = typescript.resolveModuleName( + name, + containingFile, + options, + host, + moduleResolutionCache, + redirectedReference, + ).resolvedModule; + + return resolvedModuleModifier(result, name); + }); + }; + } +} + +function normalizePath(path: string): string { + return nodePath.win32.normalize(path).replace(/\\/g, nodePath.posix.sep); +} + +function augmentHostWithReplacements( + typescript: typeof ts, + host: ts.CompilerHost, + replacements: Record, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (Object.keys(replacements).length === 0) { + return; + } + + const normalizedReplacements: Record = {}; + for (const [key, value] of Object.entries(replacements)) { + normalizedReplacements[normalizePath(key)] = normalizePath(value); + } + + const tryReplace = (resolvedModule: ts.ResolvedModule | undefined) => { + const replacement = resolvedModule && normalizedReplacements[resolvedModule.resolvedFileName]; + if (replacement) { + return { + resolvedFileName: replacement, + isExternalLibraryImport: /[/\\]node_modules[/\\]/.test(replacement), + }; + } else { + return resolvedModule; + } + }; + + augmentResolveModuleNames(typescript, host, tryReplace, moduleResolutionCache); +} + +export function createAngularCompilerHost( + typescript: typeof ts, + compilerOptions: AngularCompilerOptions, + hostOptions: AngularHostOptions, + packageJsonCache: ts.PackageJsonInfoCache | undefined, + sourceFileCache?: Map, +): AngularCompilerHost { + // Create TypeScript compiler host + const host: AngularCompilerHost = typescript.createIncrementalCompilerHost(compilerOptions); + // Set the parsing mode to the same as TS 5.3+ default for tsc. This provides a parse + // performance improvement by skipping non-type related JSDoc parsing. + host.jsDocParsingMode = typescript.JSDocParsingMode.ParseForTypeErrors; + + // The AOT compiler currently requires this hook to allow for a transformResource hook. + // Once the AOT compiler allows only a transformResource hook, this can be reevaluated. + host.readResource = async function (filename) { + // Only template resources need to be read by the compiler host. + // External stylesheets are read and bundled by the stylesheet bundler during `transformResource`. + if (!hasTemplateExtension(filename)) { + return ''; + } + + return this.readFile(filename) ?? ''; + }; + + // Add an AOT compiler resource transform hook + host.transformResource = async function (data, context) { + // Only style resources are transformed currently + if (context.type !== 'style') { + return null; + } + + assert( + !context.resourceFile || !hostOptions.externalStylesheets?.has(context.resourceFile), + 'External runtime stylesheets should not be transformed: ' + context.resourceFile, + ); + + // No transformation required if an inline resource is empty + if (!context.resourceFile && data.trim().length === 0) { + return { content: '' }; + } + + const result = await hostOptions.transformStylesheet( + data, + context.containingFile, + context.resourceFile ?? undefined, + context.order, + context.className, + ); + + return typeof result === 'string' ? { content: result } : null; + }; + + host.resourceNameToFileName = function (resourceName, containingFile) { + const resolvedPath = nodePath.join(nodePath.dirname(containingFile), resourceName); + if (!this.fileExists(resolvedPath)) { + return null; + } + + // Reject TypeScript files used as component resources (e.g., styleUrl pointing to a .ts file). + // Processing a TypeScript file as a stylesheet or template causes confusing downstream errors. + if (hasTypeScriptExtension(resolvedPath)) { + return null; + } + + // All resource names that have template file extensions are assumed to be templates + // TODO: Update compiler to provide the resource type to avoid extension matching here. + if (!hostOptions.externalStylesheets || hasTemplateExtension(resolvedPath)) { + return resolvedPath; + } + + // For external stylesheets, create a unique identifier and store the mapping + let externalId = hostOptions.externalStylesheets.get(resolvedPath); + if (externalId === undefined) { + externalId = calculateHash(resolvedPath); + hostOptions.externalStylesheets.set(resolvedPath, externalId); + } + + return externalId + '.css'; + }; + + // Allow the AOT compiler to request the set of changed templates and styles + host.getModifiedResourceFiles = function () { + return hostOptions.modifiedFiles; + }; + + // Provide a resolution cache to ensure package.json lookups are cached + const resolutionCache = typescript.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + compilerOptions, + packageJsonCache, + ); + host.getModuleResolutionCache = () => resolutionCache; + + // Augment TypeScript Host for file replacements option + if (hostOptions.fileReplacements) { + augmentHostWithReplacements(typescript, host, hostOptions.fileReplacements, resolutionCache); + } + + // Augment TypeScript Host with source file caching if provided + if (sourceFileCache) { + augmentHostWithCaching(host, sourceFileCache); + } + + return host; +} + +const TEMPLATE_EXTENSIONS: ReadonlySet = new Set(['.htm', '.html', '.svg']); +const TYPESCRIPT_EXTENSIONS: ReadonlySet = new Set(['.ts', '.tsx', '.mts', '.cts']); + +function hasTemplateExtension(file: string): boolean { + return TEMPLATE_EXTENSIONS.has(nodePath.extname(file).toLowerCase()); +} + +function hasTypeScriptExtension(file: string): boolean { + return TYPESCRIPT_EXTENSIONS.has(nodePath.extname(file).toLowerCase()); +} diff --git a/packages/angular/build/src/tools/angular/angular-host_spec.ts b/packages/angular/build/src/tools/angular/angular-host_spec.ts new file mode 100644 index 000000000000..ab2b6151aed0 --- /dev/null +++ b/packages/angular/build/src/tools/angular/angular-host_spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import ts from 'typescript'; +import { type AngularHostOptions, createAngularCompilerHost } from './angular-host'; + +describe('createAngularCompilerHost', () => { + function createHost() { + const transformStylesheet = jasmine + .createSpy('transformStylesheet') + .and.resolveTo('.compiled {}'); + const processWebWorker = jasmine + .createSpy('processWebWorker') + .and.returnValue('worker.js'); + + const host = createAngularCompilerHost( + ts, + {}, + { + transformStylesheet, + processWebWorker, + }, + undefined, + ); + + return { host, transformStylesheet }; + } + + describe('readResource', () => { + it('should not call readFile for .css and .scss stylesheet files', async () => { + const { host } = createHost(); + const readFileSpy = spyOn(host, 'readFile').and.returnValue('.btn { color: red; }'); + + expect(await host.readResource?.('/app/src/app.component.css')).toBe(''); + expect(await host.readResource?.('/app/src/app.component.scss')).toBe(''); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + + it('should call readFile for .html and .svg template files', async () => { + const { host } = createHost(); + const readFileSpy = spyOn(host, 'readFile').and.callFake( + (fileName) => ``, + ); + + expect(await host.readResource?.('/app/src/app.component.html')).toBe( + '', + ); + expect(await host.readResource?.('/app/src/icon.component.svg')).toBe( + '', + ); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('transformResource', () => { + it('should invoke transformStylesheet when context.resourceFile is set even if data is empty', async () => { + const { host, transformStylesheet } = createHost(); + + const result = await host.transformResource?.('', { + type: 'style', + containingFile: '/app/src/app.component.ts', + resourceFile: '/app/src/app.component.scss', + order: 0, + className: 'AppComponent', + }); + + expect(result).toEqual({ content: '.compiled {}' }); + expect(transformStylesheet).toHaveBeenCalledOnceWith( + '', + '/app/src/app.component.ts', + '/app/src/app.component.scss', + 0, + 'AppComponent', + ); + }); + + it('should short-circuit empty inline styles without calling transformStylesheet', async () => { + const { host, transformStylesheet } = createHost(); + + const result = await host.transformResource?.(' ', { + type: 'style', + containingFile: '/app/src/app.component.ts', + resourceFile: null, + order: 0, + className: 'AppComponent', + }); + + expect(result).toEqual({ content: '' }); + expect(transformStylesheet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts new file mode 100644 index 000000000000..3da78cdf0275 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { PartialMessage } from 'esbuild'; +import type { AngularHostOptions } from '../angular-host'; +import type { CompilerOptionOverrides } from './compiler-options'; + +export interface EmitFileResult { + filename: string; + contents: string; + dependencies?: readonly string[]; +} + +export interface FileTransformResult { + contents: string; + watchFiles?: readonly string[]; +} + +export interface AngularCompilationOptions { + allowJs?: boolean; + compilationMode?: 'full' | 'partial' | 'experimental-local'; + declarationMap?: boolean; + isolatedModules?: boolean; + sourceMap?: boolean; + inlineSourceMap?: boolean; + _useTypeScriptTranspilation?: boolean; + [key: string]: unknown; +} + +export interface AngularCompilationResult { + compilerOptions: AngularCompilationOptions; + referencedFiles: readonly string[]; + externalStylesheets?: ReadonlyMap; + templateUpdates?: ReadonlyMap; + componentResourcesDependencies?: ReadonlyMap; + warnings?: readonly PartialMessage[]; +} + +export enum DiagnosticModes { + None = 0, + Option = 1 << 0, + Syntactic = 1 << 1, + Semantic = 1 << 2, + All = Option | Syntactic | Semantic, +} + +export abstract class AngularCompilation { + abstract initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', + ): Promise; + + emitAffectedFiles(): Iterable | Promise> { + return []; + } + + transformFile?(filename: string, content: string): Promise; + + async diagnoseFiles( + modes?: DiagnosticModes, + ): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> { + return {}; + } + + update?(files: Set): Promise; + + close?(): Promise; +} diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts new file mode 100644 index 000000000000..e0aad6a77db4 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts @@ -0,0 +1,650 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import path from 'node:path'; +import ts from 'typescript'; +import type { AngularHostOptions } from '../angular-host'; +import { ensurePreDeclarationTransformers } from './aot-compilation'; +import { CompilerOptionOverrides, transformCompilerOptions } from './compiler-options'; +import { TypeScriptCompilation } from './typescript-compilation'; +import { + AngularCompilation, + AngularCompilationResult, + DiagnosticModes, + createAngularCompilation, +} from './index'; + +describe('AngularCompilation', () => { + class CustomTransformCompilation extends AngularCompilation { + override async initialize(): Promise { + return { + compilerOptions: { allowJs: false }, + referencedFiles: ['/src/main.ts'], + }; + } + + override async transformFile(filename: string, content: string) { + if (filename.endsWith('.ts')) { + return { + contents: content + '\n// transformed', + watchFiles: ['/src/dep.ts'], + }; + } + + return null; + } + } + + it('allows implementing on-demand transformFile without collectDiagnostics', async () => { + const compilation = new CustomTransformCompilation(); + const initResult = await compilation.initialize(); + + expect(initResult.referencedFiles).toEqual(['/src/main.ts']); + expect(initResult.compilerOptions.allowJs).toBe(false); + + const transformResult = await compilation.transformFile?.( + '/src/main.ts', + 'console.log("hello");', + ); + expect(transformResult).toEqual({ + contents: 'console.log("hello");\n// transformed', + watchFiles: ['/src/dep.ts'], + }); + + const diagnostics = await compilation.diagnoseFiles(); + expect(diagnostics).toEqual({}); + }); + + describe('TypeScriptCompilation', () => { + class MockTypeScriptCompilation extends TypeScriptCompilation { + async initialize(): Promise { + return { compilerOptions: {}, referencedFiles: [] }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + if (modes & DiagnosticModes.Option) { + yield { + category: ts.DiagnosticCategory.Error, + code: 1234, + messageText: 'Mock option error', + file: undefined, + start: undefined, + length: undefined, + }; + } + if (modes & DiagnosticModes.Semantic) { + yield { + category: ts.DiagnosticCategory.Warning, + code: 5678, + messageText: 'Mock semantic warning', + file: undefined, + start: undefined, + length: undefined, + }; + } + } + + public getCachedSourceFiles(): Map { + return this.sourceFiles; + } + + public async testLoadConfiguration( + tsconfig: string, + overrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', + ) { + return this.loadConfiguration(tsconfig, overrides, buildType); + } + } + + it('collects and converts diagnostics categorized by error and warning', async () => { + const compilation = new MockTypeScriptCompilation(); + const diagnostics = await compilation.diagnoseFiles(DiagnosticModes.All); + + expect(diagnostics.errors?.length).toBe(1); + expect(diagnostics.errors?.[0].text).toContain('Mock option error'); + expect(diagnostics.warnings?.length).toBe(1); + expect(diagnostics.warnings?.[0].text).toContain('Mock semantic warning'); + }); + + it('filters diagnostics according to requested DiagnosticModes', async () => { + const compilation = new MockTypeScriptCompilation(); + const diagnostics = await compilation.diagnoseFiles(DiagnosticModes.Option); + + expect(diagnostics.errors?.length).toBe(1); + expect(diagnostics.errors?.[0].text).toContain('Mock option error'); + expect(diagnostics.warnings).toBeUndefined(); + }); + + it('returns empty diagnostics immediately when mode is DiagnosticModes.None', async () => { + const compilation = new MockTypeScriptCompilation(); + const diagnostics = await compilation.diagnoseFiles(DiagnosticModes.None); + + expect(diagnostics).toEqual({}); + }); + + it('evicts files from AST cache on update and invalidateFiles', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockSourceFile = ts.createSourceFile('test.ts', '', ts.ScriptTarget.Latest); + compilation.getCachedSourceFiles().set('/src/test.ts', mockSourceFile); + + expect(compilation.getCachedSourceFiles().has('/src/test.ts')).toBeTrue(); + + await compilation.update?.(new Set(['/src/test.ts'])); + expect(compilation.getCachedSourceFiles().has('/src/test.ts')).toBeFalse(); + }); + + it('dynamically loads the @angular/compiler-cli module', async () => { + const compilerCli = await TypeScriptCompilation.loadCompilerCli(); + expect(compilerCli).toBeDefined(); + expect(typeof compilerCli.readConfiguration).toBe('function'); + }); + + it('loads configuration and applies compiler option transformations', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2020 }, + rootNames: ['/src/main.ts'], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result = await compilation.testLoadConfiguration('tsconfig.json', { sourcemap: true }); + + expect(mockReadConfig).toHaveBeenCalledWith( + 'tsconfig.json', + jasmine.objectContaining({ + suppressOutputPathCheck: true, + outDir: undefined, + }), + undefined, + jasmine.any(Map), + ); + expect(result.rootNames).toEqual(['/src/main.ts']); + expect(result.fallbackRootNames).toBeUndefined(); + expect(result.compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(result.compilerOptions.inlineSources).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.tsConfigFiles).toEqual(['tsconfig.json']); + }); + + it('filters rootNames to rootFiles while retaining ambient declaration files and setting fallbackRootNames', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2022 }, + rootNames: [ + '/src/app/included.spec.ts', + '/src/app/excluded.spec.ts', + '/src/app/app-module.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result = await compilation.testLoadConfiguration('tsconfig.spec.json', { + rootFiles: ['/src/app/included.spec.ts'], + }); + + expect(result.rootNames).toEqual([ + '/src/app/included.spec.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ]); + expect(result.fallbackRootNames).toEqual([ + '/src/app/included.spec.ts', + '/src/app/excluded.spec.ts', + '/src/app/app-module.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ]); + }); + + it('omits fallbackRootNames when buildType is "library" or when rootFiles covers all tsconfig files', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2022 }, + rootNames: ['/src/public-api.ts', '/src/internal.ts', '/src/env.d.ts'], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const libraryResult = await compilation.testLoadConfiguration( + 'tsconfig.lib.json', + { rootFiles: ['/src/public-api.ts'] }, + 'library', + ); + expect(libraryResult.rootNames).toEqual(['/src/public-api.ts', '/src/env.d.ts']); + expect(libraryResult.fallbackRootNames).toBeUndefined(); + + const fullSetResult = await compilation.testLoadConfiguration( + 'tsconfig.app.json', + { rootFiles: ['/src/public-api.ts', '/src/internal.ts'] }, + 'application', + ); + expect(fullSetResult.rootNames).toEqual([ + '/src/public-api.ts', + '/src/internal.ts', + '/src/env.d.ts', + ]); + expect(fullSetResult.fallbackRootNames).toBeUndefined(); + }); + + it('passes extendedConfigCache to readConfiguration and invalidates entries on config change', async () => { + const compilation = new MockTypeScriptCompilation(); + let passedCache: Map | undefined; + const mockReadConfig = jasmine + .createSpy('readConfiguration') + .and.callFake((_project, _options, _host, extendedConfigCache) => { + passedCache = extendedConfigCache; + extendedConfigCache?.set( + '/path/to/tsconfig.base.json', + {} as ts.ExtendedConfigCacheEntry, + ); + + return { + options: { target: ts.ScriptTarget.ES2020 }, + rootNames: ['/src/main.ts'], + errors: [], + }; + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result1 = await compilation.testLoadConfiguration('tsconfig.json'); + expect(mockReadConfig).toHaveBeenCalledTimes(1); + expect(passedCache).toBeDefined(); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeTrue(); + expect(result1.tsConfigFiles).toEqual(['tsconfig.json', '/path/to/tsconfig.base.json']); + + // Invalidation of non-config file should keep extended config cache intact + await compilation.update?.(new Set(['/src/main.ts'])); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeTrue(); + + // Invalidation of extended tsconfig file should remove it from cache + await compilation.update?.(new Set(['/path/to/tsconfig.base.json'])); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeFalse(); + + // Invalidation with different casing should also remove it from cache + passedCache?.set('/path/to/tsconfig.base.json', {} as ts.ExtendedConfigCacheEntry); + await compilation.update?.(new Set(['/PATH/TO/TSCONFIG.BASE.JSON'])); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeFalse(); + + // Subsequent configuration load re-uses the same cache instance + await compilation.testLoadConfiguration('tsconfig.json'); + expect(mockReadConfig).toHaveBeenCalledTimes(2); + expect(mockReadConfig.calls.argsFor(1)[3]).toBe(passedCache); + }); + }); + + describe('createAngularCompilation', () => { + it('creates JitCompilation when mode is "jit"', async () => { + const compilation = await createAngularCompilation('jit', true, false); + expect(compilation.constructor.name).toBe('JitCompilation'); + }); + + it('creates AotCompilation when mode is "aot"', async () => { + const compilation = await createAngularCompilation('aot', true, false); + expect(compilation.constructor.name).toBe('AotCompilation'); + }); + + it('creates JitCompilation when mode is boolean true (legacy)', async () => { + const compilation = await createAngularCompilation(true, true, false); + expect(compilation.constructor.name).toBe('JitCompilation'); + }); + + it('creates AotCompilation when mode is boolean false (legacy)', async () => { + const compilation = await createAngularCompilation(false, true, false); + expect(compilation.constructor.name).toBe('AotCompilation'); + }); + + it('throws when mode is "transform"', async () => { + await expectAsync(createAngularCompilation('transform', true, false)).toBeRejectedWithError( + 'Transform compilation mode is not supported.', + ); + }); + }); + + describe('transformCompilerOptions', () => { + it('does not mutate the input compiler options object', () => { + const originalOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }; + const originalCopy = { ...originalOptions }; + + transformCompilerOptions(ts, originalOptions); + + expect(originalOptions).toEqual(originalCopy); + }); + + it('sets target to ES2022 and useDefineForClassFields to false when target is undefined', () => { + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + { module: ts.ModuleKind.ES2022 }, + undefined, + 'tsconfig.json', + ); + + expect(compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(compilerOptions.useDefineForClassFields).toBe(false); + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain( + "TypeScript compiler options 'target' and 'useDefineForClassFields'", + ); + expect(warnings[0].location?.file).toBe('tsconfig.json'); + }); + + it('preserves existing useDefineForClassFields if target < ES2022', () => { + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + { + target: ts.ScriptTarget.ES2020, + useDefineForClassFields: true, + module: ts.ModuleKind.ES2022, + }, + undefined, + 'tsconfig.json', + ); + + expect(compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(compilerOptions.useDefineForClassFields).toBe(true); + expect(warnings.length).toBe(1); + }); + + it('sets compilationMode to full and warns when compilationMode is partial', () => { + const { compilerOptions, warnings } = transformCompilerOptions(ts, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + compilationMode: 'partial', + }); + + expect(compilerOptions.compilationMode).toBe('full'); + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain('Angular partial compilation mode is not supported'); + }); + + it('configures incremental and tsBuildInfoFile when cachePath is provided', () => { + const { compilerOptions } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022 }, + { cachePath: '/tmp/cache' }, + ); + + expect(compilerOptions.incremental).toBe(true); + expect(compilerOptions.tsBuildInfoFile).toContain('.tsbuildinfo'); + }); + + it('sets incremental to false when cachePath is not provided or incremental is false', () => { + const { compilerOptions: opt1 } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022 }, + undefined, + ); + expect(opt1.incremental).toBe(false); + + const { compilerOptions: opt2 } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022, incremental: false }, + { cachePath: '/tmp/cache' }, + ); + expect(opt2.incremental).toBe(false); + }); + + it('sets module to ES2022 and warns when module < ES2015', () => { + const { compilerOptions, warnings } = transformCompilerOptions(ts, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }); + + expect(compilerOptions.module).toBe(ts.ModuleKind.ES2022); + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain( + "TypeScript compiler options 'module' values 'CommonJS', 'UMD'", + ); + }); + + it('warns when isolatedModules is enabled with emitDecoratorMetadata', () => { + const { warnings } = transformCompilerOptions(ts, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + isolatedModules: true, + emitDecoratorMetadata: true, + }); + + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain( + "TypeScript compiler option 'isolatedModules' may prevent", + ); + }); + + it('synchronizes customConditions when moduleResolution is Bundler or module is Preserve', () => { + const { compilerOptions: bundlerOptions } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022, moduleResolution: ts.ModuleResolutionKind.Bundler }, + { customConditions: ['development'] }, + ); + expect(bundlerOptions.customConditions).toEqual(['development']); + + const { compilerOptions: preserveOptions } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.Preserve }, + { customConditions: ['development'] }, + ); + expect(preserveOptions.customConditions).toEqual(['development']); + }); + + it('applies override options correctly', () => { + const { compilerOptions } = transformCompilerOptions( + ts, + { + target: ts.ScriptTarget.ES2022, + isolatedModules: true, + basePath: '/workspace/projects/app', + }, + { + sourcemap: true, + preserveSymlinks: true, + externalRuntimeStyles: true, + enableHmr: true, + instrumentForCoverage: true, + includeTestMetadata: true, + basePath: '/workspace', + }, + ); + + expect(compilerOptions.basePath).toBe('/workspace'); + expect(compilerOptions.inlineSources).toBe(true); + expect(compilerOptions.inlineSourceMap).toBe(true); + expect(compilerOptions.preserveSymlinks).toBe(true); + expect(compilerOptions.externalRuntimeStyles).toBe(true); + expect(compilerOptions['_enableHmr']).toBe(true); + expect(compilerOptions['_useTypeScriptTranspilation']).toBe(true); + expect(compilerOptions.supportTestBed).toBe(true); + expect(compilerOptions.supportJitMode).toBe(true); + expect(compilerOptions.noEmit).toBe(false); + expect(compilerOptions.noEmitOnError).toBe(false); + expect(compilerOptions.emitDeclarationOnly).toBe(false); + expect(compilerOptions.composite).toBe(false); + }); + + it('enforces library compiler options and skips app-only target warning', () => { + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + { + target: ts.ScriptTarget.ES2020, + noEmit: true, + emitDeclarationOnly: true, + isolatedModules: true, + }, + { + cachePath: '/tmp/cache', + }, + 'tsconfig.lib.json', + 'library', + ); + + expect(compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(compilerOptions.module).toBe(ts.ModuleKind.Preserve); + expect(compilerOptions.useDefineForClassFields).toBeUndefined(); + expect(compilerOptions.noEmit).toBe(false); + expect(compilerOptions.emitDeclarationOnly).toBe(false); + expect(compilerOptions.incremental).toBe(true); + expect(compilerOptions.tsBuildInfoFile).toBe(path.join('/tmp/cache', '.tsbuildinfo')); + expect(warnings).toEqual([]); + }); + }); + + describe('ensurePreDeclarationTransformers', () => { + function createTestProgram( + fileName: string, + content: string, + ): { + program: ts.Program; + sourceFile: ts.SourceFile; + } { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + declaration: true, + noLib: true, + }; + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ES2022, true); + const host = ts.createCompilerHost(options); + host.getSourceFile = (name) => (name === fileName ? sourceFile : undefined); + host.fileExists = (name) => name === fileName; + host.readFile = (name) => (name === fileName ? content : undefined); + host.writeFile = () => {}; + + const program = ts.createProgram([fileName], options, host); + + return { program, sourceFile }; + } + + it('transforms each SourceFile only once when emitting .d.ts followed by .js and reuses the cached SourceFile', () => { + const { program, sourceFile } = createTestProgram('/index.ts', 'export const value = 1;'); + + let preDeclStatePopulated = false; + const beforeSpy1 = jasmine + .createSpy('beforeTransformer1') + .and.callFake((sf: ts.SourceFile) => { + preDeclStatePopulated = true; + const addedStmt = ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'fromBefore1', + undefined, + undefined, + ts.factory.createNumericLiteral(2), + ), + ], + ts.NodeFlags.Const, + ), + ); + + return ts.factory.updateSourceFile(sf, [...sf.statements, addedStmt]); + }); + + const beforeSpy2 = jasmine + .createSpy('beforeTransformer2') + .and.callFake((sf: ts.SourceFile) => { + const addedStmt = ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'fromBefore2', + undefined, + undefined, + ts.factory.createNumericLiteral(3), + ), + ], + ts.NodeFlags.Const, + ), + ); + + return ts.factory.updateSourceFile(sf, [...sf.statements, addedStmt]); + }); + + let sawPopulatedPreDeclState = false; + const afterDeclSpy = jasmine + .createSpy('afterDeclarationsTransformer') + .and.callFake((sf: ts.SourceFile) => { + sawPopulatedPreDeclState = preDeclStatePopulated; + + return sf; + }); + + const transformers: ts.CustomTransformers = { + before: [() => beforeSpy1, () => beforeSpy2], + afterDeclarations: [() => afterDeclSpy], + }; + + ensurePreDeclarationTransformers(transformers); + + const emittedFiles = new Map(); + const writeFile: ts.WriteFileCallback = (fileName, text) => { + emittedFiles.set(fileName, text); + }; + + // Emit .d.ts first (emitOnlyDtsFiles = true) + program.emit(sourceFile, writeFile, undefined, true, transformers); + + expect(sawPopulatedPreDeclState).toBeTrue(); + expect(beforeSpy1).toHaveBeenCalledTimes(1); + expect(beforeSpy2).toHaveBeenCalledTimes(1); + expect(afterDeclSpy).toHaveBeenCalledTimes(1); + expect(emittedFiles.has('/index.d.ts')).toBeTrue(); + expect(emittedFiles.has('/index.js')).toBeFalse(); + + // Emit .js second; before transformers must not execute again and cached AST must be emitted + program.emit(sourceFile, writeFile, undefined, 0 as unknown as boolean, transformers); + + expect(beforeSpy1).toHaveBeenCalledTimes(1); + expect(beforeSpy2).toHaveBeenCalledTimes(1); + expect(emittedFiles.get('/index.js')).toContain('export const fromBefore1 = 2;'); + expect(emittedFiles.get('/index.js')).toContain('export const fromBefore2 = 3;'); + }); + + it('transforms each SourceFile only once when emitting .js and .d.ts together', () => { + const { program, sourceFile } = createTestProgram('/index.ts', 'export const value = 1;'); + + const beforeSpy = jasmine + .createSpy('beforeTransformer') + .and.callFake((sf: ts.SourceFile) => sf); + const afterDeclSpy = jasmine + .createSpy('afterDeclarationsTransformer') + .and.callFake((sf: ts.SourceFile) => sf); + + const transformers: ts.CustomTransformers = { + before: [() => beforeSpy], + afterDeclarations: [() => afterDeclSpy], + }; + + ensurePreDeclarationTransformers(transformers); + + program.emit(sourceFile, () => {}, undefined, false, transformers); + + expect(beforeSpy).toHaveBeenCalledTimes(1); + expect(afterDeclSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts new file mode 100644 index 000000000000..8a3e559c9c69 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -0,0 +1,1051 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import { relative } from 'node:path'; +import ts from 'typescript'; +import { useTypeChecking } from '../../../utils/environment-options'; +import { profileAsync, profileSync } from '../../../utils/profiling'; +import { + AngularHostOptions, + createAngularCompilerHost, + ensureSourceFileVersions, +} from '../angular-host'; +import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer'; +import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer'; +import { createWorkerTransformer } from '../transformers/web-worker-transformer'; +import { + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { collectHmrCandidates } from './hmr-candidates'; +import { TypeScriptCompilation } from './typescript-compilation'; +import { printSourceFileWithMap } from './typescript-printer'; + +/** + * The modified files count limit for performing component HMR analysis. + * Performing content analysis for a large amount of files can result in longer rebuild times + * than a full rebuild would entail. + */ +const HMR_MODIFIED_FILE_LIMIT = 32; + +/** + * Extension for Angular type-checking files. + */ +const TTC_EXTENSION = '.ngtypecheck.ts'; + +/** + * Length of the `.ngtypecheck.ts` extension. + */ +const TTC_EXTENSION_LENGTH = TTC_EXTENSION.length; + +/** + * Internal TypeScript `ts.EmitOnly` enum values used by `Program.emit`. + */ +enum EmitOnly { + Js = 0, + Dts = 1, + BuilderSignature = 2, +} + +/** + * Checks if a file is an Angular type-checking shim file. + */ +function isNgTypeCheckFile(fileName: string): boolean { + return fileName.endsWith(TTC_EXTENSION); +} + +/** + * Returns the original filename for a type-checking shim file. + */ +function getOriginalFilename(fileName: string): string { + return fileName.slice(0, -TTC_EXTENSION_LENGTH) + '.ts'; +} + +class AngularCompilationState { + constructor( + public readonly angularProgram: ng.NgtscProgram, + public readonly compilerHost: ng.CompilerHost, + public typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public readonly affectedFiles: ReadonlySet, + public readonly templateDiagnosticsOptimization: ng.OptimizeFor, + public readonly webWorkerTransform: ts.TransformerFactory, + public readonly useTypeScriptTranspilation: boolean, + public readonly diagnosticCache = new WeakMap(), + public readonly declarationDiagnostics = new WeakMap(), + public readonly filesWithoutDtsSignature = new Set(), + public readonly useFallbackRootNames = false, + public readonly emittedDtsSourceFiles = new WeakSet(), + ) {} + + get angularCompiler() { + return this.angularProgram.compiler; + } +} + +export class AotCompilation extends TypeScriptCompilation { + #state?: AngularCompilationState; + + constructor(private readonly browserOnlyBuild: boolean) { + super(); + } + + // eslint-disable-next-line max-lines-per-function + async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + // Dynamically load the Angular compiler CLI package + const { NgtscProgram, OptimizeFor } = await TypeScriptCompilation.loadCompilerCli(); + + // Load the compiler configuration and transform as needed + const { + compilerOptions, + rootNames, + fallbackRootNames, + errors: configurationDiagnostics, + warnings, + tsConfigFiles, + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); + + const useTypeScriptTranspilation = + (compilerOptions['_useTypeScriptTranspilation'] as boolean | undefined) ?? + !compilerOptions.isolatedModules; + + if (compilerOptions.externalRuntimeStyles) { + hostOptions.externalStylesheets ??= new Map(); + } + + // Reuse the package.json cache from the previous compilation + const packageJsonCache = this.#state?.compilerHost + .getModuleResolutionCache?.() + ?.getPackageJsonInfoCache(); + + const useHmr = + compilerOptions['_enableHmr'] && + hostOptions.modifiedFiles && + hostOptions.modifiedFiles.size <= HMR_MODIFIED_FILE_LIMIT; + + let staleSourceFiles; + let clearPackageJsonCache = false; + if (hostOptions.modifiedFiles) { + this.invalidateFiles(hostOptions.modifiedFiles); + + if (this.#state && hostOptions.modifiedFiles.size > 0) { + for (const modifiedFile of hostOptions.modifiedFiles) { + // Clear package.json cache if a node modules file was modified + if (!clearPackageJsonCache && modifiedFile.includes('node_modules')) { + clearPackageJsonCache = true; + packageJsonCache?.clear(); + } + + // Collect stale source files for HMR analysis of inline component resources + if (useHmr) { + const sourceFile = this.#state.typeScriptProgram.getSourceFile(modifiedFile); + if (sourceFile) { + staleSourceFiles ??= new Map(); + staleSourceFiles.set(modifiedFile, sourceFile); + } + } + } + + if (useTypeChecking || useTypeScriptTranspilation) { + populatePreviousDtsSignatures( + this.#state.typeScriptProgram, + hostOptions.modifiedFiles, + this.#state.filesWithoutDtsSignature, + ); + } + } + } + + // Create Angular compiler host + const host = createAngularCompilerHost( + ts, + compilerOptions, + hostOptions, + packageJsonCache, + this.sourceFiles, + ); + + // If a previous watch rebuild already fell back to the full tsconfig root file set, + // start directly with fallbackRootNames to avoid repeating the two-step check. + let useFallbackRootNames = this.#state?.useFallbackRootNames ?? false; + + // Create the Angular specific program that contains the Angular compiler + let angularProgram = profileSync( + 'NG_CREATE_PROGRAM', + () => + new NgtscProgram( + (useFallbackRootNames && fallbackRootNames) || rootNames, + compilerOptions, + host, + this.#state?.angularProgram, + ), + ); + let angularCompiler = angularProgram.compiler; + + await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); + + let angularTypeScriptProgram = angularProgram.getTsProgram(); + + // When rootFiles filtering is active and a non-standalone component without a resolved + // NgModule scope is encountered, its declaring NgModule may not be directly imported by + // the test entry points. Fall back to the full tsconfig root file set if it contains + // additional uncompiled files so the Angular compiler can discover all NgModules and + // resolve component compilation scopes. + if ( + !useFallbackRootNames && + fallbackRootNames?.some((file) => !angularTypeScriptProgram.getSourceFile(file)) && + hasUnscopedComponent(angularCompiler) + ) { + useFallbackRootNames = true; + angularProgram = profileSync( + 'NG_CREATE_PROGRAM_FALLBACK', + () => new NgtscProgram(fallbackRootNames, compilerOptions, host, angularProgram), + ); + angularCompiler = angularProgram.compiler; + + await profileAsync('NG_ANALYZE_PROGRAM_FALLBACK', () => angularCompiler.analyzeAsync()); + angularTypeScriptProgram = angularProgram.getTsProgram(); + } + + ensureSourceFileVersions(angularTypeScriptProgram); + + let oldProgram = this.#state?.typeScriptProgram; + let usingBuildInfo = false; + if (!oldProgram) { + oldProgram = ts.readBuilderProgram(compilerOptions, host); + usingBuildInfo = !!oldProgram; + } + + const typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + angularTypeScriptProgram, + host, + oldProgram, + configurationDiagnostics, + ); + + const filesWithoutDtsSignature = this.#state?.filesWithoutDtsSignature ?? new Set(); + if (!this.#state && !usingBuildInfo && (useTypeChecking || useTypeScriptTranspilation)) { + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if ( + !sourceFile.isDeclarationFile && + !angularCompiler.ignoreForDiagnostics.has(sourceFile) + ) { + filesWithoutDtsSignature.add(sourceFile.fileName); + } + } + } + + let templateUpdates; + if (useHmr && hostOptions.modifiedFiles && this.#state) { + const componentNodes = collectHmrCandidates( + hostOptions.modifiedFiles, + angularProgram, + staleSourceFiles, + ); + + for (const node of componentNodes) { + if (!ts.isClassDeclaration(node)) { + continue; + } + const componentFilename = node.getSourceFile().fileName; + let relativePath = relative( + compilerOptions.rootDir ?? host.getCurrentDirectory(), + componentFilename, + ); + if (relativePath.startsWith('..')) { + relativePath = componentFilename; + } + relativePath = relativePath.replaceAll('\\', '/'); + 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. + if (updateText === null) { + // Build is needed if a template cannot be updated + templateUpdates = undefined; + break; + } + templateUpdates ??= new Map(); + templateUpdates.set(updateId, updateText); + } + } + + // The affected files walk runs a full semantic type-check as a side effect + // (via `getSemanticDiagnosticsOfNextAffectedFile`). Its result is only consumed to + // scope Angular template diagnostics and to force re-emit of TS-affected files in the + // slow TypeScript emit path. When type-checking is disabled, template/semantic + // diagnostics are already suppressed at the consumption layer (see the compiler-plugin + // `diagnoseFiles` mask), and in the isolatedModules fast path emit is per-file, so the + // set is never read. Skip the walk in that case to avoid a discarded semantic pass. + const affectedFiles = + useTypeChecking || useTypeScriptTranspilation + ? profileSync('NG_FIND_AFFECTED', () => + findAffectedFiles( + typeScriptProgram, + angularCompiler, + usingBuildInfo, + this.#state?.typeScriptProgram, + filesWithoutDtsSignature, + ), + ) + : new Set(); + + const componentResourcesDependencies = new Map(); + + // Get all files referenced in the TypeScript/Angular program including component resources + const referencedFiles = [...tsConfigFiles]; + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + referencedFiles.push(sourceFile.fileName); + if (sourceFile.isDeclarationFile) { + continue; + } + + const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); + componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies); + + if (resourceDependencies.length === 0) { + continue; + } + + referencedFiles.push(...resourceDependencies); + + // Also invalidate Angular diagnostics for a source file if component resources are modified + if (this.#state && hostOptions.modifiedFiles?.size) { + for (const resourceDependency of resourceDependencies) { + if ( + !hostOptions.modifiedFiles.has(resourceDependency) || + /\.(?:css|scss|sass|less)$/i.test(resourceDependency) + ) { + continue; + } + + this.#state.diagnosticCache.delete(sourceFile); + + const oldSourceFile = this.#state.typeScriptProgram.getSourceFile(sourceFile.fileName); + if (oldSourceFile) { + this.#state.diagnosticCache.delete(oldSourceFile); + } + + // Also mark as affected in case changed template affects diagnostics + affectedFiles.add(sourceFile); + break; + } + } + } + + this.#state = new AngularCompilationState( + angularProgram, + host, + typeScriptProgram, + affectedFiles, + affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram, + createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), + useTypeScriptTranspilation, + this.#state?.diagnosticCache, + this.#state?.declarationDiagnostics, + filesWithoutDtsSignature, + useFallbackRootNames, + this.#state?.emittedDtsSourceFiles, + ); + + return { + compilerOptions, + referencedFiles, + externalStylesheets: hostOptions.externalStylesheets, + templateUpdates, + componentResourcesDependencies, + warnings, + }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + assert(this.#state, 'Angular compilation must be initialized prior to collecting diagnostics.'); + const { + affectedFiles, + angularCompiler, + declarationDiagnostics, + diagnosticCache, + templateDiagnosticsOptimization, + typeScriptProgram, + } = this.#state; + + const syntactic = modes & DiagnosticModes.Syntactic; + const semantic = modes & DiagnosticModes.Semantic; + + try { + // Collect program level diagnostics + if (modes & DiagnosticModes.Option) { + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + yield* angularCompiler.getOptionDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); + } + if (syntactic) { + yield* typeScriptProgram.getGlobalDiagnostics(); + } + + // Collect source file specific diagnostics + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + if (syntactic) { + // TypeScript will use cached diagnostics for files that have not been + // changed or affected for this build when using incremental building. + yield* profileSync( + 'NG_DIAGNOSTICS_SYNTACTIC', + () => typeScriptProgram.getSyntacticDiagnostics(sourceFile), + true, + ); + } + + if (!semantic) { + continue; + } + + yield* profileSync( + 'NG_DIAGNOSTICS_SEMANTIC', + () => typeScriptProgram.getSemanticDiagnostics(sourceFile), + true, + ); + + // Declaration files cannot have template or declaration emit diagnostics + if (sourceFile.isDeclarationFile) { + continue; + } + + const fileDeclarationDiagnostics = declarationDiagnostics.get(sourceFile); + if (fileDeclarationDiagnostics) { + yield* fileDeclarationDiagnostics; + } + + // Only request Angular template diagnostics for affected files to avoid + // overhead of template diagnostics for unchanged files. + if (affectedFiles.has(sourceFile)) { + const angularDiagnostics = profileSync( + 'NG_DIAGNOSTICS_TEMPLATE', + () => + angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization), + true, + ); + diagnosticCache.set(sourceFile, angularDiagnostics); + yield* angularDiagnostics; + } else { + const angularDiagnostics = diagnosticCache.get(sourceFile); + if (angularDiagnostics) { + yield* angularDiagnostics; + } + } + } + } finally { + // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program` + // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()` + // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files. + const currentTsProgram = angularCompiler.getCurrentProgram(); + if (currentTsProgram !== typeScriptProgram.getProgram()) { + ensureSourceFileVersions(currentTsProgram, typeScriptProgram); + this.#state.typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + currentTsProgram, + this.#state.compilerHost, + typeScriptProgram, + ); + + // Temporarily enable `isolatedModules` and `assumeChangesOnlyAffectDirectDependencies` while + // draining the newly created `.ngtypecheck.ts` files. Because each component `.ts` file + // references its `.ngtypecheck.ts` shim via `sourceFile.referencedFiles`, TypeScript's + // `referencedMap` treats the component `.ts` file as depending on its `.ngtypecheck.ts` shim. + // Without both flags (whether `isolatedModules` is enabled or disabled in `tsconfig`), + // TypeScript would walk from `.ngtypecheck.ts` to the component `.ts` file and its transitive + // importers, resetting their `.d.ts` shape signatures and queueing them into + // `affectedFilesPendingEmit` for the next incremental rebuild. + const compilerOptions = currentTsProgram.getCompilerOptions(); + const prevIsolatedModules = compilerOptions.isolatedModules; + const prevAssumeDirect = compilerOptions.assumeChangesOnlyAffectDirectDependencies; + compilerOptions.isolatedModules = true; + compilerOptions.assumeChangesOnlyAffectDirectDependencies = true; + + const baseEmit = currentTsProgram.emit; + currentTsProgram.emit = () => ({ emitSkipped: true, diagnostics: [] }); + try { + while (this.#state.typeScriptProgram.emitNextAffectedFile(() => {})) { + /* empty */ + } + } finally { + currentTsProgram.emit = baseEmit; + compilerOptions.isolatedModules = prevIsolatedModules; + compilerOptions.assumeChangesOnlyAffectDirectDependencies = prevAssumeDirect; + } + } + } + } + + override emitAffectedFiles(): Iterable { + assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); + const { + angularCompiler, + compilerHost, + declarationDiagnostics, + emittedDtsSourceFiles, + typeScriptProgram, + webWorkerTransform, + useTypeScriptTranspilation, + } = this.#state; + const compilerOptions = typeScriptProgram.getCompilerOptions(); + const emitDeclarations = !!compilerOptions.declaration; + const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; + + const emittedFiles = new Map(); + const emittedSourceFiles = new Set(); + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { + // Save builder info contents to specified location + compilerHost.writeFile(filename, contents, false); + + return; + } + + assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename); + const sourceFile = ts.getOriginalNode(sourceFiles[0], ts.isSourceFile); + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + return; + } + + const isMap = filename.endsWith('.map'); + const isDts = !isMap && /\.d\.[cm]?ts$/i.test(filename); + if (!emitDeclarations || (!isMap && !isDts)) { + angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); + emittedSourceFiles.add(sourceFile); + } else if (isDts) { + emittedDtsSourceFiles.add(sourceFile); + } + + const targetFilename = emitDeclarations ? filename : sourceFile.fileName; + emittedFiles.set(targetFilename, { filename: targetFilename, contents }); + }; + const transformers = angularCompiler.prepareEmit().transformers; + if (!emitDeclarations) { + transformers.before ??= []; + transformers.before.push( + replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), + webWorkerTransform, + ); + + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } + } else if (transformers.before?.length && transformers.afterDeclarations?.length) { + ensurePreDeclarationTransformers(transformers); + } + + // Emit is handled in write file callback when using TypeScript + if (useTypeScriptTranspilation) { + // TypeScript will loop until there are no more affected files in the program + while (true) { + const affectedEmitResult = typeScriptProgram.emitNextAffectedFile( + writeFileCallback, + undefined, + undefined, + transformers, + ); + if (!affectedEmitResult) { + break; + } + if ('kind' in affectedEmitResult.affected) { + declarationDiagnostics.set( + affectedEmitResult.affected, + affectedEmitResult.result.diagnostics, + ); + } + } + } else if (compilerOptions.tsBuildInfoFile) { + // Manually get the builder state for the persistent cache + // The TypeScript API currently embeds this behavior inside the program emit + // via emitNextAffectedFile but that also applies all internal transforms. + const programWithGetState = typeScriptProgram.getProgram() as ts.Program & { + emitBuildInfo(writeFileCallback?: ts.WriteFileCallback): void; + }; + + assert( + typeof programWithGetState.emitBuildInfo === 'function', + 'TypeScript program emitBuildInfo is missing.', + ); + + programWithGetState.emitBuildInfo(); + } + + // Angular may have files that must be emitted but TypeScript does not consider affected + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (emittedSourceFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + if (sourceFile.isDeclarationFile) { + continue; + } + + if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) { + continue; + } + + if (useTypeScriptTranspilation) { + const hasEmittedDts = emittedDtsSourceFiles.has(sourceFile); + const emitOnly = hasEmittedDts ? (EmitOnly.Js as unknown as boolean) : undefined; + const emitResult = typeScriptProgram.emit( + sourceFile, + writeFileCallback, + undefined, + emitOnly, + transformers, + ); + if (!hasEmittedDts) { + declarationDiagnostics.set(sourceFile, emitResult.diagnostics); + } + continue; + } + + // When not using TypeScript transpilation, directly apply only Angular specific transformations + const transformResult = ts.transform( + sourceFile, + [ + ...(transformers.before ?? []), + ...(transformers.after ?? []), + ] as ts.TransformerFactory[], + compilerOptions, + ); + + assert( + transformResult.transformed.length === 1, + 'TypeScript transforms should not produce multiple outputs for ' + sourceFile.fileName, + ); + + let contents: string; + if (sourceFile === transformResult.transformed[0]) { + // Use original content if no changes were made + contents = sourceFile.text; + } else { + // Otherwise, print the transformed source file with map if needed + const printer = ts.createPrinter(compilerOptions, transformResult); + const printResult = printSourceFileWithMap( + transformResult.transformed[0], + printer, + compilerHost, + compilerOptions, + ); + + contents = printResult.code; + + if (printResult.map) { + if (compilerOptions.inlineSourceMap) { + const base64Map = Buffer.from(printResult.map).toString('base64'); + contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + } else if (compilerOptions.sourceMap) { + const mapFilename = sourceFile.fileName + '.map'; + emittedFiles.set(mapFilename, { filename: mapFilename, contents: printResult.map }); + } + } + } + + angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); + emittedSourceFiles.add(sourceFile); + emittedFiles.set(sourceFile.fileName, { filename: sourceFile.fileName, contents }); + } + + return emittedFiles.values(); + } +} + +/** + * When TypeScript's `BuilderProgram.emitNextAffectedFile` emits `.d.ts`-only (`emitOnly = Dts`) + * for a transitive importer, TypeScript skips `transformers.before`. However, Angular's + * `declarationTransformFactory` in `afterDeclarations` relies on `ivyTransformFactory` in + * `before` having compiled the file's classes to populate `dtsTransforms`. + * + * This helper ensures `before` transformers have executed on the original `SourceFile` prior to + * `afterDeclarations`, and caches the transformed `SourceFile` so a subsequent `.js` emit for the + * same file does not re-execute `before` transformers. + */ +export function ensurePreDeclarationTransformers(transformers: ts.CustomTransformers): void { + if (!transformers.before?.length || !transformers.afterDeclarations?.length) { + return; + } + + const beforeTransformers = transformers.before as ts.TransformerFactory[]; + const seenSourceFiles = new Set(); + const preTransformedSourceFiles = new Map(); + const runBefore = ( + beforeTransforms: ts.Transformer[], + sourceFile: ts.SourceFile, + originalSourceFile: ts.SourceFile, + ): ts.SourceFile => { + seenSourceFiles.add(originalSourceFile); + let current = sourceFile; + for (const transform of beforeTransforms) { + current = transform(current); + } + + return current; + }; + + transformers.before = [ + (context) => { + let beforeTransforms: ts.Transformer[] | undefined; + + return (sourceFile) => { + const originalSourceFile = ts.getOriginalNode(sourceFile, ts.isSourceFile); + const preTransformed = preTransformedSourceFiles.get(originalSourceFile); + if (preTransformed !== undefined) { + preTransformedSourceFiles.delete(originalSourceFile); + + return preTransformed; + } + + beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); + + return runBefore(beforeTransforms, sourceFile, originalSourceFile); + }; + }, + ]; + transformers.afterDeclarations = [ + (context) => { + let beforeTransforms: ts.Transformer[] | undefined; + + return (fileOrBundle) => { + if (!ts.isBundle(fileOrBundle)) { + const originalSourceFile = ts.getOriginalNode(fileOrBundle, ts.isSourceFile); + if (!seenSourceFiles.has(originalSourceFile)) { + beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); + preTransformedSourceFiles.set( + originalSourceFile, + runBefore(beforeTransforms, originalSourceFile, originalSourceFile), + ); + } + } + + return fileOrBundle; + }; + }, + ...transformers.afterDeclarations, + ]; +} + +function findAffectedFiles( + builder: ts.EmitAndSemanticDiagnosticsBuilderProgram, + { ignoreForDiagnostics, incrementalCompilation }: ng.NgtscProgram['compiler'], + includeTTC: boolean, + oldBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram | undefined, + filesWithoutDtsSignature: Set, +): Set { + const affectedFiles = new Set(); + + // Temporarily disable `isolatedModules` and `verbatimModuleSyntax` while walking affected files. + // In TypeScript, `getIsolatedModules(options)` is true when either `isolatedModules` or + // `verbatimModuleSyntax` is enabled, which causes `getFilesAffectedByUpdatedShapeWhenModuleEmit` + // to short-circuit and return only `[sourceFileWithUpdatedShape]` instead of walking + // `referencedMap` during `getSemanticDiagnosticsOfNextAffectedFile`. + // Disabling both flags forces TypeScript to traverse `referencedMap` when a file's `.d.ts` shape + // signature changes so that dependent files—especially `.ngtypecheck.ts` shims that reference + // directives or pipes used in templates—are visited by the `ignoreSourceFile` callback and have + // their own `.d.ts` shape signatures updated along the dependency chain. + const compilerOptions = builder.getCompilerOptions(); + const prevIsolatedModules = compilerOptions.isolatedModules; + const prevVerbatimModuleSyntax = compilerOptions.verbatimModuleSyntax; + compilerOptions.isolatedModules = false; + compilerOptions.verbatimModuleSyntax = false; + + const program = builder.getProgram(); + const baseEmit = program.emit; + const oldProgram = oldBuilder?.getProgram(); + const hadDtsSignatureComputed = new Set(); + const tsAffectedFiles: ts.SourceFile[] = []; + program.emit = ( + targetSourceFile, + writeFile, + cancellationToken, + emitOnly, + customTransformers, + ...rest + ) => { + if (targetSourceFile) { + if (isNgTypeCheckFile(targetSourceFile.fileName)) { + return { emitSkipped: true, diagnostics: [] }; + } + + if (oldProgram && filesWithoutDtsSignature.has(targetSourceFile.fileName)) { + const oldSourceFile = oldProgram.getSourceFile(targetSourceFile.fileName); + if (oldSourceFile) { + let oldDtsText: string | undefined; + let oldHasDiagnostics = false; + oldProgram.emit( + oldSourceFile, + (_fileName, text, _bom, _onError, _sourceFiles, data) => { + oldDtsText = text; + oldHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined)?.diagnostics + ?.length; + }, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + + return baseEmit.call( + program, + targetSourceFile, + (fileName, text, writeByteOrderMark, onError, sourceFiles, data) => { + const newHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined) + ?.diagnostics?.length; + if (text !== oldDtsText || oldHasDiagnostics || newHasDiagnostics) { + filesWithoutDtsSignature.delete(targetSourceFile.fileName); + hadDtsSignatureComputed.add(targetSourceFile.fileName); + writeFile?.(fileName, text, writeByteOrderMark, onError, sourceFiles, data); + } + }, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + } + + filesWithoutDtsSignature.delete(targetSourceFile.fileName); + } + + hadDtsSignatureComputed.add(targetSourceFile.fileName); + } + + return baseEmit.call( + program, + targetSourceFile, + writeFile, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + }; + + try { + while ( + builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => { + if (ignoreForDiagnostics.has(sourceFile)) { + // If the affected file is a TTC shim, add the shim's original source file. + // This ensures that changes that affect TTC are typechecked even when the changes + // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. + // For example, changing @Input property types of a directive used in another component's + // template. + // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`. + if (isNgTypeCheckFile(sourceFile.fileName)) { + // This file name conversion relies on internal compiler logic and should be converted + // to an official method when available. + const originalFilename = getOriginalFilename(sourceFile.fileName); + const originalSourceFile = builder.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + } + } else { + affectedFiles.add(sourceFile); + tsAffectedFiles.push(sourceFile); + } + + return true; + }) + ) { + /* empty */ + } + } finally { + program.emit = baseEmit; + compilerOptions.isolatedModules = prevIsolatedModules; + compilerOptions.verbatimModuleSyntax = prevVerbatimModuleSyntax; + } + + // Transitive importers visited by `getSemanticDiagnosticsOfNextAffectedFile` via + // `handleDtsMayChangeOf` do not have a `.d.ts` signature computed via `program.emit` and + // instead have their BuilderProgram signature reset to `sourceFile.version`. Track them so + // `populatePreviousDtsSignatures` restores their `.d.ts` signature if they are modified later. + for (const sourceFile of tsAffectedFiles) { + if (!sourceFile.isDeclarationFile && !hadDtsSignatureComputed.has(sourceFile.fileName)) { + filesWithoutDtsSignature.add(sourceFile.fileName); + } + } + + // Also include files identified by Angular's incremental compilation graph as needing + // re-emission or template re-typechecking (such as selector renames or required input changes + // that only affect Angular metadata without altering standard TypeScript `.d.ts` signatures). + for (const sourceFile of builder.getSourceFiles()) { + if (sourceFile.isDeclarationFile || ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + if ( + !incrementalCompilation.safeToSkipEmit(sourceFile) || + incrementalCompilation.priorTypeCheckingResultsFor(sourceFile) === null + ) { + affectedFiles.add(sourceFile); + } + } + + // Add all files with associated template type checking files. + // Stored TS build info does not have knowledge of the AOT compiler or the typechecking state of the templates. + // To ensure that errors are reported correctly, all AOT component diagnostics need to be analyzed even if build + // info is present. + if (includeTTC) { + for (const sourceFile of builder.getSourceFiles()) { + if (ignoreForDiagnostics.has(sourceFile) && isNgTypeCheckFile(sourceFile.fileName)) { + // This file name conversion relies on internal compiler logic and should be converted + // to an official method when available. + const originalFilename = getOriginalFilename(sourceFile.fileName); + const originalSourceFile = builder.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + } + } + } + + return affectedFiles; +} + +/** + * Ensures `.d.ts` shape signatures are computed on the previous `BuilderProgram` for modified + * files before the next `BuilderProgram` is created. + * + * On the initial build (`oldProgram === undefined`), TypeScript initializes each file's shape + * signature to its `.ts` source version hash rather than emitting `.d.ts` declarations for every + * file. When a file is modified on a watch rebuild, TypeScript computes its new `.d.ts` signature + * and compares it against the previous program's stored signature. Emitting the `.d.ts` signature + * on `oldProgram` for the modified file replaces its initial `.ts` version hash with its true + * `.d.ts` signature prior to diffing, avoiding false-positive `.d.ts` cascades across importers. + */ +function populatePreviousDtsSignatures( + oldProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + modifiedFiles: ReadonlySet, + filesWithoutDtsSignature: Set, +): void { + const filesToUpdate: ts.SourceFile[] = []; + for (const modifiedFile of modifiedFiles) { + const sf = oldProgram.getSourceFile(modifiedFile); + if (sf && !sf.isDeclarationFile && filesWithoutDtsSignature.delete(sf.fileName)) { + filesToUpdate.push(sf); + } + } + + if (filesToUpdate.length === 0) { + return; + } + + const compilerOptions = oldProgram.getCompilerOptions(); + const prevDeclaration = compilerOptions.declaration; + const prevComposite = compilerOptions.composite; + const prevNoEmit = compilerOptions.noEmit; + const prevNoEmitOnError = compilerOptions.noEmitOnError; + + compilerOptions.declaration = true; + compilerOptions.composite = false; + compilerOptions.noEmit = false; + compilerOptions.noEmitOnError = false; + + const program = oldProgram.getProgram(); + const baseEmit = program.emit as ( + targetSourceFile?: ts.SourceFile, + writeFile?: ts.WriteFileCallback, + cancellationToken?: ts.CancellationToken, + emitOnlyDtsFiles?: boolean | EmitOnly, + customTransformers?: ts.CustomTransformers, + forceDtsEmit?: boolean, + ) => ts.EmitResult; + program.emit = (targetSourceFile, writeFile, cancellationToken) => + baseEmit.call( + program, + targetSourceFile, + writeFile, + cancellationToken, + EmitOnly.BuilderSignature, + undefined, + true, // forceDtsEmit + ); + + try { + for (const sf of filesToUpdate) { + oldProgram.emit(sf, () => {}, undefined, true); + } + } finally { + program.emit = baseEmit; + compilerOptions.declaration = prevDeclaration; + compilerOptions.composite = prevComposite; + compilerOptions.noEmit = prevNoEmit; + compilerOptions.noEmitOnError = prevNoEmitOnError; + } +} + +/** + * Checks whether the analyzed Angular program contains any non-standalone `@Component` + * declarations in user source files that lack an owning `NgModule` scope. + */ +function hasUnscopedComponent(angularCompiler: ng.NgCompiler): boolean { + // `incrementalCompilation.state` is typed as the `IncrementalState` union + // (`AnalyzedIncrementalState | DeltaIncrementalState | FreshIncrementalState`), where only + // `AnalyzedIncrementalState` (populated once `analyzeAsync()` completes) contains + // `priorAnalysis`. Because the `IncrementalStateKind` discriminant enum is not re-exported + // from `@angular/compiler-cli`, narrow the union via `'priorAnalysis' in state`. + const state = angularCompiler.incrementalCompilation.state; + if (!('priorAnalysis' in state)) { + return false; + } + + let templateTypeChecker: ReturnType | undefined; + + for (const [sourceFile, records] of state.priorAnalysis) { + if (records.length === 0 || angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + for (const { node } of records) { + if (!ts.isClassDeclaration(node)) { + continue; + } + + const meta = angularCompiler.getMeta(node); + if (meta && 'isComponent' in meta && meta.isComponent && !meta.isStandalone) { + if (!templateTypeChecker) { + // `NgtscProgram` initializes `NgCompiler` with `enableTemplateTypeChecker = false`, which + // guards the public `getTemplateTypeChecker()` getter. Temporarily enable the flag after + // `analyzeAsync()` has already constructed `TemplateTypeCheckerImpl` so we can query + // `getOwningNgModule()` without altering `typeCheckingConfig.enableTemplateTypeChecker`. + const compiler = angularCompiler as { enableTemplateTypeChecker: boolean }; + const previous = compiler.enableTemplateTypeChecker; + try { + compiler.enableTemplateTypeChecker = true; + templateTypeChecker = angularCompiler.getTemplateTypeChecker(); + } finally { + compiler.enableTemplateTypeChecker = previous; + } + } + + if (templateTypeChecker.getOwningNgModule(node) === null) { + return true; + } + } + } + } + + return false; +} diff --git a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts new file mode 100644 index 000000000000..87a7c7822fc3 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import type { PartialMessage } from 'esbuild'; +import * as path from 'node:path'; +import type ts from 'typescript'; +import { toPosixPath } from '../../../utils/path'; + +export interface CompilerOptionOverrides { + sourcemap?: boolean; + preserveSymlinks?: boolean; + cachePath?: string; + externalRuntimeStyles?: boolean; + enableHmr?: boolean; + instrumentForCoverage?: boolean; + includeTestMetadata?: boolean; + customConditions?: string[]; + rootFiles?: string[]; + declarationMap?: boolean; + compilationMode?: 'full' | 'partial'; + paths?: Record; + basePath?: string; +} + +export function transformCompilerOptions( + typeScript: typeof ts, + baseCompilerOptions: ng.CompilerOptions, + overrides?: CompilerOptionOverrides, + tsconfig?: string, + buildType: 'application' | 'library' = 'application', +): { compilerOptions: ng.CompilerOptions; warnings: PartialMessage[] } { + const compilerOptions = { ...baseCompilerOptions }; + const warnings: PartialMessage[] = []; + const isLibrary = buildType === 'library'; + + if (isLibrary) { + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.module = typeScript.ModuleKind.Preserve; + compilerOptions.importHelpers = true; + compilerOptions.declaration = true; + compilerOptions.declarationMap = !!( + overrides?.declarationMap ?? compilerOptions.declarationMap + ); + compilerOptions.declarationDir = undefined; + } + + if ( + compilerOptions.target === undefined || + compilerOptions.target < typeScript.ScriptTarget.ES2022 + ) { + // If 'useDefineForClassFields' is already defined in the users project leave the value as is. + // Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995 + // which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well. + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.useDefineForClassFields ??= false; + + warnings.push({ + text: + `TypeScript compiler options 'target' and 'useDefineForClassFields' are set to 'ES2022' and ` + + `'false' respectively by the Angular CLI.`, + location: tsconfig ? { file: tsconfig } : null, + notes: [ + { + text: + 'To control ECMA version and features use the Browserslist configuration. ' + + 'For more information, see https://angular.dev/tools/cli/build#configuring-browser-compatibility', + }, + ], + }); + } + + if (!isLibrary && compilerOptions.compilationMode === 'partial') { + warnings.push({ + text: 'Angular partial compilation mode is not supported when building applications.', + location: null, + notes: [{ text: 'Full compilation mode will be used instead.' }], + }); + compilerOptions.compilationMode = 'full'; + } else if (overrides?.compilationMode) { + compilerOptions.compilationMode = overrides.compilationMode; + } else if (isLibrary) { + compilerOptions.compilationMode ??= 'partial'; + } + + // Enable incremental compilation by default if caching is enabled and incremental is not explicitly disabled + if (compilerOptions.incremental !== false && overrides?.cachePath) { + compilerOptions.incremental = true; + // Set the build info file location to the configured cache directory + compilerOptions.tsBuildInfoFile = path.join(overrides.cachePath, '.tsbuildinfo'); + } else { + compilerOptions.incremental = false; + } + + if ( + compilerOptions.module === undefined || + compilerOptions.module < typeScript.ModuleKind.ES2015 + ) { + compilerOptions.module = typeScript.ModuleKind.ES2022; + warnings.push({ + text: `TypeScript compiler options 'module' values 'CommonJS', 'UMD', 'System' and 'AMD' are not supported.`, + location: null, + notes: [{ text: `The 'module' option will be set to 'ES2022' instead.` }], + }); + } + + if (compilerOptions.isolatedModules && compilerOptions.emitDecoratorMetadata) { + warnings.push({ + text: `TypeScript compiler option 'isolatedModules' may prevent the 'emitDecoratorMetadata' option from emitting all metadata.`, + location: null, + notes: [ + { + text: + `The 'emitDecoratorMetadata' option is not required by Angular ` + + 'and can be removed if not explicitly required by the project.', + }, + ], + }); + } + + // Synchronize custom resolve conditions. + // Set if using the supported bundler resolution mode (bundler is the default in new projects) + if ( + compilerOptions.moduleResolution === typeScript.ModuleResolutionKind.Bundler || + compilerOptions.module === typeScript.ModuleKind.Preserve + ) { + compilerOptions.customConditions = overrides?.customConditions; + } + + return { + compilerOptions: { + ...compilerOptions, + basePath: overrides?.basePath ? toPosixPath(overrides.basePath) : compilerOptions.basePath, + noEmit: false, + noEmitOnError: false, + emitDeclarationOnly: false, + composite: false, + inlineSources: !!overrides?.sourcemap, + inlineSourceMap: !isLibrary && !!overrides?.sourcemap, + sourceMap: isLibrary ? !!overrides?.sourcemap : undefined, + mapRoot: undefined, + sourceRoot: undefined, + preserveSymlinks: overrides?.preserveSymlinks, + externalRuntimeStyles: overrides?.externalRuntimeStyles, + _enableHmr: !!overrides?.enableHmr, + // TypeScript transpilation is forced if: + // - Building a library (TypeScript emits both .js and .d.ts in a single pass). + // - isolatedModules is disabled (TS needs full module types to emit JS). + // - Karma code coverage is active (the coverage instrumentation transformer is Babel-based + // and cannot parse raw TypeScript code; Vitest handles coverage instrumentation downstream). + _useTypeScriptTranspilation: + isLibrary || !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, + supportTestBed: isLibrary ? undefined : !!overrides?.includeTestMetadata, + supportJitMode: isLibrary ? undefined : !!overrides?.includeTestMetadata, + paths: overrides?.paths + ? { ...baseCompilerOptions.paths, ...overrides.paths } + : baseCompilerOptions.paths, + }, + warnings, + }; +} diff --git a/packages/angular/build/src/tools/angular/compilation/diagnostics.ts b/packages/angular/build/src/tools/angular/compilation/diagnostics.ts new file mode 100644 index 000000000000..41563b90ebbd --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/diagnostics.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { PartialMessage, PartialNote } from 'esbuild'; +import { platform } from 'node:os'; +import type ts from 'typescript'; + +/** + * Converts TypeScript Diagnostic related information into an esbuild compatible note object. + * Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic + * notes associated with the main Diagnostic. + * @param info The TypeScript diagnostic relative information to convert. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +function convertTypeScriptDiagnosticInfo( + typescript: typeof ts, + info: ts.DiagnosticRelatedInformation, + textPrefix?: string, +): PartialNote { + const newLine = platform() === 'win32' ? '\r\n' : '\n'; + let text = typescript.flattenDiagnosticMessageText(info.messageText, newLine); + if (textPrefix) { + text = textPrefix + text; + } + + const note: PartialNote = { text }; + + if (info.file) { + note.location = { + file: info.file.fileName, + length: info.length, + }; + + // Calculate the line/column location and extract the full line text that has the diagnostic + if (info.start !== undefined) { + const { line, character } = typescript.getLineAndCharacterOfPosition(info.file, info.start); + note.location.line = line + 1; + note.location.column = character; + + // The start position for the slice is the first character of the error line + const lineStartPosition = typescript.getPositionOfLineAndCharacter(info.file, line, 0); + + // The end position for the slice is the first character of the next line or the length of + // the entire file if the line is the last line of the file (getPositionOfLineAndCharacter + // will error if a nonexistent line is passed). + const { line: lastLineOfFile } = typescript.getLineAndCharacterOfPosition( + info.file, + Math.max(0, info.file.text.length - 1), + ); + const lineEndPosition = + line < lastLineOfFile + ? typescript.getPositionOfLineAndCharacter(info.file, line + 1, 0) + : info.file.text.length; + + note.location.lineText = info.file.text.slice(lineStartPosition, lineEndPosition).trimEnd(); + } + } + + return note; +} + +/** + * Converts a TypeScript Diagnostic message into an esbuild compatible message object. + * @param diagnostic The TypeScript diagnostic to convert. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +export function convertTypeScriptDiagnostic( + typescript: typeof ts, + diagnostic: ts.Diagnostic, +): PartialMessage { + let codePrefix = 'TS'; + let code = `${diagnostic.code}`; + + // Custom ngtsc diagnostics are prefixed with -99 which isn't a valid TypeScript diagnostic code. + // Strip it and mark the diagnostic as coming from Angular. Note that we can't rely on + // `diagnostic.source`, because it isn't always produced. This is identical to: + // https://github.com/angular/angular/blob/main/packages/compiler-cli/src/ngtsc/diagnostics/src/util.ts + if (code.startsWith('-99')) { + codePrefix = 'NG'; + code = code.slice(3); + } + + const message: PartialMessage = convertTypeScriptDiagnosticInfo( + typescript, + diagnostic, + `${codePrefix}${code}: `, + ); + + if (diagnostic.relatedInformation?.length) { + message.notes = diagnostic.relatedInformation.map((info) => + convertTypeScriptDiagnosticInfo(typescript, info), + ); + } + + return message; +} diff --git a/packages/angular/build/src/tools/angular/compilation/diagnostics_spec.ts b/packages/angular/build/src/tools/angular/compilation/diagnostics_spec.ts new file mode 100644 index 000000000000..8ea79c20ba43 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/diagnostics_spec.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import ts from 'typescript'; +import { convertTypeScriptDiagnostic } from './diagnostics'; + +describe('convertTypeScriptDiagnostic', () => { + it('populates line, column, and lineText when diagnostic or relatedInformation starts at offset 0', () => { + const sourceFile = ts.createSourceFile( + '/src/app/app.component.ts', + 'const invalid: number = "string";\nconst secondLine = 2;\n', + ts.ScriptTarget.Latest, + ); + const templateFile = ts.createSourceFile( + '/src/app/app.component.html', + '\n
Valid
\n', + ts.ScriptTarget.Latest, + ); + + const diagnostic: ts.Diagnostic = { + category: ts.DiagnosticCategory.Error, + code: -998001, + file: templateFile, + start: 0, + length: 17, + messageText: "'unknown-element' is not a known element.", + relatedInformation: [ + { + category: ts.DiagnosticCategory.Message, + code: 0, + file: sourceFile, + start: 0, + length: 5, + messageText: 'Error occurs in the template of component AppComponent.', + }, + ], + }; + + const result = convertTypeScriptDiagnostic(ts, diagnostic); + + expect(result.text).toBe("NG8001: 'unknown-element' is not a known element."); + expect(result.location).toEqual({ + file: '/src/app/app.component.html', + length: 17, + line: 1, + column: 0, + lineText: '', + }); + + expect(result.notes).toEqual([ + { + text: 'Error occurs in the template of component AppComponent.', + location: { + file: '/src/app/app.component.ts', + length: 5, + line: 1, + column: 0, + lineText: 'const invalid: number = "string";', + }, + }, + ]); + }); + + it('does not throw a TypeScript Debug Failure when diagnostic is reported on an empty file', () => { + const emptyFile = ts.createSourceFile('/src/empty.ts', '', ts.ScriptTarget.Latest); + + const diagnosticAtZero: ts.Diagnostic = { + category: ts.DiagnosticCategory.Error, + code: 2306, + file: emptyFile, + start: 0, + length: 0, + messageText: "File '/src/empty.ts' is not a module.", + }; + + expect(() => convertTypeScriptDiagnostic(ts, diagnosticAtZero)).not.toThrow(); + expect(convertTypeScriptDiagnostic(ts, diagnosticAtZero)).toEqual({ + text: "TS2306: File '/src/empty.ts' is not a module.", + location: { + file: '/src/empty.ts', + length: 0, + line: 1, + column: 0, + lineText: '', + }, + }); + + const diagnosticPastEnd: ts.Diagnostic = { + category: ts.DiagnosticCategory.Error, + code: 1005, + file: emptyFile, + start: 1, + length: 0, + messageText: 'Unexpected end of text.', + }; + + expect(() => convertTypeScriptDiagnostic(ts, diagnosticPastEnd)).not.toThrow(); + expect(convertTypeScriptDiagnostic(ts, diagnosticPastEnd).location).toEqual({ + file: '/src/empty.ts', + length: 0, + line: 1, + column: 1, + lineText: '', + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/factory.ts b/packages/angular/build/src/tools/angular/compilation/factory.ts new file mode 100644 index 000000000000..3944979f1ee5 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/factory.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { useParallelTs } from '../../../utils/environment-options'; +import type { AngularCompilation } from './angular-compilation'; + +export type AngularCompilationMode = 'aot' | 'jit' | 'transform'; + +/** + * Creates an Angular compilation object that can be used to perform Angular application + * compilation either for AOT, JIT, or on-demand transform mode. By default a parallel compilation is created + * that uses a Node.js worker thread. + * @param mode True or 'jit' for JIT mode; False or 'aot' for AOT compilation; 'transform' for on-demand transformation. + * @param browserOnlyBuild True, for browser only builds; False, for browser and server builds. + * @param parallel True to execute compilation in a worker thread. + * @returns An instance of an Angular compilation object. + */ +export async function createAngularCompilation( + mode: boolean | AngularCompilationMode, + browserOnlyBuild: boolean, + parallel: boolean = useParallelTs, +): Promise { + if (mode === 'transform') { + throw new Error('Transform compilation mode is not supported.'); + } + + const isJit = mode === true || mode === 'jit'; + + if (parallel) { + const { ParallelCompilation } = await import('./parallel-compilation'); + + return new ParallelCompilation(isJit, browserOnlyBuild); + } + + if (isJit) { + const { JitCompilation } = await import('./jit-compilation'); + + return new JitCompilation(browserOnlyBuild); + } else { + const { AotCompilation } = await import('./aot-compilation'); + + return new AotCompilation(browserOnlyBuild); + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/hmr-candidates.ts b/packages/angular/build/src/tools/angular/compilation/hmr-candidates.ts new file mode 100644 index 000000000000..cba0b18e7414 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/hmr-candidates.ts @@ -0,0 +1,347 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import ts from 'typescript'; + +/** + * Analyzes one or more modified files for changes to determine if any + * class declarations for Angular components are candidates for hot + * module replacement (HMR). If any source files are also modified but + * are not candidates then all candidates become invalid. This invalidation + * ensures that a full rebuild occurs and the running application stays + * synchronized with the code. + * @param modifiedFiles A set of modified files to analyze. + * @param param1 An Angular compiler instance + * @param staleSourceFiles A map of paths to previous source file instances. + * @returns A set of HMR candidate component class declarations. + */ +export function collectHmrCandidates( + modifiedFiles: Set, + { compiler }: ng.NgtscProgram, + staleSourceFiles: Map | undefined, +): Set { + const candidates = new Set(); + + for (const file of modifiedFiles) { + // If the file is a template for component(s), add component classes as candidates + const templateFileNodes = compiler.getComponentsWithTemplateFile(file); + if (templateFileNodes.size) { + templateFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration)); + continue; + } + + // If the file is a style for component(s), add component classes as candidates + const styleFileNodes = compiler.getComponentsWithStyleFile(file); + if (styleFileNodes.size) { + styleFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration)); + continue; + } + + const staleSource = staleSourceFiles?.get(file); + if (staleSource === undefined) { + // Unknown file requires a rebuild so clear out the candidates and stop collecting + candidates.clear(); + break; + } + + const updatedSource = compiler.getCurrentProgram().getSourceFile(file); + if (updatedSource === undefined) { + // No longer existing program file requires a rebuild so clear out the candidates and stop collecting + candidates.clear(); + break; + } + + // Analyze the stale and updated file for changes + const fileCandidates = analyzeFileUpdates(staleSource, updatedSource, compiler); + if (fileCandidates) { + fileCandidates.forEach((node) => candidates.add(node)); + } else { + // Unsupported HMR changes present + // Only template and style literal changes are allowed. + candidates.clear(); + break; + } + } + + return candidates; +} + +/** + * Analyzes the updates of a source file for potential HMR component class candidates. + * A source file can contain candidates if only the Angular component metadata of a class + * has been changed and the metadata changes are only of supported fields. + * @param stale The stale (previous) source file instance. + * @param updated The updated source file instance. + * @param compiler An Angular compiler instance. + * @returns An array of candidate class declarations; or `null` if unsupported changes are present. + */ +function analyzeFileUpdates( + stale: ts.SourceFile, + updated: ts.SourceFile, + compiler: ng.NgtscProgram['compiler'], +): ts.ClassDeclaration[] | null { + if (stale.statements.length !== updated.statements.length) { + return null; + } + + const candidates: ts.ClassDeclaration[] = []; + + for (let i = 0; i < updated.statements.length; ++i) { + const updatedNode = updated.statements[i]; + const staleNode = stale.statements[i]; + + if (ts.isClassDeclaration(updatedNode)) { + if (!ts.isClassDeclaration(staleNode)) { + return null; + } + + // Check class declaration differences (name/heritage/modifiers) + if (updatedNode.name?.text !== staleNode.name?.text) { + return null; + } + if (!equalRangeText(updatedNode.heritageClauses, updated, staleNode.heritageClauses, stale)) { + return null; + } + const updatedModifiers = ts.getModifiers(updatedNode); + const staleModifiers = ts.getModifiers(staleNode); + if ( + updatedModifiers?.length !== staleModifiers?.length || + !updatedModifiers?.every((updatedModifier) => + staleModifiers?.some((staleModifier) => updatedModifier.kind === staleModifier.kind), + ) + ) { + return null; + } + + // Check for component class nodes + const meta = compiler.getMeta(updatedNode); + if (meta?.decorator && (meta as { isComponent?: boolean }).isComponent === true) { + const updatedDecorators = ts.getDecorators(updatedNode); + const staleDecorators = ts.getDecorators(staleNode); + if (!staleDecorators || staleDecorators.length !== updatedDecorators?.length) { + return null; + } + + // TODO: Check other decorators instead of assuming all multi-decorator components are unsupported + if (staleDecorators.length > 1) { + return null; + } + + // Find index of component metadata decorator + const metaDecoratorIndex = updatedDecorators?.indexOf(meta.decorator); + assert( + metaDecoratorIndex !== undefined, + 'Component metadata decorator should always be present on component class.', + ); + const updatedDecoratorExpression = meta.decorator.expression; + assert( + ts.isCallExpression(updatedDecoratorExpression) && + updatedDecoratorExpression.arguments.length === 1, + 'Component metadata decorator should contain a call expression with a single argument.', + ); + + // Check the matching stale index for the component decorator + const staleDecoratorExpression = staleDecorators[metaDecoratorIndex]?.expression; + if ( + !staleDecoratorExpression || + !ts.isCallExpression(staleDecoratorExpression) || + staleDecoratorExpression.arguments.length !== 1 + ) { + return null; + } + + // Check decorator name/expression + // NOTE: This would typically be `Component` but can also be a property expression or some other alias. + // To avoid complex checks, this ensures the textual representation does not change. This has a low chance + // of a false positive if the expression is changed to still reference the `Component` type but has different + // text. However, it is rare for `Component` to not be used directly and additionally unlikely that it would + // be changed between edits. A false positive would also only lead to a difference of a full page reload versus + // an HMR update. + if ( + !equalRangeText( + updatedDecoratorExpression.expression, + updated, + staleDecoratorExpression.expression, + stale, + ) + ) { + return null; + } + + // Compare component meta decorator object literals + const analysis = analyzeMetaUpdates( + staleDecoratorExpression, + stale, + updatedDecoratorExpression, + updated, + ); + if (analysis === MetaUpdateAnalysis.Unsupported) { + return null; + } + + // Compare text of the member nodes to determine if any changes have occurred + if (!equalRangeText(updatedNode.members, updated, staleNode.members, stale)) { + // A change to a member outside a component's metadata is unsupported + return null; + } + + // If all previous class checks passed, this class is supported for HMR updates + if (analysis === MetaUpdateAnalysis.Supported) { + candidates.push(updatedNode); + } + continue; + } + } + + // Compare text of the statement nodes to determine if any changes have occurred + // TODO: Consider expanding this to check semantic updates for each node kind + if (!equalRangeText(updatedNode, updated, staleNode, stale)) { + // A change to a statement outside a component's metadata is unsupported + return null; + } + } + + return candidates; +} + +/** + * The set of Angular component metadata fields that are supported by HMR updates. + */ +const SUPPORTED_FIELD_NAMES = new Set([ + 'template', + 'templateUrl', + 'styles', + 'styleUrl', + 'stylesUrl', +]); + +enum MetaUpdateAnalysis { + Supported, + Unsupported, + None, +} + +/** + * Analyzes the metadata fields of a decorator call expression for unsupported HMR updates. + * Only updates to supported fields can be present for HMR to be viable. + * @param staleCall A call expression instance. + * @param staleSource The source file instance containing the stale call instance. + * @param updatedCall A call expression instance. + * @param updatedSource The source file instance containing the updated call instance. + * @returns A MetaUpdateAnalysis enum value. + */ +function analyzeMetaUpdates( + staleCall: ts.CallExpression, + staleSource: ts.SourceFile, + updatedCall: ts.CallExpression, + updatedSource: ts.SourceFile, +): MetaUpdateAnalysis { + const staleObject = staleCall.arguments[0]; + const updatedObject = updatedCall.arguments[0]; + let hasSupportedUpdate = false; + + if (!ts.isObjectLiteralExpression(staleObject) || !ts.isObjectLiteralExpression(updatedObject)) { + return MetaUpdateAnalysis.Unsupported; + } + + const supportedFields = new Map(); + const unsupportedFields: ts.Node[] = []; + + for (const property of staleObject.properties) { + if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) { + // Unsupported object literal property + return MetaUpdateAnalysis.Unsupported; + } + + const name = property.name.text; + if (SUPPORTED_FIELD_NAMES.has(name)) { + supportedFields.set(name, property.initializer); + continue; + } + + unsupportedFields.push(property.initializer); + } + + let i = 0; + for (const property of updatedObject.properties) { + if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) { + // Unsupported object literal property + return MetaUpdateAnalysis.Unsupported; + } + + const name = property.name.text; + if (SUPPORTED_FIELD_NAMES.has(name)) { + const staleInitializer = supportedFields.get(name); + // If the supported field was added or has its content changed, there has been a supported update + if ( + !staleInitializer || + !equalRangeText(property.initializer, updatedSource, staleInitializer, staleSource) + ) { + hasSupportedUpdate = true; + } + // Remove the field entry to allow tracking removed fields + supportedFields.delete(name); + continue; + } + + // Compare in order + if (!equalRangeText(property.initializer, updatedSource, unsupportedFields[i++], staleSource)) { + return MetaUpdateAnalysis.Unsupported; + } + } + + if (i !== unsupportedFields.length) { + return MetaUpdateAnalysis.Unsupported; + } + + // Any remaining supported field indicates a field removal. This is also considered a supported update. + hasSupportedUpdate ||= supportedFields.size > 0; + + return hasSupportedUpdate ? MetaUpdateAnalysis.Supported : MetaUpdateAnalysis.None; +} + +/** + * Compares the text from a provided range in a source file to the text of a range in a second source file. + * The comparison avoids making any intermediate string copies. + * @param firstRange A text range within the first source file. + * @param firstSource A source file instance. + * @param secondRange A text range within the second source file. + * @param secondSource A source file instance. + * @returns true, if the text from both ranges is equal; false, otherwise. + */ +function equalRangeText( + firstRange: ts.ReadonlyTextRange | undefined, + firstSource: ts.SourceFile, + secondRange: ts.ReadonlyTextRange | undefined, + secondSource: ts.SourceFile, +): boolean { + // Check matching undefined values + if (!firstRange || !secondRange) { + return firstRange === secondRange; + } + + // Ensure lengths are equal + const firstLength = firstRange.end - firstRange.pos; + const secondLength = secondRange.end - secondRange.pos; + if (firstLength !== secondLength) { + return false; + } + + // Check each character + for (let i = 0; i < firstLength; ++i) { + const firstChar = firstSource.text.charCodeAt(i + firstRange.pos); + const secondChar = secondSource.text.charCodeAt(i + secondRange.pos); + if (firstChar !== secondChar) { + return false; + } + } + + return true; +} diff --git a/packages/angular/build/src/tools/angular/compilation/index.ts b/packages/angular/build/src/tools/angular/compilation/index.ts new file mode 100644 index 000000000000..268abec678ca --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export { + AngularCompilation, + type AngularCompilationOptions, + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, + type FileTransformResult, +} from './angular-compilation'; +export type { CompilerOptionOverrides } from './compiler-options'; +export { createAngularCompilation, type AngularCompilationMode } from './factory'; diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts new file mode 100644 index 000000000000..ec88987369b0 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import ts from 'typescript'; +import { profileSync } from '../../../utils/profiling'; +import { AngularHostOptions, createAngularCompilerHost } from '../angular-host'; +import { createJitResourceTransformer } from '../transformers/jit-resource-transformer'; +import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer'; +import { createWorkerTransformer } from '../transformers/web-worker-transformer'; +import { + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { TypeScriptCompilation } from './typescript-compilation'; + +class JitCompilationState { + constructor( + public readonly compilerHost: ng.CompilerHost, + public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public readonly constructorParametersDownlevelTransform: ts.TransformerFactory, + public readonly replaceResourcesTransform: ts.TransformerFactory, + public readonly webWorkerTransform: ts.TransformerFactory, + ) {} +} + +export class JitCompilation extends TypeScriptCompilation { + #state?: JitCompilationState; + + constructor(private readonly browserOnlyBuild: boolean) { + super(); + } + + async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + // Dynamically load the Angular compiler CLI package + const { constructorParametersDownlevelTransform } = + await import('@angular/compiler-cli/private/tooling'); + + // Load the compiler configuration and transform as needed + const { + compilerOptions, + rootNames, + errors: configurationDiagnostics, + warnings, + tsConfigFiles, + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); + + if (hostOptions.modifiedFiles) { + this.invalidateFiles(hostOptions.modifiedFiles); + } + + // Create Angular compiler host + const host = createAngularCompilerHost( + ts, + compilerOptions, + hostOptions, + undefined, + this.sourceFiles, + ); + + // Create the TypeScript Program + const typeScriptProgram = profileSync('TS_CREATE_PROGRAM', () => + ts.createEmitAndSemanticDiagnosticsBuilderProgram( + rootNames, + compilerOptions, + host, + this.#state?.typeScriptProgram ?? ts.readBuilderProgram(compilerOptions, host), + configurationDiagnostics, + ), + ); + + this.#state = new JitCompilationState( + host, + typeScriptProgram, + constructorParametersDownlevelTransform(typeScriptProgram.getProgram()), + createJitResourceTransformer(() => typeScriptProgram.getProgram().getTypeChecker()), + createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), + ); + + const referencedFiles = [ + ...tsConfigFiles, + ...typeScriptProgram.getSourceFiles().map((sourceFile) => sourceFile.fileName), + ]; + + return { compilerOptions, referencedFiles, warnings }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + assert(this.#state, 'Compilation must be initialized prior to collecting diagnostics.'); + const { typeScriptProgram } = this.#state; + + // Collect program level diagnostics + if (modes & DiagnosticModes.Option) { + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); + } + if (modes & DiagnosticModes.Syntactic) { + yield* typeScriptProgram.getGlobalDiagnostics(); + yield* profileSync('NG_DIAGNOSTICS_SYNTACTIC', () => + typeScriptProgram.getSyntacticDiagnostics(), + ); + } + if (modes & DiagnosticModes.Semantic) { + yield* profileSync('NG_DIAGNOSTICS_SEMANTIC', () => + typeScriptProgram.getSemanticDiagnostics(), + ); + } + } + + override emitAffectedFiles(): Iterable { + assert(this.#state, 'Compilation must be initialized prior to emitting files.'); + const { + compilerHost, + typeScriptProgram, + constructorParametersDownlevelTransform, + replaceResourcesTransform, + webWorkerTransform, + } = this.#state; + const compilerOptions = typeScriptProgram.getCompilerOptions(); + const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; + + const emittedFiles: EmitFileResult[] = []; + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { + // Save builder info contents to specified location + compilerHost.writeFile(filename, contents, false); + + return; + } + + assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename); + + emittedFiles.push({ filename: sourceFiles[0].fileName, contents }); + }; + const transformers = { + before: [ + replaceResourcesTransform, + constructorParametersDownlevelTransform, + webWorkerTransform, + ], + }; + + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } + + // TypeScript will loop until there are no more affected files in the program + while ( + typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers) + ) { + /* empty */ + } + + return emittedFiles; + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts new file mode 100644 index 000000000000..60945a758081 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { PartialMessage } from 'esbuild'; +import { createRequire } from 'node:module'; +import { MessageChannel } from 'node:worker_threads'; +import { mergeCumulativeDurations } from '../../../utils/profiling'; +import { WorkerPool } from '../../../utils/worker-pool'; +import type { AngularHostOptions } from '../angular-host'; +import { + AngularCompilation, + AngularCompilationResult, + DiagnosticModes, + EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; + +/** + * An Angular compilation which uses a Node.js Worker thread to load and execute + * the TypeScript and Angular compilers. This allows for longer synchronous actions + * such as semantic and template diagnostics to be calculated in parallel to the + * other aspects of the application bundling process. The worker thread also has + * a separate memory pool which significantly reduces the need for adjusting the + * main Node.js CLI process memory settings with large application code sizes. + */ +export class ParallelCompilation extends AngularCompilation { + readonly #worker: WorkerPool; + #webWorkerChannel?: MessageChannel; + + constructor( + private readonly jit: boolean, + private readonly browserOnlyBuild: boolean, + ) { + super(); + + // TODO: Convert to import.meta usage during ESM transition + const localRequire = createRequire(__filename); + + this.#worker = new WorkerPool({ + maxThreads: 1, + idleTimeout: Infinity, + filename: localRequire.resolve('./parallel-worker'), + }); + } + + override async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + const stylesheetChannel = new MessageChannel(); + // The request identifier is required because Angular can issue multiple concurrent requests + stylesheetChannel.port1.on( + 'message', + ({ requestId, data, containingFile, stylesheetFile, order, className }) => { + hostOptions + .transformStylesheet(data, containingFile, stylesheetFile, order, className) + .then((value) => stylesheetChannel.port1.postMessage({ requestId, value })) + .catch((error) => stylesheetChannel.port1.postMessage({ requestId, error })); + }, + ); + + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + + let webWorkerSignal: Int32Array | undefined; + if (buildType !== 'library') { + // The web worker processing is a synchronous operation and uses shared memory combined with + // the Atomics API to block execution here until a response is received. + const webWorkerChannel = new MessageChannel(); + this.#webWorkerChannel = webWorkerChannel; + const signal = new Int32Array(new SharedArrayBuffer(4)); + webWorkerSignal = signal; + webWorkerChannel.port1.on('message', ({ workerFile, containingFile }) => { + try { + const workerCodeFile = hostOptions.processWebWorker(workerFile, containingFile); + webWorkerChannel.port1.postMessage({ workerCodeFile }); + } catch (error) { + webWorkerChannel.port1.postMessage({ error }); + } finally { + Atomics.store(signal, 0, 1); + Atomics.notify(signal, 0); + } + }); + } + + let success = false; + try { + // Execute the initialize function in the worker thread + const result = await this.#worker.run( + { + fileReplacements: hostOptions.fileReplacements, + tsconfig, + jit: this.jit, + browserOnlyBuild: this.browserOnlyBuild, + compilerOptionOverrides, + buildType, + stylesheetPort: stylesheetChannel.port2, + webWorkerPort: this.#webWorkerChannel?.port2, + webWorkerSignal, + }, + { + name: 'initialize', + transferList: this.#webWorkerChannel + ? [stylesheetChannel.port2, this.#webWorkerChannel.port2] + : [stylesheetChannel.port2], + }, + ); + success = true; + + return result; + } finally { + stylesheetChannel.port1.close(); + if (!success) { + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + } + } + } + + override async diagnoseFiles( + modes = DiagnosticModes.All, + ): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> { + const { timings, ...result } = await this.#worker.run(modes, { name: 'diagnose' }); + if (timings) { + mergeCumulativeDurations(timings); + } + + return result; + } + + override async emitAffectedFiles(): Promise> { + try { + return await this.#worker.run(undefined, { name: 'emit' }); + } finally { + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + } + } + + override update(files: Set): Promise { + return this.#worker.run(files, { name: 'update' }); + } + + override close() { + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + + return this.#worker.destroy(); + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation_spec.ts new file mode 100644 index 000000000000..7157251e1ab9 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation_spec.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { MessageChannel } from 'node:worker_threads'; +import { AotCompilation } from './aot-compilation'; +import { initialize } from './parallel-worker'; + +describe('parallel-worker', () => { + it('uses incrementing numeric request IDs for transformStylesheet and handles omitted webWorkerPort in library builds', async () => { + const receivedRequestIds: unknown[] = []; + let workerResult: string | undefined; + + spyOn(AotCompilation.prototype, 'initialize').and.callFake(async (_tsconfig, hostOptions) => { + const [style1, style2] = await Promise.all([ + hostOptions.transformStylesheet('body {}', '/src/a.ts', '/src/a.css', 0, 'CompA'), + hostOptions.transformStylesheet('div {}', '/src/b.ts', '/src/b.css', 1, 'CompB'), + ]); + expect(style1).toBe('transformed:body {}'); + expect(style2).toBe('transformed:div {}'); + + workerResult = hostOptions.processWebWorker('./my.worker', '/src/a.ts'); + + return { + compilerOptions: {}, + referencedFiles: ['/src/a.ts'], + }; + }); + + const stylesheetChannel = new MessageChannel(); + stylesheetChannel.port1.on('message', ({ requestId, data }) => { + receivedRequestIds.push(requestId); + stylesheetChannel.port1.postMessage({ + requestId, + value: `transformed:${data}`, + }); + }); + + try { + const result = await initialize({ + jit: false, + browserOnlyBuild: false, + tsconfig: '/src/tsconfig.lib.json', + buildType: 'library', + stylesheetPort: stylesheetChannel.port2, + }); + + expect(receivedRequestIds).toEqual([1, 2]); + expect(workerResult).toBe('./my.worker'); + expect(result.referencedFiles).toEqual(['/src/a.ts']); + } finally { + stylesheetChannel.port1.close(); + } + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts new file mode 100644 index 000000000000..dde3df4c0c20 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { PartialMessage } from 'esbuild'; +import assert from 'node:assert'; +import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads'; +import { initializeHash } from '../../../utils/hash'; +import { getAndClearCumulativeDurations } from '../../../utils/profiling'; +import type { + AngularCompilation, + AngularCompilationResult, + DiagnosticModes, +} from './angular-compilation'; +import { AotCompilation } from './aot-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { JitCompilation } from './jit-compilation'; + +export interface InitRequest { + jit: boolean; + browserOnlyBuild: boolean; + tsconfig: string; + fileReplacements?: Record; + compilerOptionOverrides?: CompilerOptionOverrides; + buildType?: 'application' | 'library'; + stylesheetPort: MessagePort; + webWorkerPort?: MessagePort; + webWorkerSignal?: Int32Array; +} + +let compilation: AngularCompilation | undefined; +let activeWebWorkerPort: MessagePort | undefined; + +const modifiedFiles = new Set(); + +export async function initialize(request: InitRequest): Promise { + activeWebWorkerPort?.close(); + activeWebWorkerPort = request.webWorkerPort; + + const currentModifiedFiles = new Set(modifiedFiles); + modifiedFiles.clear(); + + let success = false; + try { + await initializeHash(); + compilation ??= request.jit + ? new JitCompilation(request.browserOnlyBuild) + : new AotCompilation(request.browserOnlyBuild); + + let nextRequestId = 0; + const stylesheetRequests = new Map< + number, + [(value: string) => void, (reason: Error) => void] + >(); + request.stylesheetPort.on('message', ({ requestId, value, error }) => { + const handlers = stylesheetRequests.get(requestId); + if (handlers) { + stylesheetRequests.delete(requestId); + if (error) { + handlers[1](error); + } else { + handlers[0](value); + } + } + }); + + const { + compilerOptions, + referencedFiles, + externalStylesheets, + templateUpdates, + componentResourcesDependencies, + warnings, + } = await compilation.initialize( + request.tsconfig, + { + fileReplacements: request.fileReplacements, + modifiedFiles: currentModifiedFiles, + transformStylesheet(data, containingFile, stylesheetFile, order, className) { + const requestId = ++nextRequestId; + const resultPromise = new Promise((resolve, reject) => + stylesheetRequests.set(requestId, [resolve, reject]), + ); + + request.stylesheetPort.postMessage({ + requestId, + data, + containingFile, + stylesheetFile, + order, + className, + }); + + return resultPromise; + }, + processWebWorker(workerFile, containingFile) { + if (!request.webWorkerPort || !request.webWorkerSignal) { + return workerFile; + } + + Atomics.store(request.webWorkerSignal, 0, 0); + request.webWorkerPort.postMessage({ workerFile, containingFile }); + + Atomics.wait(request.webWorkerSignal, 0, 0); + const result = receiveMessageOnPort(request.webWorkerPort)?.message; + + if (result?.error) { + throw result.error; + } + + return result?.workerCodeFile ?? workerFile; + }, + }, + request.compilerOptionOverrides, + request.buildType, + ); + + success = true; + + return { + externalStylesheets, + templateUpdates, + referencedFiles, + warnings, + // TODO: Expand? `allowJs`, `isolatedModules`, `sourceMap`, `inlineSourceMap` are the only fields needed currently. + compilerOptions: { + allowJs: compilerOptions.allowJs, + compilationMode: compilerOptions.compilationMode, + declarationMap: compilerOptions.declarationMap, + isolatedModules: compilerOptions.isolatedModules, + sourceMap: compilerOptions.sourceMap, + inlineSourceMap: compilerOptions.inlineSourceMap, + _useTypeScriptTranspilation: compilerOptions['_useTypeScriptTranspilation'] as + boolean | undefined, + }, + componentResourcesDependencies, + }; + } finally { + request.stylesheetPort.close(); + if (!success) { + activeWebWorkerPort?.close(); + activeWebWorkerPort = undefined; + } + } +} + +export async function diagnose(modes: DiagnosticModes): Promise<{ + errors?: PartialMessage[]; + warnings?: PartialMessage[]; + timings?: Record; +}> { + assert(compilation); + + const diagnostics = await compilation.diagnoseFiles(modes); + const timings = getAndClearCumulativeDurations(); + + return { + ...diagnostics, + timings, + }; +} + +export async function emit() { + assert(compilation); + + try { + const files = await compilation.emitAffectedFiles(); + + return [...files]; + } finally { + activeWebWorkerPort?.close(); + activeWebWorkerPort = undefined; + } +} + +export async function update(files: Set): Promise { + for (const file of files) { + modifiedFiles.add(file); + } + await compilation?.update?.(files); +} diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts new file mode 100644 index 000000000000..6601500fbcc9 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import type { PartialMessage } from 'esbuild'; +import ts from 'typescript'; +import { canonicalizePath, toPosixPath } from '../../../utils/path'; +import { profileAsync, profileSync } from '../../../utils/profiling'; +import { AngularCompilation, DiagnosticModes } from './angular-compilation'; +import { type CompilerOptionOverrides, transformCompilerOptions } from './compiler-options'; +import { convertTypeScriptDiagnostic } from './diagnostics'; + +export interface TransformedConfiguration { + compilerOptions: ng.CompilerOptions; + rootNames: string[]; + fallbackRootNames?: string[]; + errors: ts.Diagnostic[]; + warnings: PartialMessage[]; + tsConfigFiles: readonly string[]; +} + +export abstract class TypeScriptCompilation extends AngularCompilation { + static #angularCompilerCliModule?: typeof ng; + readonly #extendedConfigCache = new Map(); + + static async loadCompilerCli(): Promise { + TypeScriptCompilation.#angularCompilerCliModule ??= await import('@angular/compiler-cli'); + + return TypeScriptCompilation.#angularCompilerCliModule; + } + + protected async loadConfiguration( + tsconfig: string, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); + + const { + options: originalCompilerOptions, + rootNames: originalRootNames, + errors, + } = profileSync('NG_READ_CONFIG', () => + readConfiguration( + tsconfig, + { + // Angular specific configuration defaults and overrides to ensure a functioning compilation. + suppressOutputPathCheck: true, + outDir: undefined, + sourceMap: false, + ...(buildType === 'library' + ? { declaration: true } + : { + declaration: false, + declarationMap: false, + }), + allowEmptyCodegenFiles: false, + annotationsAs: 'decorators', + enableResourceInlining: false, + supportTestBed: false, + supportJitMode: false, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. + removeComments: false, + }, + undefined, + this.#extendedConfigCache, + ), + ); + + const tsConfigFiles = [toPosixPath(tsconfig), ...this.#extendedConfigCache.keys()]; + + let rootNames = originalRootNames; + let fallbackRootNames: string[] | undefined; + if (compilerOptionOverrides?.rootFiles?.length) { + // When explicit rootFiles are provided (e.g., filtered unit test entry points), restrict + // the initial program to only those entry points so TypeScript resolves only their + // transitive imports and skips unrelated or non-included test/application files. + const rootFilesSet = new Set( + compilerOptionOverrides.rootFiles.map((file) => canonicalizePath(toPosixPath(file))), + ); + const fallbackFilesSet = new Set(rootFilesSet); + for (const file of originalRootNames) { + const canonicalFile = canonicalizePath(toPosixPath(file)); + // Ambient declaration files (.d.ts) are not imported directly, so they must always be + // retained from the tsconfig to preserve global type definitions. + if (/\.d\.[cm]?ts$/i.test(file)) { + rootFilesSet.add(canonicalFile); + } + fallbackFilesSet.add(canonicalFile); + } + rootNames = [...rootFilesSet]; + // For application builds (including unit tests), preserve the full set of tsconfig root + // files as a fallback in case the AOT compilation encounters a non-standalone component + // whose declaring NgModule is not directly imported by the entry points. Note that when a + // test tsconfig uses broad globs (e.g., `src/**/*.ts`), this fallback set will also include + // non-included spec files; this is an acceptable trade-off to keep compilation agnostic of + // test-file naming conventions and only occurs for legacy non-standalone components lacking + // explicit NgModule imports. Library builds are excluded because all public NgModules must + // be reachable from the library entry points and should never resolve compilation scope from + // unexported tsconfig files. + if (buildType === 'application' && fallbackFilesSet.size > rootFilesSet.size) { + fallbackRootNames = [...fallbackFilesSet]; + } + } + + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + originalCompilerOptions, + compilerOptionOverrides, + tsconfig, + buildType, + ); + + return { + compilerOptions, + rootNames, + fallbackRootNames, + errors, + warnings, + tsConfigFiles, + }; + } + + protected readonly sourceFiles = new Map(); + + protected invalidateFiles(files: Iterable): void { + for (const file of files) { + const posixFile = toPosixPath(file); + this.sourceFiles.delete(posixFile); + + if (this.#extendedConfigCache.size === 0) { + continue; + } + + if (this.#extendedConfigCache.delete(posixFile)) { + continue; + } + + // Check with lowercased key because TypeScript lowercases the keys + // of the extended config cache on case-insensitive operating systems. + this.#extendedConfigCache.delete(posixFile.toLowerCase()); + } + } + + override async update(files: Set): Promise { + this.invalidateFiles(files); + } + + protected abstract collectDiagnostics( + modes: DiagnosticModes, + ): Iterable | Promise>; + + override async diagnoseFiles( + modes = DiagnosticModes.All, + ): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> { + if (modes === DiagnosticModes.None) { + return {}; + } + + const result: { errors?: PartialMessage[]; warnings?: PartialMessage[] } = {}; + + await profileAsync('NG_DIAGNOSTICS_TOTAL', async () => { + const diagnostics = await this.collectDiagnostics(modes); + + for (const diagnostic of diagnostics) { + const message = convertTypeScriptDiagnostic(ts, diagnostic); + if (diagnostic.category === ts.DiagnosticCategory.Error) { + (result.errors ??= []).push(message); + } else { + (result.warnings ??= []).push(message); + } + } + }); + + return result; + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-printer.ts b/packages/angular/build/src/tools/angular/compilation/typescript-printer.ts new file mode 100644 index 000000000000..04d90ddb01c0 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/typescript-printer.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview Helper functions and typings for utilizing internal TypeScript printer + * and sourcemap generation APIs. + */ + +import assert from 'node:assert'; +import { dirname } from 'node:path'; +import ts from 'typescript'; + +/** + * Partial interface representing the internal TypeScript `EmitTextWriter` API. + * Used to collect printed text output from the printer. + */ +export interface EmitTextWriter { + write(s: string): void; + rawWrite(s: string): void; + writeLine(force?: boolean): void; + getText(): string; + clear(): void; + isAtStartOfLine(): boolean; + getTextPos(): number; + writeComment(s: string): void; +} + +/** + * Partial interface representing the internal TypeScript `SourceMapGenerator` API. + * Used to construct mappings and serialize a sourcemap. + */ +export interface SourceMapGenerator { + getSources(): string[]; + addSource(fileName: string): number; + setSourceContent(sourceIndex: number, content: string | null): void; + addMapping( + generatedLine: number, + generatedCharacter: number, + sourceIndex: number, + sourceLine: number, + sourceCharacter: number, + nameIndex?: number, + ): void; + toJSON(): object; + toString(): string; +} + +/** + * Extended `ts.Printer` interface containing the internal `writeFile` method + * which supports sourcemap generation. + */ +export interface ExtendedPrinter extends ts.Printer { + writeFile( + sourceFile: ts.SourceFile, + writer: EmitTextWriter, + sourceMapGenerator?: SourceMapGenerator, + ): void; +} + +/** + * Typing structure for internal TypeScript module exports that are not exposed + * in the public `@types/typescript` package. + */ +export interface TypeScriptInternals { + createTextWriter(newLine: string): EmitTextWriter; + createSourceMapGenerator( + host: { + getCurrentDirectory(): string; + getCanonicalFileName(fileName: string): string; + }, + file: string, + sourceRoot: string | undefined, + sourcesDirectoryPath: string, + generatorOptions: ts.CompilerOptions, + ): SourceMapGenerator; +} + +const tsInternals = ts as unknown as TypeScriptInternals; + +/** + * Asserts that the required internal TypeScript APIs are present in the currently + * loaded TypeScript module. + * + * @throws {AssertionError} If any required internal API is missing. + */ +export function assertTypeScriptPrinterInternals(): void { + assert( + typeof tsInternals.createTextWriter === 'function', + 'TypeScript internal "createTextWriter" is missing.', + ); + assert( + typeof tsInternals.createSourceMapGenerator === 'function', + 'TypeScript internal "createSourceMapGenerator" is missing.', + ); +} + +/** + * Result object returned from printing a source file. + */ +export interface PrintResult { + /** The printed source code text. */ + code: string; + + /** The generated sourcemap JSON string, if sourcemap generation was requested. */ + map?: string; +} + +/** + * Prints a TypeScript source file AST to a string, optionally generating a sourcemap + * using internal TypeScript APIs. + * + * @param sourceFile The TypeScript AST node representing the file to print. + * @param printer The printer instance to print the file with. + * @param compilerHost The TypeScript compiler host, used for path canonicalization and context. + * @param compilerOptions The compiler options configured for the build target. + * @returns A result containing the printed code and optional sourcemap text. + */ +export function printSourceFileWithMap( + sourceFile: ts.SourceFile, + printer: ts.Printer, + compilerHost: ts.CompilerHost, + compilerOptions: ts.CompilerOptions, +): PrintResult { + const shouldGenerateMap = compilerOptions.sourceMap || compilerOptions.inlineSourceMap; + if (!shouldGenerateMap) { + return { code: printer.printFile(sourceFile) }; + } + + assertTypeScriptPrinterInternals(); + + const extendedPrinter = printer as ExtendedPrinter; + assert( + typeof extendedPrinter.writeFile === 'function', + 'TypeScript Printer is missing internal "writeFile" method.', + ); + + const writer = tsInternals.createTextWriter(compilerHost.getNewLine()); + + const sourceMapGenerator = tsInternals.createSourceMapGenerator( + { + getCurrentDirectory: () => compilerHost.getCurrentDirectory(), + getCanonicalFileName: (fileName) => compilerHost.getCanonicalFileName(fileName), + }, + sourceFile.fileName, + compilerOptions.sourceRoot, + dirname(sourceFile.fileName), + compilerOptions, + ); + + extendedPrinter.writeFile(sourceFile, writer, sourceMapGenerator); + + const code = writer.getText(); + const map = sourceMapGenerator.toString(); + + return { code, map }; +} diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts new file mode 100644 index 000000000000..245443781601 --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { AstHost, Range } from '@angular/compiler-cli/linker'; +import { FatalLinkerError } from '@angular/compiler-cli/linker'; +import type { + ArrayExpression, + ArrowFunctionExpression, + BooleanLiteral, + CallExpression, + Function as FunctionNode, + Node, + NullLiteral, + NumericLiteral, + ObjectExpression, + StringLiteral, + UnaryExpression, +} from '@oxc-project/types'; + +function isNode(node: unknown): node is Node { + return typeof node === 'object' && node !== null && 'type' in node; +} + +function unwrapParentheses(node: unknown): unknown { + while (isNode(node) && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + +/** + * An implementation of `AstHost` that queries information from `oxc-parser` AST nodes. + */ +export class OxcAstHost implements AstHost { + getSymbolName(node: unknown): string | null { + node = unwrapParentheses(node); + if (!isNode(node)) { + return null; + } + + if (node.type === 'Identifier') { + return node.name; + } else if (node.type === 'MemberExpression') { + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name; + } + } + + return null; + } + + isStringLiteral(node: unknown): node is StringLiteral { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'Literal' && typeof node.value === 'string'; + } + + parseStringLiteral(str: unknown): string { + str = unwrapParentheses(str); + if (!this.isStringLiteral(str)) { + throw new FatalLinkerError(str as object, 'Unsupported syntax, expected a string literal.'); + } + + return str.value; + } + + isNumericLiteral(node: unknown): node is NumericLiteral { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'Literal' && typeof node.value === 'number'; + } + + parseNumericLiteral(num: unknown): number { + num = unwrapParentheses(num); + if (!this.isNumericLiteral(num)) { + throw new FatalLinkerError(num as object, 'Unsupported syntax, expected a numeric literal.'); + } + + return num.value; + } + + isBooleanLiteral(node: unknown): node is BooleanLiteral | UnaryExpression { + node = unwrapParentheses(node); + if (!isNode(node)) { + return false; + } + + return ( + (node.type === 'Literal' && typeof node.value === 'boolean') || isMinifiedBooleanLiteral(node) + ); + } + + parseBooleanLiteral(bool: unknown): boolean { + bool = unwrapParentheses(bool); + if (isNode(bool)) { + if (bool.type === 'Literal' && typeof bool.value === 'boolean') { + return bool.value; + } + if (isMinifiedBooleanLiteral(bool)) { + return !bool.argument.value; + } + } + + throw new FatalLinkerError(bool as object, 'Unsupported syntax, expected a boolean literal.'); + } + + isNull(node: unknown): node is NullLiteral { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'Literal' && node.value === null; + } + + isArrayLiteral(node: unknown): node is ArrayExpression { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'ArrayExpression'; + } + + parseArrayLiteral(array: unknown): unknown[] { + array = unwrapParentheses(array); + if (!this.isArrayLiteral(array)) { + throw new FatalLinkerError(array as object, 'Unsupported syntax, expected an array literal.'); + } + + const result: unknown[] = []; + + for (const element of array.elements) { + if (element === null) { + throw new FatalLinkerError( + array as object, + 'Unsupported syntax, element in array not to be empty.', + ); + } + const unwrappedElement = unwrapParentheses(element); + if (isNode(unwrappedElement) && unwrappedElement.type === 'SpreadElement') { + throw new FatalLinkerError( + unwrappedElement as object, + 'Unsupported syntax, element in array not to use spread syntax.', + ); + } + result.push(unwrappedElement); + } + + return result; + } + + isObjectLiteral(node: unknown): node is ObjectExpression { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'ObjectExpression'; + } + + parseObjectLiteral(obj: unknown): Map { + obj = unwrapParentheses(obj); + if (!this.isObjectLiteral(obj)) { + throw new FatalLinkerError(obj as object, 'Unsupported syntax, expected an object literal.'); + } + + const result = new Map(); + + for (const property of obj.properties) { + if (property.type !== 'Property') { + throw new FatalLinkerError( + property as object, + 'Unsupported syntax, expected a property assignment.', + ); + } + + const keyNode = unwrapParentheses(property.key); + if (!isNode(keyNode)) { + throw new FatalLinkerError( + property.key as object, + 'Unsupported syntax, expected a property name.', + ); + } + + let key: string; + if (keyNode.type === 'Identifier') { + key = keyNode.name; + } else if (this.isStringLiteral(keyNode)) { + key = keyNode.value; + } else if (this.isNumericLiteral(keyNode)) { + key = String(keyNode.value); + } else { + throw new FatalLinkerError( + keyNode as object, + 'Unsupported syntax, expected a property name.', + ); + } + + result.set(key, unwrapParentheses(property.value)); + } + + return result; + } + + isFunctionExpression(node: unknown): node is FunctionNode | ArrowFunctionExpression { + node = unwrapParentheses(node); + if (!isNode(node)) { + return false; + } + + return ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ); + } + + parseReturnValue(fn: unknown): unknown { + fn = unwrapParentheses(fn); + if (!this.isFunctionExpression(fn)) { + throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); + } + + const body = fn.body; + if (!body || !isNode(body)) { + throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function body.'); + } + + if (body.type !== 'BlockStatement') { + return unwrapParentheses(body); + } + + const statements = body.body; + if (statements.length !== 1) { + throw new FatalLinkerError( + body as object, + 'Unsupported syntax, expected a function body with a single return statement.', + ); + } + + const stmt = statements[0]; + if (stmt.type !== 'ReturnStatement') { + throw new FatalLinkerError( + stmt as object, + 'Unsupported syntax, expected a function body with a single return statement.', + ); + } + + if (!stmt.argument) { + throw new FatalLinkerError( + stmt as object, + 'Unsupported syntax, expected function to return a value.', + ); + } + + return unwrapParentheses(stmt.argument); + } + + parseParameters(fn: unknown): unknown[] { + fn = unwrapParentheses(fn); + if (!this.isFunctionExpression(fn)) { + throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); + } + + return fn.params; + } + + isCallExpression(node: unknown): node is CallExpression { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'CallExpression'; + } + + parseCallee(call: unknown): unknown { + call = unwrapParentheses(call); + if (!this.isCallExpression(call)) { + throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); + } + + return unwrapParentheses(call.callee); + } + + parseArguments(call: unknown): unknown[] { + call = unwrapParentheses(call); + if (!this.isCallExpression(call)) { + throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); + } + + const result: unknown[] = []; + + for (const arg of call.arguments) { + const unwrappedArg = unwrapParentheses(arg); + if (isNode(unwrappedArg) && unwrappedArg.type === 'SpreadElement') { + throw new FatalLinkerError( + unwrappedArg as object, + 'Unsupported syntax, argument not to use spread syntax.', + ); + } + result.push(unwrappedArg); + } + + return result; + } + + getRange(node: unknown): Range { + node = unwrapParentheses(node); + if (!isNode(node) || typeof node.start !== 'number' || typeof node.end !== 'number') { + throw new FatalLinkerError( + node as object, + 'Unable to read range for node - it is missing location information.', + ); + } + + return { + startPos: node.start, + startLine: 0, + startCol: 0, + endPos: node.end, + }; + } +} + +function isMinifiedBooleanLiteral( + node: Node, +): node is UnaryExpression & { argument: NumericLiteral } { + if (node.type !== 'UnaryExpression') { + return false; + } + + const arg = node.argument; + + return ( + node.prefix === true && + node.operator === '!' && + arg.type === 'Literal' && + typeof arg.value === 'number' && + (arg.value === 0 || arg.value === 1) + ); +} diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts new file mode 100644 index 000000000000..cbe0d6a25292 --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { FatalLinkerError } from '@angular/compiler-cli/linker'; +import { parseSync } from 'oxc-parser'; +import { OxcAstHost } from './oxc-ast-host'; + +describe('OxcAstHost', () => { + let host: OxcAstHost; + + beforeEach(() => { + host = new OxcAstHost(); + }); + + function parseExpression(code: string): unknown { + const { program } = parseSync('test.js', `const x = ${code};`, { range: true }); + const stmt = program.body[0] as { declarations: Array<{ init: unknown }> }; + + return stmt.declarations[0].init; + } + + function parseStatement(code: string): unknown { + const { program } = parseSync('test.js', code, { range: true }); + + return program.body[0]; + } + + describe('getSymbolName', () => { + it('should return the name of an identifier', () => { + const expr = parseExpression('foo'); + expect(host.getSymbolName(expr)).toBe('foo'); + expect(host.getSymbolName(parseExpression('(foo)'))).toBe('foo'); + }); + + it('should return the property name of a member expression', () => { + const expr = parseExpression('foo.bar'); + expect(host.getSymbolName(expr)).toBe('bar'); + expect(host.getSymbolName(parseExpression('(foo.bar)'))).toBe('bar'); + }); + + it('should return null for non-identifier or computed member expressions', () => { + expect(host.getSymbolName(parseExpression('foo[0]'))).toBeNull(); + expect(host.getSymbolName(parseExpression('42'))).toBeNull(); + expect(host.getSymbolName(null)).toBeNull(); + }); + }); + + describe('string literals', () => { + it('should recognize and parse valid string literals', () => { + const expr = parseExpression('"hello"'); + expect(host.isStringLiteral(expr)).toBe(true); + expect(host.parseStringLiteral(expr)).toBe('hello'); + + const parenthesized = parseExpression('("hello")'); + expect(host.isStringLiteral(parenthesized)).toBe(true); + expect(host.parseStringLiteral(parenthesized)).toBe('hello'); + }); + + it('should throw when parsing non-string literals', () => { + const expr = parseExpression('123'); + expect(host.isStringLiteral(expr)).toBe(false); + expect(() => host.parseStringLiteral(expr)).toThrowError(FatalLinkerError); + }); + }); + + describe('numeric literals', () => { + it('should recognize and parse valid numeric literals', () => { + const expr = parseExpression('123'); + expect(host.isNumericLiteral(expr)).toBe(true); + expect(host.parseNumericLiteral(expr)).toBe(123); + + const parenthesized = parseExpression('(123)'); + expect(host.isNumericLiteral(parenthesized)).toBe(true); + expect(host.parseNumericLiteral(parenthesized)).toBe(123); + }); + + it('should throw when parsing non-numeric literals', () => { + const expr = parseExpression('"123"'); + expect(host.isNumericLiteral(expr)).toBe(false); + expect(() => host.parseNumericLiteral(expr)).toThrowError(FatalLinkerError); + }); + }); + + describe('boolean literals', () => { + it('should recognize and parse true and false literals', () => { + const trueExpr = parseExpression('true'); + const falseExpr = parseExpression('false'); + expect(host.isBooleanLiteral(trueExpr)).toBe(true); + expect(host.parseBooleanLiteral(trueExpr)).toBe(true); + expect(host.isBooleanLiteral(falseExpr)).toBe(true); + expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedTrue = parseExpression('(true)'); + expect(host.isBooleanLiteral(parenthesizedTrue)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedTrue)).toBe(true); + }); + + it('should recognize and parse minified boolean literals (!0 and !1)', () => { + const trueExpr = parseExpression('!0'); + const falseExpr = parseExpression('!1'); + expect(host.isBooleanLiteral(trueExpr)).toBe(true); + expect(host.parseBooleanLiteral(trueExpr)).toBe(true); + expect(host.isBooleanLiteral(falseExpr)).toBe(true); + expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedMinified = parseExpression('(!0)'); + expect(host.isBooleanLiteral(parenthesizedMinified)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedMinified)).toBe(true); + }); + + it('should return false for invalid boolean expressions', () => { + expect(host.isBooleanLiteral(parseExpression('!2'))).toBe(false); + expect(() => host.parseBooleanLiteral(parseExpression('!2'))).toThrowError(FatalLinkerError); + }); + }); + + describe('array literals', () => { + it('should recognize and parse array literals', () => { + const expr = parseExpression('[1, "a", true]'); + expect(host.isArrayLiteral(expr)).toBe(true); + expect(host.parseArrayLiteral(expr).length).toBe(3); + + const parenthesized = parseExpression('([1, "a", true])'); + expect(host.isArrayLiteral(parenthesized)).toBe(true); + expect(host.parseArrayLiteral(parenthesized).length).toBe(3); + }); + + it('should unwrap parenthesized elements in array literals', () => { + const expr = parseExpression('[(1), ("a")]'); + const elements = host.parseArrayLiteral(expr); + expect(elements.length).toBe(2); + expect(host.isNumericLiteral(elements[0])).toBe(true); + expect(host.isStringLiteral(elements[1])).toBe(true); + }); + + it('should throw when array contains empty elements or spread syntax', () => { + expect(() => host.parseArrayLiteral(parseExpression('[1, , 2]'))).toThrowError( + FatalLinkerError, + ); + expect(() => host.parseArrayLiteral(parseExpression('[1, ...a]'))).toThrowError( + FatalLinkerError, + ); + expect(() => host.parseArrayLiteral(parseExpression('[1, ...(a)]'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('object literals', () => { + it('should recognize and parse object literals into a Map', () => { + const expr = parseExpression('{ a: 1, "b": 2, 3: "c" }'); + expect(host.isObjectLiteral(expr)).toBe(true); + + const map = host.parseObjectLiteral(expr); + expect(map.size).toBe(3); + expect(map.has('a')).toBe(true); + expect(map.has('b')).toBe(true); + expect(map.has('3')).toBe(true); + }); + + it('should recognize and parse parenthesized object literals', () => { + const expr = parseExpression('({ a: (1), b: ("c") })'); + expect(host.isObjectLiteral(expr)).toBe(true); + + const map = host.parseObjectLiteral(expr); + expect(map.size).toBe(2); + expect(host.isNumericLiteral(map.get('a'))).toBe(true); + expect(host.isStringLiteral(map.get('b'))).toBe(true); + }); + + it('should throw when object literal contains spread or non-property assignments', () => { + expect(() => host.parseObjectLiteral(parseExpression('{ ...a }'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('functions', () => { + it('should parse return value and parameters from arrow functions', () => { + const expr = parseExpression('(a, b) => 42'); + expect(host.isFunctionExpression(expr)).toBe(true); + expect(host.parseParameters(expr).length).toBe(2); + + const returnValue = host.parseReturnValue(expr); + expect(host.isNumericLiteral(returnValue)).toBe(true); + }); + + it('should parse parenthesized arrow functions and concise bodies returning parenthesized object literals', () => { + const expr = parseExpression('() => ({ a: 1 })'); + expect(host.isFunctionExpression(expr)).toBe(true); + + const returnValue = host.parseReturnValue(expr); + expect(host.isObjectLiteral(returnValue)).toBe(true); + const map = host.parseObjectLiteral(returnValue); + expect(map.has('a')).toBe(true); + + const wrappedArrow = parseExpression('((a) => (42))'); + expect(host.isFunctionExpression(wrappedArrow)).toBe(true); + expect(host.parseParameters(wrappedArrow).length).toBe(1); + expect(host.isNumericLiteral(host.parseReturnValue(wrappedArrow))).toBe(true); + }); + + it('should parse return value from function with block statement containing single return', () => { + const stmt = parseStatement('function foo(a) { return "hello"; }'); + expect(host.isFunctionExpression(stmt)).toBe(true); + + const returnValue = host.parseReturnValue(stmt); + expect(host.isStringLiteral(returnValue)).toBe(true); + }); + + it('should parse return value from function returning parenthesized expression', () => { + const stmt = parseStatement('function foo() { return ({ a: 1 }); }'); + expect(host.isFunctionExpression(stmt)).toBe(true); + + const returnValue = host.parseReturnValue(stmt); + expect(host.isObjectLiteral(returnValue)).toBe(true); + }); + + it('should throw when function body has multiple statements or no return', () => { + expect(() => + host.parseReturnValue(parseStatement('function foo() { const x = 1; return x; }')), + ).toThrowError(FatalLinkerError); + expect(() => host.parseReturnValue(parseStatement('function foo() {}'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('call expressions', () => { + it('should parse callee and arguments', () => { + const expr = parseExpression('foo(1, "a")'); + expect(host.isCallExpression(expr)).toBe(true); + expect(host.getSymbolName(host.parseCallee(expr))).toBe('foo'); + expect(host.parseArguments(expr).length).toBe(2); + + const parenthesized = parseExpression('((foo)((1), ("a")))'); + expect(host.isCallExpression(parenthesized)).toBe(true); + expect(host.getSymbolName(host.parseCallee(parenthesized))).toBe('foo'); + const args = host.parseArguments(parenthesized); + expect(args.length).toBe(2); + expect(host.isNumericLiteral(args[0])).toBe(true); + expect(host.isStringLiteral(args[1])).toBe(true); + }); + + it('should throw when call expression arguments contain spread syntax', () => { + expect(() => host.parseArguments(parseExpression('foo(...args)'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('getRange', () => { + it('should return range object for valid AST nodes', () => { + const expr = parseExpression('foo'); + const range = host.getRange(expr); + expect(range.startPos).toBeGreaterThanOrEqual(0); + expect(range.endPos).toBeGreaterThan(range.startPos); + }); + + it('should throw when node is missing range offsets', () => { + expect(() => host.getRange({})).toThrowError(FatalLinkerError); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts new file mode 100644 index 000000000000..8870fe1cc7da --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Logger } from '@angular/compiler-cli'; +import { type DeclarationScope, FileLinker, LinkerEnvironment } from '@angular/compiler-cli/linker'; +import type { + AbsoluteFsPath, + ReadonlyFileSystem, +} from '@angular/compiler-cli/src/ngtsc/file_system'; +import type { CallExpression } from '@oxc-project/types'; +import { OxcAstHost } from './oxc-ast-host'; +import { StringAstFactory } from './string-ast-factory'; + +/** + * A declaration scope that instructs the Angular compiler to emit constant pools + * inside a local IIFE around each linked declaration rather than hoisting shared + * constants to the module level. + * + * Preferred due to: + * - In-Place String Replacement: Enables fast in-place string replacements in + * `MagicString` without parsing or mutating surrounding ES module statements. + * - Better Tree-Shaking Locality: Component constants are strictly encapsulated + * within the component's `@__PURE__` IIFE closure (`(function() { ... })()`). If a + * bundler tree-shakes an unused component from a library FESM, all of its associated + * constants are automatically eliminated without leaving orphan top-level variables. + * - Negligible Wire Size Impact: LZ77/Brotli compression deduplicates repeated IIFE + * wrappers and array literals over the wire to near-zero marginal cost. + */ +class InlineDeclarationScope implements DeclarationScope { + getConstantScopeRef(): null { + return null; + } +} + +const noopFileSystem: ReadonlyFileSystem = { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, +} as unknown as ReadonlyFileSystem; + +const defaultLogger: Logger = { + level: 1, // LogLevel.info + debug(...args: string[]) { + if (this.level <= 0) { + // eslint-disable-next-line no-console + console.debug(...args); + } + }, + info(...args: string[]) { + if (this.level <= 1) { + // eslint-disable-next-line no-console + console.info(...args); + } + }, + warn(...args: string[]) { + if (this.level <= 2) { + // eslint-disable-next-line no-console + console.warn(...args); + } + }, + error(...args: string[]) { + if (this.level <= 3) { + // eslint-disable-next-line no-console + console.error(...args); + } + }, +}; + +let SHARED_AST_HOST: OxcAstHost; +let SHARED_DECLARATION_SCOPE: InlineDeclarationScope; + +/** + * Manages Angular partial declaration linking using Oxc AST nodes. + */ +export class OxcLinker { + readonly #fileLinker: FileLinker; + + constructor(filename: string, code: string, jit = false) { + SHARED_AST_HOST ??= new OxcAstHost(); + SHARED_DECLARATION_SCOPE ??= new InlineDeclarationScope(); + + const astFactory = new StringAstFactory(code); + const linkerEnvironment = LinkerEnvironment.create( + noopFileSystem, + defaultLogger, + SHARED_AST_HOST, + astFactory, + { linkerJitMode: jit, sourceMapping: false }, + ); + + this.#fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code); + } + + /** + * Attempts to link an Angular partial declaration CallExpression. + * + * @param node The CallExpression AST node to check and link. + * @returns The linked code string if the node is a partial declaration, or undefined otherwise. + */ + linkCallExpression(node: CallExpression): string | undefined { + const calleeName = SHARED_AST_HOST.getSymbolName(node.callee); + if (!calleeName || !this.#fileLinker.isPartialDeclaration(calleeName)) { + return undefined; + } + + const args = SHARED_AST_HOST.parseArguments(node); + const linkedCode = this.#fileLinker.linkPartialDeclaration( + calleeName, + args, + SHARED_DECLARATION_SCOPE, + ); + + return linkedCode as string; + } +} diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts new file mode 100644 index 000000000000..467658eb1991 --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { transform } from '../../oxc/oxc-transform'; + +describe('oxc-linker', () => { + it('should not modify code that does not need linking', () => { + const input = 'const x = 1;'; + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toBe(input); + expect(result.map).toBeUndefined(); + }); + + it('should link a partial directive declaration', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('i0.ɵɵdefineDirective'); + expect(result.code).not.toContain('i0.ɵɵngDeclareDirective'); + }); + + it('should link a partial component declaration', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = transform('test.js', input, { + link: true, + advancedOptimizations: false, + sourcemap: true, + }); + expect(result.map).toBeDefined(); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + }); + + it('should link ɵɵngDeclareClassMetadataAsync with parenthesized resolveMetadata return', () => { + const input = ` + import * as i0 from "@angular/core"; + export class DeferredFixture {} + i0.ɵɵngDeclareClassMetadataAsync({ + minVersion: "18.0.0", + version: "22.1.7", + ngImport: i0, + type: DeferredFixture, + resolveDeferredDeps: () => [], + resolveMetadata: () => ({ + decorators: [], + ctorParameters: null, + propDecorators: null, + }), + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('ɵsetClassMetadataAsync'); + expect(result.code).not.toContain('ɵɵngDeclareClassMetadataAsync'); + }); +}); diff --git a/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts b/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts new file mode 100644 index 000000000000..a703856c39bd --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts @@ -0,0 +1,335 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { + AstFactory, + BinaryOperator, + LeadingComment, + ObjectLiteralProperty, + SourceMapRange, + TemplateLiteral, + UnaryOperator, + VariableDeclarationType, +} from '@angular/compiler-cli/src/ngtsc/translator'; +import type { + BuiltInType, + Parameter, +} from '@angular/compiler-cli/src/ngtsc/translator/src/api/ast_factory'; + +const OBJECT_LITERAL_PREFIX = '\0obj_'; + +/** + * An implementation of `AstFactory` that generates JavaScript code strings directly. + */ +export class StringAstFactory implements AstFactory { + constructor(private readonly sourceCode: string = '') {} + + private render(expr: unknown): string { + let rendered: string; + if (typeof expr === 'string') { + rendered = expr; + } else if ( + typeof expr === 'object' && + expr !== null && + typeof (expr as { start?: number; end?: number }).start === 'number' && + typeof (expr as { end?: number }).end === 'number' + ) { + const { start, end } = expr as { start: number; end: number }; + + rendered = this.sourceCode.slice(start, end); + } else { + rendered = String(expr); + } + + if (rendered.startsWith(OBJECT_LITERAL_PREFIX)) { + return rendered.slice(OBJECT_LITERAL_PREFIX.length); + } + + return rendered; + } + + /** + * Wraps an expression in parentheses if it has syntax or operator precedence + * traps when used as the receiver/callee of property access, element access, + * call chains, `new` expressions, or tagged templates. + * + * For example, arrow functions (`(() => {}).foo`), numbers (`(5).foo`), object + * literals (`({ a: 1 }).foo`), and unary operators (`(void 0)()`) require + * parenthesization when used as receivers. + */ + private wrapReceiver(receiver: unknown): string { + let rendered = this.render(receiver); + if ( + rendered.startsWith('function') || + rendered.startsWith('{') || + rendered.includes('=>') || + /^(?:typeof|void|delete|new|await|throw|return)\b/.test(rendered) || + /^[!~+-]/.test(rendered) || + /^\d/.test(rendered) + ) { + rendered = `(${rendered})`; + } + + return rendered; + } + + /** + * Wraps an expression in parentheses if it is an un-parenthesized complex + * operand (such as an un-parenthesized binary, logical, or assignment expression) + * to prevent operator precedence inversion when embedded inside an outer expression. + * + * Atomic expressions (identifiers, member/element access chains, and simple + * literals matching `/^[a-zA-Z0-9_$.[\]"'`]+$/`) and already-parenthesized + * expressions are returned as-is. + */ + private wrapOperand(expr: unknown): string { + const rendered = this.render(expr); + if ( + (rendered.startsWith('(') && rendered.endsWith(')')) || + /^[a-zA-Z0-9_$.[\]"'`]+$/.test(rendered) + ) { + return rendered; + } + + return `(${rendered})`; + } + + /** + * Attaching statement-level comments is a no-op in `StringAstFactory`. + * + * Why this is safe: + * - Load-bearing tree-shaking annotations (`@__PURE__` markers) are explicitly prepended by + * `createCallExpression` and `createCallChain` whenever `pure: true`. + * - In `@angular/compiler`, `LeadingComment` is only used for `@ts-ignore` (suppressing + * TypeScript checker errors in generated `.ts` code) and `webpackChunkName` (in dynamic + * imports), neither of which occur during `.js`/`.mjs` library partial linking. + */ + attachComments(_statement: string, _leadingComments: LeadingComment[]): void {} + + createArrayLiteral(elements: unknown[]): string { + return `[${elements.map((e) => this.render(e)).join(', ')}]`; + } + + createAssignment(target: unknown, operator: BinaryOperator, value: unknown): string { + return `(${this.render(target)} ${operator} ${this.render(value)})`; + } + + createBinaryExpression( + leftOperand: unknown, + operator: BinaryOperator, + rightOperand: unknown, + ): string { + return `(${this.wrapOperand(leftOperand)} ${operator} ${this.wrapOperand(rightOperand)})`; + } + + createBlock(body: string[]): string { + return `{\n${body.join('\n')}\n}`; + } + + createCallExpression(callee: unknown, args: unknown[], pure: boolean): string { + const annotation = pure ? '/*@__PURE__*/ ' : ''; + + return `${annotation}${this.wrapReceiver(callee)}(${args.map((a) => this.render(a)).join(', ')})`; + } + + createCallChain(callee: unknown, args: unknown[], pure: boolean, isOptional: boolean): string { + const annotation = pure ? '/*@__PURE__*/ ' : ''; + const operator = isOptional ? '?.' : ''; + + return `${annotation}${this.wrapReceiver(callee)}${operator}(${args.map((a) => this.render(a)).join(', ')})`; + } + + createConditional(condition: unknown, thenExpression: unknown, elseExpression: unknown): string { + return `(${this.render(condition)} ? ${this.render(thenExpression)} : ${this.render(elseExpression)})`; + } + + createElementAccess(expression: unknown, element: unknown): string { + return `${this.wrapReceiver(expression)}[${this.render(element)}]`; + } + + createElementAccessChain(expression: unknown, element: unknown, isOptional: boolean): string { + const operator = isOptional ? '?.' : ''; + + return `${this.wrapReceiver(expression)}${operator}[${this.render(element)}]`; + } + + createExpressionStatement(expression: unknown): string { + return `${this.render(expression)};`; + } + + createFunctionDeclaration( + functionName: string, + parameters: Parameter[], + body: string, + ): string { + const params = parameters.map((p) => p.name).join(', '); + + return `function ${functionName}(${params}) ${body}`; + } + + createFunctionExpression( + functionName: string | null, + parameters: Parameter[], + body: string, + ): string { + const name = functionName ? ` ${functionName}` : ''; + const params = parameters.map((p) => p.name).join(', '); + + return `function${name}(${params}) ${body}`; + } + + createArrowFunctionExpression(parameters: Parameter[], body: unknown): string { + const params = parameters.map((p) => p.name).join(', '); + const isObjectLiteral = typeof body === 'string' && body.startsWith(OBJECT_LITERAL_PREFIX); + const renderedBody = this.render(body); + const formattedBody = isObjectLiteral ? `(${renderedBody})` : renderedBody; + + return `(${params}) => ${formattedBody}`; + } + + createDynamicImport(url: unknown): string { + return `import(${this.render(url)})`; + } + + createIdentifier(name: string): string { + return name; + } + + createIfStatement( + condition: unknown, + thenStatement: string, + elseStatement: string | null, + ): string { + const elseClause = elseStatement ? ` else ${elseStatement}` : ''; + + return `if (${this.render(condition)}) ${thenStatement}${elseClause}`; + } + + createLiteral(value: string | number | boolean | null | undefined): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); + } + + createNewExpression(expression: unknown, args: unknown[]): string { + return `new ${this.wrapReceiver(expression)}(${args.map((a) => this.render(a)).join(', ')})`; + } + + createObjectLiteral(properties: ObjectLiteralProperty[]): string { + const props = properties.map((p) => { + if (p.kind === 'spread') { + return `...${this.render(p.expression)}`; + } + + const key = p.quoted ? JSON.stringify(p.propertyName) : p.propertyName; + + return `${key}: ${this.render(p.value)}`; + }); + + return `${OBJECT_LITERAL_PREFIX}{\n${props.join(',\n')}\n}`; + } + + createParenthesizedExpression(expression: unknown): string { + return `(${this.render(expression)})`; + } + + createPropertyAccess(expression: unknown, propertyName: string): string { + return `${this.wrapReceiver(expression)}.${propertyName}`; + } + + createPropertyAccessChain( + expression: unknown, + propertyName: string, + isOptional: boolean, + ): string { + const operator = isOptional ? '?.' : '.'; + + return `${this.wrapReceiver(expression)}${operator}${propertyName}`; + } + + createReturnStatement(expression: unknown | null): string { + return `return${expression !== null ? ` ${this.render(expression)}` : ''};`; + } + + createTaggedTemplate(tag: unknown, template: TemplateLiteral): string { + return `${this.wrapReceiver(tag)}${this.createTemplateLiteral(template)}`; + } + + createTemplateLiteral(template: TemplateLiteral): string { + let result = '`'; + for (let i = 0; i < template.elements.length; i++) { + result += template.elements[i].raw; + if (i < template.expressions.length) { + result += `\${${this.render(template.expressions[i])}}`; + } + } + result += '`'; + + return result; + } + + createThrowStatement(expression: unknown): string { + return `throw ${this.render(expression)};`; + } + + createTypeOfExpression(expression: unknown): string { + return `typeof ${this.wrapOperand(expression)}`; + } + + createVoidExpression(expression: unknown): string { + return `void ${this.wrapOperand(expression)}`; + } + + createUnaryExpression(operator: UnaryOperator, operand: unknown): string { + return `${operator}${this.wrapOperand(operand)}`; + } + + createVariableDeclaration( + variableName: string, + initializer: unknown | null, + variableType: VariableDeclarationType, + _type: string | null = null, + ): string { + const init = initializer !== null ? ` = ${this.render(initializer)}` : ''; + + return `${variableType} ${variableName}${init};`; + } + + createRegularExpressionLiteral(body: string, flags: string | null): string { + return `/${body}/${flags ?? ''}`; + } + + createSpreadElement(expression: unknown): string { + return `...${this.render(expression)}`; + } + + createBuiltInType(_type: BuiltInType): string { + return ''; + } + + createExpressionType(_expression: unknown, _typeParams: string[] | null): string { + return ''; + } + + createArrayType(_elementType: string): string { + return ''; + } + + createMapType(_valueType: string): string { + return ''; + } + + transplantType(_type: string): string { + return ''; + } + + setSourceMapRange( + node: T, + _sourceMapRange: SourceMapRange | null, + ): T { + return node; + } +} diff --git a/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts b/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts new file mode 100644 index 000000000000..eb62a358dfcd --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { StringAstFactory } from './string-ast-factory'; + +describe('StringAstFactory', () => { + let factory: StringAstFactory; + + beforeEach(() => { + factory = new StringAstFactory('const foo = bar;'); + }); + + describe('source slicing (render)', () => { + it('should slice original source code when given an AST object with start and end offsets', () => { + const astNode = { start: 6, end: 9 }; + expect(factory.createExpressionStatement(astNode)).toBe('foo;'); + }); + }); + + describe('literals and identifiers', () => { + it('should correctly format string literals', () => { + expect(factory.createLiteral('hello')).toBe('"hello"'); + }); + + it('should correctly format numeric literals including NaN and Infinity', () => { + expect(factory.createLiteral(123)).toBe('123'); + expect(factory.createLiteral(NaN)).toBe('NaN'); + expect(factory.createLiteral(Infinity)).toBe('Infinity'); + }); + + it('should correctly format boolean literals', () => { + expect(factory.createLiteral(true)).toBe('true'); + expect(factory.createLiteral(false)).toBe('false'); + }); + + it('should correctly format null and undefined literals', () => { + expect(factory.createLiteral(null)).toBe('null'); + expect(factory.createLiteral(undefined)).toBe('undefined'); + }); + + it('should correctly create identifiers', () => { + expect(factory.createIdentifier('myVar')).toBe('myVar'); + }); + + it('should correctly format regular expression literals', () => { + expect(factory.createRegularExpressionLiteral('abc+', 'g')).toBe('/abc+/g'); + expect(factory.createRegularExpressionLiteral('abc+', null)).toBe('/abc+/'); + }); + }); + + describe('array and object literals', () => { + it('should correctly format array literals', () => { + expect(factory.createArrayLiteral(['1', '2', '3'])).toBe('[1, 2, 3]'); + }); + + it('should correctly format object literals with quoted and unquoted keys', () => { + const props = [ + { kind: 'property' as const, propertyName: 'foo', value: '1', quoted: false }, + { kind: 'property' as const, propertyName: 'foo-bar', value: '2', quoted: true }, + ]; + expect(factory.createObjectLiteral(props)).toContain('{\nfoo: 1,\n"foo-bar": 2\n}'); + }); + + it('should correctly format object literals with spread properties', () => { + const props = [ + { kind: 'property' as const, propertyName: 'foo', value: '1', quoted: false }, + { kind: 'spread' as const, expression: 'bar' }, + ]; + expect(factory.createObjectLiteral(props)).toContain('{\nfoo: 1,\n...bar\n}'); + }); + }); + + describe('property and element access', () => { + it('should correctly format property access and property access chains', () => { + expect(factory.createPropertyAccess('foo', 'bar')).toBe('foo.bar'); + expect(factory.createPropertyAccessChain('foo', 'bar', true)).toBe('foo?.bar'); + expect(factory.createPropertyAccessChain('foo', 'bar', false)).toBe('foo.bar'); + }); + + it('should correctly format element access and element access chains', () => { + expect(factory.createElementAccess('foo', '"bar"')).toBe('foo["bar"]'); + expect(factory.createElementAccessChain('foo', '"bar"', true)).toBe('foo?.["bar"]'); + expect(factory.createElementAccessChain('foo', '"bar"', false)).toBe('foo["bar"]'); + }); + + it('should parenthesize precedence-sensitive receivers in property and element access', () => { + expect(factory.createPropertyAccess('() => {}', 'bar')).toBe('(() => {}).bar'); + expect(factory.createPropertyAccess('5', 'bar')).toBe('(5).bar'); + expect(factory.createPropertyAccess('typeof x', 'bar')).toBe('(typeof x).bar'); + expect(factory.createElementAccess('() => {}', '"bar"')).toBe('(() => {})["bar"]'); + }); + }); + + describe('call expressions and chains', () => { + it('should format standard and pure call expressions', () => { + expect(factory.createCallExpression('foo', ['1', '2'], false)).toBe('foo(1, 2)'); + expect(factory.createCallExpression('foo', ['1', '2'], true)).toBe('/*@__PURE__*/ foo(1, 2)'); + }); + + it('should wrap arrow functions and function expressions in parentheses when used as callees', () => { + expect(factory.createCallExpression('() => {}', [], false)).toBe('(() => {})()'); + expect(factory.createCallExpression('function() {}', [], false)).toBe('(function() {})()'); + expect(factory.createCallChain('() => {}', [], false, true)).toBe('(() => {})?.()'); + expect(factory.createNewExpression('() => Foo', [])).toBe('new (() => Foo)()'); + }); + + it('should format optional call chains', () => { + expect(factory.createCallChain('foo', ['1'], false, true)).toBe('foo?.(1)'); + expect(factory.createCallChain('foo', ['1'], false, false)).toBe('foo(1)'); + }); + + it('should format new expressions', () => { + expect(factory.createNewExpression('Foo', ['1', '2'])).toBe('new Foo(1, 2)'); + }); + }); + + describe('operators and expressions', () => { + it('should format binary and assignment expressions with parentheses', () => { + expect(factory.createBinaryExpression('a', '+', 'b')).toBe('(a + b)'); + expect(factory.createBinaryExpression('a + b', '*', 'c + d')).toBe('((a + b) * (c + d))'); + expect(factory.createAssignment('a', '=', 'b')).toBe('(a = b)'); + }); + + it('should format conditional expressions with parentheses', () => { + expect(factory.createConditional('cond', 'trueVal', 'falseVal')).toBe( + '(cond ? trueVal : falseVal)', + ); + }); + + it('should format unary expressions', () => { + expect(factory.createUnaryExpression('!', 'foo')).toBe('!foo'); + expect(factory.createTypeOfExpression('foo')).toBe('typeof foo'); + expect(factory.createVoidExpression('foo')).toBe('void foo'); + expect(factory.createUnaryExpression('!', 'a + b')).toBe('!(a + b)'); + }); + }); + + describe('functions and statements', () => { + it('should format function declarations and expressions', () => { + const params = [ + { name: 'a', type: null }, + { name: 'b', type: null }, + ]; + expect(factory.createFunctionDeclaration('foo', params, '{ return a + b; }')).toBe( + 'function foo(a, b) { return a + b; }', + ); + expect(factory.createFunctionExpression('foo', params, '{ return a + b; }')).toBe( + 'function foo(a, b) { return a + b; }', + ); + expect(factory.createFunctionExpression(null, params, '{ return a + b; }')).toBe( + 'function(a, b) { return a + b; }', + ); + }); + + it('should format arrow functions', () => { + const params = [ + { name: 'a', type: null }, + { name: 'b', type: null }, + ]; + expect(factory.createArrowFunctionExpression(params, 'a + b')).toBe('(a, b) => a + b'); + }); + + it('should parenthesize object literal expression bodies in arrow functions even when containing semicolons', () => { + const params = [{ name: 'a', type: null }]; + const obj = factory.createObjectLiteral([ + { + kind: 'property', + propertyName: 'factory', + value: '() => { return new Service(); }', + quoted: false, + }, + ]); + expect(factory.createArrowFunctionExpression(params, obj)).toBe( + '(a) => ({\nfactory: () => { return new Service(); }\n})', + ); + }); + + it('should format statements', () => { + expect(factory.createBlock(['foo();', 'bar();'])).toBe('{\nfoo();\nbar();\n}'); + expect(factory.createIfStatement('cond', 'foo();', 'bar();')).toBe( + 'if (cond) foo(); else bar();', + ); + expect(factory.createIfStatement('cond', 'foo();', null)).toBe('if (cond) foo();'); + expect(factory.createReturnStatement('val')).toBe('return val;'); + expect(factory.createReturnStatement(null)).toBe('return;'); + expect(factory.createThrowStatement('err')).toBe('throw err;'); + expect(factory.createVariableDeclaration('x', '1', 'const')).toBe('const x = 1;'); + expect(factory.createVariableDeclaration('y', null, 'let')).toBe('let y;'); + }); + }); + + describe('template literals', () => { + it('should format template literals and tagged templates', () => { + const template = { + elements: [ + { raw: 'Hello ', cooked: 'Hello ', range: null }, + { raw: '!', cooked: '!', range: null }, + ], + expressions: ['name'], + }; + expect(factory.createTemplateLiteral(template)).toBe('`Hello ${name}!`'); + expect(factory.createTaggedTemplate('tag', template)).toBe('tag`Hello ${name}!`'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts new file mode 100644 index 000000000000..d1d0a76df85d --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts @@ -0,0 +1,241 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import ts from 'typescript'; + +/** + * The name of the Angular platform that should be replaced within + * bootstrap call expressions to support AOT. + */ +const PLATFORM_BROWSER_DYNAMIC_NAME = 'platformBrowserDynamic'; + +export function replaceBootstrap( + getTypeChecker: () => ts.TypeChecker, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + let bootstrapImport: ts.ImportDeclaration | undefined; + let bootstrapNamespace: ts.Identifier | undefined; + const replacedNodes: ts.Node[] = []; + const nodeFactory = context.factory; + + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const target = node.expression; + if (target.text === PLATFORM_BROWSER_DYNAMIC_NAME) { + if (!bootstrapNamespace) { + bootstrapNamespace = nodeFactory.createUniqueName('__NgCli_bootstrap_'); + bootstrapImport = nodeFactory.createImportDeclaration( + undefined, + nodeFactory.createImportClause( + undefined, + undefined, + nodeFactory.createNamespaceImport(bootstrapNamespace), + ), + nodeFactory.createStringLiteral('@angular/platform-browser'), + ); + } + replacedNodes.push(target); + + return nodeFactory.updateCallExpression( + node, + nodeFactory.createPropertyAccessExpression(bootstrapNamespace, 'platformBrowser'), + node.typeArguments, + node.arguments, + ); + } + } + + return ts.visitEachChild(node, visitNode, context); + }; + + return (sourceFile: ts.SourceFile) => { + if (!sourceFile.text.includes(PLATFORM_BROWSER_DYNAMIC_NAME)) { + return sourceFile; + } + + let updatedSourceFile = ts.visitEachChild(sourceFile, visitNode, context); + + if (bootstrapImport) { + // Remove any unused platform browser dynamic imports + const removals = elideImports( + updatedSourceFile, + replacedNodes, + getTypeChecker, + context.getCompilerOptions(), + ); + if (removals.size > 0) { + updatedSourceFile = ts.visitEachChild( + updatedSourceFile, + (node) => (removals.has(node) ? undefined : node), + context, + ); + } + + // Add new platform browser import + return nodeFactory.updateSourceFile( + updatedSourceFile, + ts.setTextRange( + nodeFactory.createNodeArray([bootstrapImport, ...updatedSourceFile.statements]), + sourceFile.statements, + ), + ); + } else { + return updatedSourceFile; + } + }; + }; +} + +// Remove imports for which all identifiers have been removed. +// Needs type checker, and works even if it's not the first transformer. +// Works by removing imports for symbols whose identifiers have all been removed. +// Doesn't use the `symbol.declarations` because that previous transforms might have removed nodes +// but the type checker doesn't know. +// See https://github.com/Microsoft/TypeScript/issues/17552 for more information. +export function elideImports( + sourceFile: ts.SourceFile, + removedNodes: ts.Node[], + getTypeChecker: () => ts.TypeChecker, + compilerOptions: ts.CompilerOptions, +): Set { + const importNodeRemovals = new Set(); + + if (removedNodes.length === 0) { + return importNodeRemovals; + } + + const typeChecker = getTypeChecker(); + + // Collect all imports and used identifiers + const usedSymbols = new Set(); + const imports: ts.ImportDeclaration[] = []; + + ts.forEachChild(sourceFile, function visit(node) { + // Skip removed nodes. + if (removedNodes.includes(node)) { + return; + } + + // Consider types for 'implements' as unused. + // A HeritageClause token can also be an 'AbstractKeyword' + // which in that case we should not elide the import. + if (ts.isHeritageClause(node) && node.token === ts.SyntaxKind.ImplementsKeyword) { + return; + } + + // Record import and skip + if (ts.isImportDeclaration(node)) { + if (!node.importClause?.isTypeOnly) { + imports.push(node); + } + + return; + } + + // Type reference imports do not need to be emitted when emitDecoratorMetadata is disabled. + if (ts.isTypeReferenceNode(node) && !compilerOptions.emitDecoratorMetadata) { + return; + } + + let symbol: ts.Symbol | undefined; + switch (node.kind) { + case ts.SyntaxKind.Identifier: + if (node.parent && ts.isShorthandPropertyAssignment(node.parent)) { + const shorthandSymbol = typeChecker.getShorthandAssignmentValueSymbol(node.parent); + if (shorthandSymbol) { + symbol = shorthandSymbol; + } + } else { + symbol = typeChecker.getSymbolAtLocation(node); + } + break; + case ts.SyntaxKind.ExportSpecifier: + symbol = typeChecker.getExportSpecifierLocalTargetSymbol(node as ts.ExportSpecifier); + break; + case ts.SyntaxKind.ShorthandPropertyAssignment: + symbol = typeChecker.getShorthandAssignmentValueSymbol(node); + break; + } + + if (symbol) { + usedSymbols.add(symbol); + } + + ts.forEachChild(node, visit); + }); + + if (imports.length === 0) { + return importNodeRemovals; + } + + const isUnused = (node: ts.Identifier) => { + // Do not remove JSX factory imports + if (node.text === compilerOptions.jsxFactory) { + return false; + } + + const symbol = typeChecker.getSymbolAtLocation(node); + + return symbol && !usedSymbols.has(symbol); + }; + + for (const node of imports) { + if (!node.importClause) { + // "import 'abc';" + continue; + } + + const namedBindings = node.importClause.namedBindings; + + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + // "import * as XYZ from 'abc';" + if (isUnused(namedBindings.name)) { + importNodeRemovals.add(node); + } + } else { + const specifierNodeRemovals = []; + let clausesCount = 0; + + // "import { XYZ, ... } from 'abc';" + if (namedBindings && ts.isNamedImports(namedBindings)) { + let removedClausesCount = 0; + clausesCount += namedBindings.elements.length; + + for (const specifier of namedBindings.elements) { + if (specifier.isTypeOnly || isUnused(specifier.name)) { + removedClausesCount++; + // in case we don't have any more namedImports we should remove the parent ie the {} + const nodeToRemove = + clausesCount === removedClausesCount ? specifier.parent : specifier; + + specifierNodeRemovals.push(nodeToRemove); + } + } + } + + // "import XYZ from 'abc';" + if (node.importClause.name) { + clausesCount++; + + if (node.importClause.isTypeOnly || isUnused(node.importClause.name)) { + specifierNodeRemovals.push(node.importClause.name); + } + } + + if (specifierNodeRemovals.length === clausesCount) { + importNodeRemovals.add(node); + } else { + for (const specifierNodeRemoval of specifierNodeRemovals) { + importNodeRemovals.add(specifierNodeRemoval); + } + } + } + } + + return importNodeRemovals; +} diff --git a/packages/angular/build/src/tools/angular/transformers/jit-resource-transformer.ts b/packages/angular/build/src/tools/angular/transformers/jit-resource-transformer.ts new file mode 100644 index 000000000000..2a064ba63cb5 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/jit-resource-transformer.ts @@ -0,0 +1,308 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import ts from 'typescript'; +import { generateJitFileUri, generateJitInlineUri } from '../uri'; + +/** + * Creates a TypeScript Transformer to transform Angular Component resource references into + * static import statements. This transformer is used in Angular's JIT compilation mode to + * support processing of component resources. When in AOT mode, the Angular AOT compiler handles + * this processing and this transformer is not used. + * @param getTypeChecker A function that returns a TypeScript TypeChecker instance for the program. + * @returns A TypeScript transformer factory. + */ +export function createJitResourceTransformer( + getTypeChecker: () => ts.TypeChecker, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + const typeChecker = getTypeChecker(); + const nodeFactory = context.factory; + const resourceImportDeclarations: ts.ImportDeclaration[] = []; + + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isClassDeclaration(node)) { + const decorators = ts.getDecorators(node); + + if (!decorators || decorators.length === 0) { + return node; + } + + return nodeFactory.updateClassDeclaration( + node, + [ + ...decorators.map((current) => + visitDecorator(nodeFactory, current, typeChecker, resourceImportDeclarations), + ), + ...(ts.getModifiers(node) ?? []), + ], + node.name, + node.typeParameters, + node.heritageClauses, + node.members, + ); + } + + return ts.visitEachChild(node, visitNode, context); + }; + + return (sourceFile) => { + const updatedSourceFile = ts.visitEachChild(sourceFile, visitNode, context); + + if (resourceImportDeclarations.length > 0) { + return nodeFactory.updateSourceFile( + updatedSourceFile, + ts.setTextRange( + nodeFactory.createNodeArray( + [...resourceImportDeclarations, ...updatedSourceFile.statements], + updatedSourceFile.statements.hasTrailingComma, + ), + updatedSourceFile.statements, + ), + updatedSourceFile.isDeclarationFile, + updatedSourceFile.referencedFiles, + updatedSourceFile.typeReferenceDirectives, + updatedSourceFile.hasNoDefaultLib, + updatedSourceFile.libReferenceDirectives, + ); + } else { + return updatedSourceFile; + } + }; + }; +} + +function visitDecorator( + nodeFactory: ts.NodeFactory, + node: ts.Decorator, + typeChecker: ts.TypeChecker, + resourceImportDeclarations: ts.ImportDeclaration[], +): ts.Decorator { + const origin = getDecoratorOrigin(node, typeChecker); + if (!origin || origin.module !== '@angular/core' || origin.name !== 'Component') { + return node; + } + + if (!ts.isCallExpression(node.expression)) { + return node; + } + + const decoratorFactory = node.expression; + const args = decoratorFactory.arguments; + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + // Unsupported component metadata + return node; + } + + const objectExpression = args[0]; + const styleReplacements: ts.Expression[] = []; + + // visit all properties + let properties = ts.visitNodes(objectExpression.properties, (node) => + ts.isObjectLiteralElementLike(node) + ? visitComponentMetadata(nodeFactory, node, styleReplacements, resourceImportDeclarations) + : node, + ) as ts.NodeArray; + + // replace properties with updated properties + if (styleReplacements.length > 0) { + const styleProperty = nodeFactory.createPropertyAssignment( + nodeFactory.createIdentifier('styles'), + nodeFactory.createArrayLiteralExpression(styleReplacements), + ); + + properties = nodeFactory.createNodeArray([...properties, styleProperty]); + } + + return nodeFactory.updateDecorator( + node, + nodeFactory.updateCallExpression( + decoratorFactory, + decoratorFactory.expression, + decoratorFactory.typeArguments, + [nodeFactory.updateObjectLiteralExpression(objectExpression, properties)], + ), + ); +} + +function visitComponentMetadata( + nodeFactory: ts.NodeFactory, + node: ts.ObjectLiteralElementLike, + styleReplacements: ts.Expression[], + resourceImportDeclarations: ts.ImportDeclaration[], +): ts.ObjectLiteralElementLike | undefined { + if (!ts.isPropertyAssignment(node) || ts.isComputedPropertyName(node.name)) { + return node; + } + + switch (node.name.text) { + case 'templateUrl': + // Only analyze string literals + if (!ts.isStringLiteralLike(node.initializer)) { + return node; + } + + return node.initializer.text.length === 0 + ? node + : nodeFactory.updatePropertyAssignment( + node, + nodeFactory.createIdentifier('template'), + createResourceImport( + nodeFactory, + generateJitFileUri(node.initializer.text, 'template'), + resourceImportDeclarations, + ), + ); + case 'styles': + if (ts.isStringLiteralLike(node.initializer)) { + styleReplacements.unshift( + createResourceImport( + nodeFactory, + generateJitInlineUri(node.initializer.text, 'style'), + resourceImportDeclarations, + ), + ); + + return undefined; + } + + if (ts.isArrayLiteralExpression(node.initializer)) { + const inlineStyles = ts.visitNodes(node.initializer.elements, (node) => { + if (!ts.isStringLiteralLike(node)) { + return node; + } + + return node.text.length === 0 + ? undefined // An empty inline style is equivalent to not having a style element + : createResourceImport( + nodeFactory, + generateJitInlineUri(node.text, 'style'), + resourceImportDeclarations, + ); + }) as ts.NodeArray; + + // Inline styles should be placed first + styleReplacements.unshift(...inlineStyles); + + // The inline styles will be added afterwards in combination with any external styles + return undefined; + } + + return node; + + case 'styleUrl': + if (ts.isStringLiteralLike(node.initializer)) { + styleReplacements.push( + createResourceImport( + nodeFactory, + generateJitFileUri(node.initializer.text, 'style'), + resourceImportDeclarations, + ), + ); + + return undefined; + } + + return node; + + case 'styleUrls': { + if (!ts.isArrayLiteralExpression(node.initializer)) { + return node; + } + + const externalStyles = ts.visitNodes(node.initializer.elements, (node) => { + if (!ts.isStringLiteralLike(node)) { + return node; + } + + return node.text + ? createResourceImport( + nodeFactory, + generateJitFileUri(node.text, 'style'), + resourceImportDeclarations, + ) + : undefined; + }) as ts.NodeArray; + + // External styles are applied after any inline styles + styleReplacements.push(...externalStyles); + + // The external styles will be added afterwards in combination with any inline styles + return undefined; + } + default: + // All other elements are passed through + return node; + } +} + +function createResourceImport( + nodeFactory: ts.NodeFactory, + url: string, + resourceImportDeclarations: ts.ImportDeclaration[], +): ts.Identifier { + const urlLiteral = nodeFactory.createStringLiteral(url); + + const importName = nodeFactory.createIdentifier( + `__NG_CLI_RESOURCE__${resourceImportDeclarations.length}`, + ); + resourceImportDeclarations.push( + nodeFactory.createImportDeclaration( + undefined, + nodeFactory.createImportClause(undefined, importName, undefined), + urlLiteral, + ), + ); + + return importName; +} + +function getDecoratorOrigin( + decorator: ts.Decorator, + typeChecker: ts.TypeChecker, +): { name: string; module: string } | null { + if (!ts.isCallExpression(decorator.expression)) { + return null; + } + + let identifier: ts.Node; + let name = ''; + + if (ts.isPropertyAccessExpression(decorator.expression.expression)) { + identifier = decorator.expression.expression.expression; + name = decorator.expression.expression.name.text; + } else if (ts.isIdentifier(decorator.expression.expression)) { + identifier = decorator.expression.expression; + } else { + return null; + } + + // NOTE: resolver.getReferencedImportDeclaration would work as well but is internal + const symbol = typeChecker.getSymbolAtLocation(identifier); + if (symbol && symbol.declarations && symbol.declarations.length > 0) { + const declaration = symbol.declarations[0]; + let module: string; + + if (ts.isImportSpecifier(declaration)) { + name = (declaration.propertyName || declaration.name).text; + module = (declaration.parent.parent.parent.moduleSpecifier as ts.StringLiteral).text; + } else if (ts.isNamespaceImport(declaration)) { + // Use the name from the decorator namespace property access + module = (declaration.parent.parent.moduleSpecifier as ts.StringLiteral).text; + } else if (ts.isImportClause(declaration)) { + name = (declaration.name as ts.Identifier).text; + module = (declaration.parent.moduleSpecifier as ts.StringLiteral).text; + } else { + return null; + } + + return { name, module }; + } + + return null; +} diff --git a/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts new file mode 100644 index 000000000000..10d45d00d714 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { relative } from 'node:path/posix'; +import ts from 'typescript'; + +/** + * A transformer factory that adds a property to the lazy-loaded route object. + * This property is used to allow for the retrieval of the module path during SSR. + * + * @param compilerOptions The compiler options. + * @param compilerHost The compiler host. + * @returns A transformer factory. + * + * @example + * **Before:** + * ```ts + * const routes: Routes = [ + * { + * path: 'lazy', + * loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule) + * } + * ]; + * ``` + * + * **After:** + * ```ts + * const routes: Routes = [ + * { + * path: 'lazy', + * loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule), + * ...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "./lazy/lazy.module.ts" }: {}) + * } + * ]; + * ``` + */ +export function lazyRoutesTransformer( + compilerOptions: ts.CompilerOptions, + compilerHost: ts.CompilerHost, +): ts.TransformerFactory { + const moduleResolutionCache = compilerHost.getModuleResolutionCache?.(); + assert( + typeof compilerOptions.basePath === 'string', + 'compilerOptions.basePath should be a string.', + ); + const basePath = compilerOptions.basePath; + + return (context: ts.TransformationContext) => { + const factory = context.factory; + + const visitor = (node: ts.Node): ts.Node => { + if (!ts.isObjectLiteralExpression(node)) { + // Not an object literal, so skip it. + return ts.visitEachChild(node, visitor, context); + } + + const loadFunction = getLoadComponentOrChildrenProperty(node)?.initializer; + // Check if the initializer is an arrow function or a function expression + if ( + !loadFunction || + (!ts.isArrowFunction(loadFunction) && !ts.isFunctionExpression(loadFunction)) + ) { + return ts.visitEachChild(node, visitor, context); + } + + let callExpression: ts.CallExpression | undefined; + + if (ts.isArrowFunction(loadFunction)) { + // Handle arrow functions: body can either be a block or a direct call expression + const body = loadFunction.body; + + if (ts.isBlock(body)) { + // Arrow function with a block: check the first statement for a return call expression + const firstStatement = body.statements[0]; + + if ( + firstStatement && + ts.isReturnStatement(firstStatement) && + firstStatement.expression && + ts.isCallExpression(firstStatement.expression) + ) { + callExpression = firstStatement.expression; + } + } else if (ts.isCallExpression(body)) { + // Arrow function with a direct call expression as its body + callExpression = body; + } + } else if (ts.isFunctionExpression(loadFunction)) { + // Handle function expressions: check for a return statement with a call expression + const returnExpression = loadFunction.body.statements.find( + ts.isReturnStatement, + )?.expression; + + if (returnExpression && ts.isCallExpression(returnExpression)) { + callExpression = returnExpression; + } + } + + if (!callExpression) { + return ts.visitEachChild(node, visitor, context); + } + + // Optionally check for the 'then' property access expression + const expression = callExpression.expression; + if ( + !ts.isCallExpression(expression) && + ts.isPropertyAccessExpression(expression) && + expression.name.text !== 'then' + ) { + return ts.visitEachChild(node, visitor, context); + } + + const importExpression = ts.isPropertyAccessExpression(expression) + ? expression.expression // Navigate to the underlying expression for 'then' + : callExpression; + + // Ensure the underlying expression is an import call + if ( + !ts.isCallExpression(importExpression) || + importExpression.expression.kind !== ts.SyntaxKind.ImportKeyword + ) { + return ts.visitEachChild(node, visitor, context); + } + + // Check if the argument to the import call is a string literal + const callExpressionArgument = importExpression.arguments[0]; + if (!ts.isStringLiteralLike(callExpressionArgument)) { + // Not a string literal, so skip it. + return ts.visitEachChild(node, visitor, context); + } + + const resolvedPath = ts.resolveModuleName( + callExpressionArgument.text, + node.getSourceFile().fileName, + compilerOptions, + compilerHost, + moduleResolutionCache, + )?.resolvedModule?.resolvedFileName; + + if (!resolvedPath) { + // Could not resolve the module, so skip it. + return ts.visitEachChild(node, visitor, context); + } + + const resolvedRelativePath = relative(basePath, resolvedPath); + + // Create the new property + // Example: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" }: {})` + const newProperty = factory.createSpreadAssignment( + factory.createParenthesizedExpression( + factory.createConditionalExpression( + factory.createBinaryExpression( + factory.createBinaryExpression( + factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')), + factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken), + factory.createStringLiteral('undefined'), + ), + factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken), + factory.createIdentifier('ngServerMode'), + ), + factory.createToken(ts.SyntaxKind.QuestionToken), + factory.createObjectLiteralExpression([ + factory.createPropertyAssignment( + factory.createIdentifier('ɵentryName'), + factory.createStringLiteral(resolvedRelativePath), + ), + ]), + factory.createToken(ts.SyntaxKind.ColonToken), + factory.createObjectLiteralExpression([]), + ), + ), + ); + + // Add the new property to the object literal. + return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]); + }; + + return (sourceFile) => { + const text = sourceFile.text; + if (!text.includes('loadC')) { + // Fast check for 'loadComponent' and 'loadChildren'. + return sourceFile; + } + + return ts.visitEachChild(sourceFile, visitor, context); + }; + }; +} + +/** + * Retrieves the property assignment for the `loadComponent` or `loadChildren` property of a route object. + * + * @param node The object literal expression to search. + * @returns The property assignment if found, otherwise `undefined`. + */ +function getLoadComponentOrChildrenProperty( + node: ts.ObjectLiteralExpression, +): ts.PropertyAssignment | undefined { + let hasPathProperty = false; + let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) { + continue; + } + + const propertyNameText = prop.name.text; + if (propertyNameText === 'path') { + hasPathProperty = true; + } else if (propertyNameText === 'loadComponent' || propertyNameText === 'loadChildren') { + loadComponentOrChildrenProperty = prop; + } + + if (hasPathProperty && loadComponentOrChildrenProperty) { + break; + } + } + + return loadComponentOrChildrenProperty; +} diff --git a/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts new file mode 100644 index 000000000000..4dd388f28eb1 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import ts from 'typescript'; +import { lazyRoutesTransformer } from './lazy-routes-transformer'; + +describe('lazyRoutesTransformer', () => { + let program: ts.Program; + let compilerHost: ts.CompilerHost; + + beforeEach(() => { + // Mock a basic TypeScript program and compilerHost + program = ts.createProgram(['/project/src/dummy.ts'], { basePath: '/project/' }); + compilerHost = { + getNewLine: () => '\n', + fileExists: () => true, + readFile: () => '', + writeFile: () => undefined, + getCanonicalFileName: (fileName: string) => fileName, + getCurrentDirectory: () => '/project', + getDefaultLibFileName: () => 'lib.d.ts', + getSourceFile: () => undefined, + useCaseSensitiveFileNames: () => true, + resolveModuleNames: (moduleNames, containingFile) => + moduleNames.map( + (name) => + ({ + resolvedFileName: `/project/src/${name}.ts`, + }) as ts.ResolvedModule, + ), + }; + }); + + const transformSourceFile = (sourceCode: string): ts.SourceFile => { + const sourceFile = ts.createSourceFile( + '/project/src/dummy.ts', + sourceCode, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ); + + const transformer = lazyRoutesTransformer(program.getCompilerOptions(), compilerHost); + const result = ts.transform(sourceFile, [transformer]); + + return result.transformed[0]; + }; + + it('should return the same object when the routes array contains an empty object', () => { + const source = ` + const routes = [{}]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain(`const routes = [{}]`); + }); + + it('should add ɵentryName property to object with loadComponent and path (Arrow function)', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: () => import('./home').then(m => m.HomeComponent) + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent and path (Arrow function with return)', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: () => { + return import('./home').then(m => m.HomeComponent); + } + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent and path (Arrow function without .then)', () => { + const source = ` + const routes = [ + { + path: 'about', + loadComponent: () => import('./about') + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/about.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent using return and .then', () => { + const source = ` + const routes = [ + { + path: '', + loadComponent: () => { + return import('./home').then((m) => m.HomeComponent); + } + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent and path (Function expression)', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: function () { return import('./home').then(m => m.HomeComponent) } + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should not modify unrelated object literals', () => { + const source = ` + const routes = [ + { + path: 'home', + component: HomeComponent + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).not.toContain(`ɵentryName`); + }); + + it('should ignore loadComponent without a valid import call', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: () => someFunction() + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).not.toContain(`ɵentryName`); + }); + + it('should resolve paths relative to basePath', () => { + const source = ` + const routes = [ + { + path: 'about', + loadChildren: () => import('./features/about').then(m => m.AboutModule) + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/features/about.ts" } : {})`, + ); + }); +}); diff --git a/packages/angular/build/src/tools/angular/transformers/web-worker-transformer.ts b/packages/angular/build/src/tools/angular/transformers/web-worker-transformer.ts new file mode 100644 index 000000000000..329320843838 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/web-worker-transformer.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import ts from 'typescript'; + +/** + * Creates a TypeScript Transformer to process Worker and SharedWorker entry points and transform + * the URL instances to reference the built and bundled worker code. This uses a callback process + * similar to the component stylesheets to allow the main esbuild plugin to process files as needed. + * Unsupported worker expressions will be left in their origin form. + * @param getTypeChecker A function that returns a TypeScript TypeChecker instance for the program. + * @returns A TypeScript transformer factory. + */ +export function createWorkerTransformer( + fileProcessor: (file: string, importer: string) => string, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + const nodeFactory = context.factory; + + const visitNode: ts.Visitor = (node: ts.Node) => { + // Check if the node is a valid new expression for a Worker or SharedWorker + // TODO: Add global scope check + if ( + !ts.isNewExpression(node) || + !ts.isIdentifier(node.expression) || + (node.expression.text !== 'Worker' && node.expression.text !== 'SharedWorker') + ) { + // Visit child nodes of non-Worker expressions + return ts.visitEachChild(node, visitNode, context); + } + + // Worker should have atleast one argument but not more than two + if (!node.arguments || node.arguments.length < 1 || node.arguments.length > 2) { + return node; + } + + // First argument must be a new URL expression + const workerUrlNode = node.arguments[0]; + // TODO: Add global scope check + if ( + !ts.isNewExpression(workerUrlNode) || + !ts.isIdentifier(workerUrlNode.expression) || + workerUrlNode.expression.text !== 'URL' + ) { + return node; + } + + // URL must have 2 arguments + if (!workerUrlNode.arguments || workerUrlNode.arguments.length !== 2) { + return node; + } + + // URL arguments must be a string and then `import.meta.url` + if ( + !ts.isStringLiteralLike(workerUrlNode.arguments[0]) || + !ts.isPropertyAccessExpression(workerUrlNode.arguments[1]) || + !ts.isMetaProperty(workerUrlNode.arguments[1].expression) || + workerUrlNode.arguments[1].name.text !== 'url' + ) { + return node; + } + + const filePath = workerUrlNode.arguments[0].text; + const importer = node.getSourceFile().fileName; + + // Process the file + const replacementPath = fileProcessor(filePath, importer); + + // Update if the path changed + if (replacementPath !== filePath) { + return nodeFactory.updateNewExpression( + node, + node.expression, + node.typeArguments, + // Update Worker arguments + ts.setTextRange( + nodeFactory.createNodeArray( + [ + nodeFactory.updateNewExpression( + workerUrlNode, + workerUrlNode.expression, + workerUrlNode.typeArguments, + // Update URL arguments + ts.setTextRange( + nodeFactory.createNodeArray( + [ + nodeFactory.createStringLiteral(replacementPath), + workerUrlNode.arguments[1], + ], + workerUrlNode.arguments.hasTrailingComma, + ), + workerUrlNode.arguments, + ), + ), + // Use the second Worker argument (options) if present. + // Otherwise create a default options object for module Workers. + node.arguments[1] ?? + nodeFactory.createObjectLiteralExpression([ + nodeFactory.createPropertyAssignment( + 'type', + nodeFactory.createStringLiteral('module'), + ), + ]), + ], + node.arguments.hasTrailingComma, + ), + node.arguments, + ), + ); + } else { + return node; + } + }; + + return (sourceFile) => { + // Skip transformer if there are no Workers + if (!sourceFile.text.includes('Worker')) { + return sourceFile; + } + + return ts.visitEachChild(sourceFile, visitNode, context); + }; + }; +} diff --git a/packages/angular/build/src/tools/angular/uri.ts b/packages/angular/build/src/tools/angular/uri.ts new file mode 100644 index 000000000000..0b662f58c670 --- /dev/null +++ b/packages/angular/build/src/tools/angular/uri.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * A string value representing the base namespace for Angular JIT mode related imports. + */ +const JIT_BASE_NAMESPACE = 'angular:jit'; + +/** + * A string value representing the namespace for Angular JIT mode related imports for + * Component styles. This namespace is used for both inline (`styles`) and external + * (`styleUrls`) styles. + */ +export const JIT_STYLE_NAMESPACE = `${JIT_BASE_NAMESPACE}:style` as const; + +/** + * A string value representing the namespace for Angular JIT mode related imports for + * Component templates. This namespace is currently only used for external (`templateUrl`) + * templates. + */ +export const JIT_TEMPLATE_NAMESPACE = `${JIT_BASE_NAMESPACE}:template` as const; + +/** + * A regular expression that can be used to match a Angular JIT mode namespace URI. + * It contains capture groups for the type (template/style), origin (file/inline), and specifier. + * The {@link parseJitUri} function can be used to parse and return an object representation of a JIT URI. + */ +export const JIT_NAMESPACE_REGEXP = new RegExp( + `^${JIT_BASE_NAMESPACE}:(template|style):(file|inline);(.*)$`, +); + +/** + * Generates an Angular JIT mode namespace URI for a given file. + * @param file The path of the file to be included. + * @param type The type of the file (`style` or `template`). + * @returns A string containing the full JIT namespace URI. + */ +export function generateJitFileUri(file: string, type: 'style' | 'template') { + return `${JIT_BASE_NAMESPACE}:${type}:file;${file}`; +} + +/** + * Generates an Angular JIT mode namespace URI for a given inline style or template. + * The provided content is base64 encoded and included in the URI. + * @param data The content to encode within the URI. + * @param type The type of the content (`style` or `template`). + * @returns A string containing the full JIT namespace URI. + */ +export function generateJitInlineUri(data: string | Uint8Array, type: 'style' | 'template') { + // Node.js types do not allow a string even though this is valid. + return `${JIT_BASE_NAMESPACE}:${type}:inline;${Buffer.from(data as Uint8Array).toString('base64')}`; +} + +/** + * Parses a string containing a JIT namespace URI. + * JIT namespace URIs are used to encode the information for an Angular component's stylesheets + * and templates when compiled in JIT mode. + * @param uri The URI to parse into its underlying components. + * @returns An object containing the namespace, type, origin, and specifier of the URI; + * `undefined` if not a JIT namespace URI. + */ +export function parseJitUri(uri: string) { + const matches = JIT_NAMESPACE_REGEXP.exec(uri); + if (!matches) { + return undefined; + } + + return { + namespace: `${JIT_BASE_NAMESPACE}:${matches[1]}`, + type: matches[1] as 'style' | 'template', + origin: matches[2] as 'file' | 'inline', + specifier: matches[3], + }; +} diff --git a/packages/angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.ts b/packages/angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.ts new file mode 100644 index 000000000000..d0aa9aff841d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; + +const NG_LOCALIZE_RESOLUTION = Symbol('NG_LOCALIZE_RESOLUTION'); + +/** + * This plugin addresses an issue where '@angular/localize/init' is directly imported, + * potentially resulting in undefined behavior. By detecting such imports, the plugin + * issues a warning and suggests including '@angular/localize/init' as a polyfill. + * + * @returns An esbuild plugin. + */ +export function createAngularLocalizeInitWarningPlugin(): Plugin { + return { + name: 'angular-localize-init-warning', + setup(build) { + build.onResolve({ filter: /^@angular\/localize\/init/ }, async (args) => { + if (args.pluginData?.[NG_LOCALIZE_RESOLUTION]) { + return null; + } + + if (args.namespace?.startsWith('angular:')) { + return null; + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[NG_LOCALIZE_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + return { + ...result, + warnings: [ + ...result.warnings, + { + text: `Direct import of '@angular/localize/init' detected. This may lead to undefined behavior.`, + notes: [{ text: `Include '@angular/localize/init' as a polyfill instead.` }], + }, + ], + }; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts new file mode 100644 index 000000000000..541f52bbca76 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { CompilerOptions } from '@angular/compiler-cli'; +import type { AngularCompilation } from '../../angular/compilation'; + +export abstract class AngularCompilationContext { + abstract readonly compilation?: AngularCompilation; + abstract isPrimary(): this is PrimaryCompilationContext; + abstract readonly waitUntilReady: Promise; + abstract getCompilerOptions(): Promise; + abstract dispose(): Promise; + + createSecondaryContext(): AngularCompilationContext { + return new SecondaryCompilationContext(this); + } +} + +export class PrimaryCompilationContext extends AngularCompilationContext { + readonly #compilation: AngularCompilation; + #pendingCompilation = true; + #resolveCompilationReady: ((value: boolean) => void) | undefined; + #compilationReadyPromise: Promise | undefined; + #hasErrors = true; + + #compilerOptions: CompilerOptions | undefined; + #resolveCompilerOptions: ((options: CompilerOptions) => void) | undefined; + #compilerOptionsPromise: Promise | undefined; + + constructor(compilation: AngularCompilation) { + super(); + this.#compilation = compilation; + } + + override isPrimary(): this is PrimaryCompilationContext { + return true; + } + + override get compilation(): AngularCompilation { + return this.#compilation; + } + + override get waitUntilReady(): Promise { + if (!this.#pendingCompilation) { + return Promise.resolve(this.#hasErrors); + } + + this.#compilationReadyPromise ??= new Promise((resolve) => { + this.#resolveCompilationReady = resolve; + }); + + return this.#compilationReadyPromise; + } + + override getCompilerOptions(): Promise { + if (this.#compilerOptions) { + return Promise.resolve(this.#compilerOptions); + } + + if (!this.#pendingCompilation) { + return Promise.resolve({}); + } + + this.#compilerOptionsPromise ??= new Promise((resolve) => { + this.#resolveCompilerOptions = resolve; + }); + + return this.#compilerOptionsPromise; + } + + setCompilerOptions(options: CompilerOptions): void { + this.#compilerOptions = options; + this.#resolveCompilerOptions?.(options); + this.#resolveCompilerOptions = undefined; + this.#compilerOptionsPromise = undefined; + } + + markAsReady(hasErrors: boolean): void { + this.#hasErrors = hasErrors; + this.#resolveCompilationReady?.(hasErrors); + this.#resolveCompilationReady = undefined; + this.#compilationReadyPromise = undefined; + this.#pendingCompilation = false; + + if (this.#resolveCompilerOptions) { + this.#resolveCompilerOptions(this.#compilerOptions ?? {}); + this.#resolveCompilerOptions = undefined; + this.#compilerOptionsPromise = undefined; + } + } + + markAsInProgress(): void { + this.#pendingCompilation = true; + this.#compilerOptions = undefined; + } + + #disposal: Promise | undefined; + + override dispose(): Promise { + // Reuse any in progress disposal to ensure all callers can await completion + return (this.#disposal ??= this.#close()); + } + + async #close(): Promise { + this.markAsReady(true); + try { + await this.#compilation.close?.(); + } catch { + // Suppress closure errors to avoid unhandled rejections during teardown. + } + } +} + +export class SecondaryCompilationContext extends AngularCompilationContext { + constructor(private readonly primaryContext?: AngularCompilationContext) { + super(); + } + + override isPrimary(): this is PrimaryCompilationContext { + return false; + } + + override get compilation(): undefined { + return undefined; + } + + override get waitUntilReady(): Promise { + return this.primaryContext?.waitUntilReady ?? Promise.resolve(false); + } + + override getCompilerOptions(): Promise { + return this.primaryContext?.getCompilerOptions() ?? Promise.resolve({}); + } + + override async dispose(): Promise { + // No-op for secondary context to avoid disposing the primary compilation worker + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation-state_spec.ts b/packages/angular/build/src/tools/esbuild/angular/compilation-state_spec.ts new file mode 100644 index 000000000000..8007427d497c --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state_spec.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { CompilerOptions } from '@angular/compiler-cli'; +import type { AngularCompilation } from '../../angular/compilation'; +import { PrimaryCompilationContext, SecondaryCompilationContext } from './compilation-state'; + +describe('compilation-state', () => { + let mockCompilation: jasmine.SpyObj; + + beforeEach(() => { + mockCompilation = jasmine.createSpyObj('AngularCompilation', [ + 'initialize', + 'close', + ]); + }); + + describe('PrimaryCompilationContext', () => { + it('identifies as primary and provides the underlying compilation', () => { + const context = new PrimaryCompilationContext(mockCompilation); + + expect(context.isPrimary()).toBe(true); + expect(context.compilation).toBe(mockCompilation); + }); + + it('resolves getCompilerOptions after setCompilerOptions is called', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + const options: CompilerOptions = { target: 9, allowJs: true }; + + context.setCompilerOptions(options); + + const resolved = await context.getCompilerOptions(); + expect(resolved).toEqual(options); + }); + + it('resolves pending getCompilerOptions when setCompilerOptions is called later', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + const options: CompilerOptions = { target: 9, allowJs: true }; + + const optionsPromise = context.getCompilerOptions(); + context.setCompilerOptions(options); + + const resolved = await optionsPromise; + expect(resolved).toEqual(options); + }); + + it('manages ready state lifecycle correctly', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + // Initially in progress + const readyPromise = context.waitUntilReady; + context.markAsReady(false); + + const hasErrors = await readyPromise; + expect(hasErrors).toBe(false); + + // When already ready, immediately returns error state + expect(await context.waitUntilReady).toBe(false); + + // Mark in progress again + context.markAsInProgress(); + const secondReadyPromise = context.waitUntilReady; + context.markAsReady(true); + + expect(await secondReadyPromise).toBe(true); + }); + + it('closes compilation and marks ready with errors on dispose', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + const readyPromise = context.waitUntilReady; + await context.dispose(); + + expect(mockCompilation.close).toHaveBeenCalledTimes(1); + expect(await readyPromise).toBe(true); + }); + + it('unblocks pending getCompilerOptions on markAsReady error', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + const optionsPromise = context.getCompilerOptions(); + context.markAsReady(true); + + const options = await optionsPromise; + expect(options).toEqual({}); + }); + + it('returns empty options from getCompilerOptions when compilation is not pending and options are unset', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + context.markAsReady(false); + + const options = await context.getCompilerOptions(); + expect(options).toEqual({}); + }); + + it('clears cached compiler options on markAsInProgress to wait for fresh options on rebuild', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + const initialOptions: CompilerOptions = { target: 9, allowJs: false }; + context.setCompilerOptions(initialOptions); + context.markAsReady(false); + + expect(await context.getCompilerOptions()).toEqual(initialOptions); + + // Rebuild starts + context.markAsInProgress(); + + // Pending call should wait for new options rather than returning stale ones + const nextOptionsPromise = context.getCompilerOptions(); + const updatedOptions: CompilerOptions = { target: 10, allowJs: true }; + context.setCompilerOptions(updatedOptions); + + expect(await nextOptionsPromise).toEqual(updatedOptions); + }); + + it('creates a linked SecondaryCompilationContext', () => { + const context = new PrimaryCompilationContext(mockCompilation); + const secondary = context.createSecondaryContext(); + + expect(secondary).toBeInstanceOf(SecondaryCompilationContext); + expect(secondary.isPrimary()).toBe(false); + expect(secondary.compilation).toBeUndefined(); + }); + }); + + describe('SecondaryCompilationContext', () => { + it('identifies as non-primary and has undefined compilation', () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + + expect(secondary.isPrimary()).toBe(false); + expect(secondary.compilation).toBeUndefined(); + }); + + it('delegates waitUntilReady to primary context', async () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + + const secondaryReadyPromise = secondary.waitUntilReady; + primary.markAsReady(false); + + expect(await secondaryReadyPromise).toBe(false); + }); + + it('delegates getCompilerOptions to primary context', async () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + const options: CompilerOptions = { target: 9, allowJs: false }; + + primary.setCompilerOptions(options); + + expect(await secondary.getCompilerOptions()).toEqual(options); + }); + + it('does not dispose primary compilation when secondary is disposed', async () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + + await secondary.dispose(); + + expect(mockCompilation.close).not.toHaveBeenCalled(); + }); + + it('handles standalone instantiation with sensible defaults', async () => { + const standalone = new SecondaryCompilationContext(); + + expect(standalone.isPrimary()).toBe(false); + expect(standalone.compilation).toBeUndefined(); + expect(await standalone.waitUntilReady).toBe(false); + expect(await standalone.getCompilerOptions()).toEqual({}); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts new file mode 100644 index 000000000000..23e7e88dba74 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -0,0 +1,803 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { + BuildFailure, + Loader, + Metafile, + OnStartResult, + OutputFile, + PartialMessage, + PartialNote, + Plugin, + PluginBuild, +} from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { type PersistentCacheStore, createPersistentCacheStore } from '../../../utils/cache'; +import { maxTransformWorkers, useTypeChecking } from '../../../utils/environment-options'; +import { calculateHash, initializeHash } from '../../../utils/hash'; +import { + logCumulativeDurations, + profileAsync, + resetCumulativeDurations, +} from '../../../utils/profiling'; +import { AngularHostOptions } from '../../angular/angular-host'; +import { AngularCompilation, DiagnosticModes } from '../../angular/compilation'; +import { JavaScriptTransformer } from '../../javascript-transformer'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; +import { AngularCompilationContext, PrimaryCompilationContext } from './compilation-state'; +import { ComponentStylesheetBundler } from './component-stylesheets'; +import { FileReferenceTracker } from './file-reference-tracker'; +import { setupJitPluginCallbacks } from './jit-plugin-callbacks'; +import { rewriteForBazel } from './rewrite-bazel-paths'; +import { SourceFileCache } from './source-file-cache'; + +export interface CompilerPluginOptions { + sourcemap: boolean | 'external'; + tsconfig: string; + jit?: boolean; + + /** + * Include class metadata and JIT information in built code. + * The Angular TestBed APIs require additional metadata for the Angular aspects of the application + * such as Components, Modules, Pipes, etc. + * TestBed may also leverage JIT capabilities during testing (e.g., overrideComponent). + */ + includeTestMetadata?: boolean; + + advancedOptimizations?: boolean; + thirdPartySourcemaps?: boolean; + fileReplacements?: Record; + sourceFileCache?: SourceFileCache; + loadResultCache?: LoadResultCache; + incremental: boolean; + externalRuntimeStyles?: boolean; + instrumentForCoverage?: (request: string) => boolean; + templateUpdates?: Map; + rootFiles?: string[]; +} + +// eslint-disable-next-line max-lines-per-function +export function createCompilerPlugin( + pluginOptions: CompilerPluginOptions, + compilationContextOrCompilation: + AngularCompilationContext | AngularCompilation | (() => Promise), + stylesheetBundler: ComponentStylesheetBundler, +): Plugin { + return { + name: 'angular-compiler', + // eslint-disable-next-line max-lines-per-function + async setup(build: PluginBuild): Promise { + let setupWarnings: PartialMessage[] | undefined = []; + let diagnosticsPromise: ReturnType | undefined; + const preserveSymlinks = build.initialOptions.preserveSymlinks; + + // Initialize a worker pool for JavaScript transformations. + // Webcontainers currently do not support this persistent cache store. + let cacheStore: PersistentCacheStore | undefined; + if (pluginOptions.sourceFileCache?.persistentCachePath && !process.versions.webcontainer) { + try { + cacheStore = await createPersistentCacheStore( + path.join(pluginOptions.sourceFileCache.persistentCachePath, 'angular-compiler'), + ); + } catch (e) { + setupWarnings.push({ + text: 'Unable to initialize JavaScript cache storage.', + location: null, + notes: [ + ...(e as Error).message + .split('\n') + .slice(1) + .map((text) => ({ text })), + { + text: 'This will not affect the build output content but may result in slower builds.', + }, + ], + }); + } + } + const javascriptTransformer = new JavaScriptTransformer( + { + sourcemap: !!pluginOptions.sourcemap, + thirdPartySourcemaps: pluginOptions.thirdPartySourcemaps, + advancedOptimizations: pluginOptions.advancedOptimizations, + jit: pluginOptions.jit || pluginOptions.includeTestMetadata, + maxConcurrency: maxTransformWorkers, + }, + cacheStore?.createCache('jstransformer'), + ); + + // Setup defines based on the values used by the Angular compiler-cli + build.initialOptions.define ??= {}; + build.initialOptions.define['ngI18nClosureMode'] ??= 'false'; + + // The factory is only relevant for compatibility purposes with the private API. + // TODO: Update private API in the next major to allow compilation function factory removal here. + const angularCompilationContext = + compilationContextOrCompilation instanceof AngularCompilationContext + ? compilationContextOrCompilation + : new PrimaryCompilationContext( + typeof compilationContextOrCompilation === 'function' + ? await compilationContextOrCompilation() + : compilationContextOrCompilation, + ); + const compilation: AngularCompilation | undefined = angularCompilationContext.compilation; + + // The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files. + // A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code. + const typeScriptFileCache = + pluginOptions.sourceFileCache?.typeScriptFileCache ?? + new Map(); + + // The resources from component stylesheets and web workers that will be added to the build results output files + const additionalResults = new Map< + string, + { outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] } + >(); + + // Compilation is initially assumed to have errors until emitted + let hasCompilationErrors = true; + + // Determines if TypeScript should process JavaScript files based on tsconfig `allowJs` option + let shouldTsIgnoreJs = true; + // Determines if transpilation should be handle by TypeScript or esbuild + let useTypeScriptTranspilation = true; + + // To fully invalidate files, track resource referenced files and their referencing source + const referencedFileTracker = new FileReferenceTracker(); + + // eslint-disable-next-line max-lines-per-function + build.onStart(async () => { + hasCompilationErrors = true; + await initializeHash(); + + const result: OnStartResult = { + warnings: setupWarnings, + }; + + if (!angularCompilationContext.isPrimary()) { + hasCompilationErrors = await angularCompilationContext.waitUntilReady; + const compilerOptions = await angularCompilationContext.getCompilerOptions(); + shouldTsIgnoreJs = !compilerOptions.allowJs; + useTypeScriptTranspilation = !!compilerOptions['_useTypeScriptTranspilation']; + + return result; + } + + angularCompilationContext.markAsInProgress(); + const compilation = angularCompilationContext.compilation; + + // Reset debug performance tracking + resetCumulativeDurations(); + + // Update the reference tracker and generate a full set of modified files for the + // Angular compiler which does not have direct knowledge of transitive resource + // dependencies or web worker processing. + let modifiedFiles; + if (pluginOptions.sourceFileCache?.modifiedFiles.size) { + // TODO: Differentiate between changed input files and stale output files + modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles); + pluginOptions.sourceFileCache.invalidate(modifiedFiles); + // External runtime styles are invalidated and rebuilt at the beginning of a rebuild to avoid + // the need to execute the application bundler for component style only changes. + if (!pluginOptions.externalRuntimeStyles) { + stylesheetBundler.invalidate(modifiedFiles); + } + // Remove any stale additional results based on modified files + modifiedFiles.forEach((file) => additionalResults.delete(file)); + } + + if (compilation.update && pluginOptions.sourceFileCache?.modifiedFiles.size) { + await compilation.update(modifiedFiles ?? pluginOptions.sourceFileCache.modifiedFiles); + } + + // Create Angular compiler host options + const hostOptions: AngularHostOptions = { + fileReplacements: pluginOptions.fileReplacements, + modifiedFiles, + async transformStylesheet(data, containingFile, stylesheetFile, order, className) { + let stylesheetResult; + let resultSource = stylesheetFile ?? containingFile; + + // Stylesheet file only exists for external stylesheets + if (stylesheetFile) { + stylesheetResult = await stylesheetBundler.bundleFile(stylesheetFile); + } else { + stylesheetResult = await stylesheetBundler.bundleInline( + data, + containingFile, + // Inline stylesheets from a template style element are always CSS; Otherwise, use default. + containingFile.endsWith('.html') ? 'css' : undefined, + // When external runtime styles are enabled, an identifier for the style that does not change + // based on the content is required to avoid emitted JS code changes. Any JS code changes will + // invalid the output and force a full page reload for HMR cases. The containing file and order + // of the style within the containing file is used. + pluginOptions.externalRuntimeStyles + ? calculateHash(`${containingFile}${order ?? 0}${className ?? ''}`) + : undefined, + ); + // Adjust result source for inline styles. + // There may be multiple inline styles with the same containing file and to ensure that the results + // do not overwrite each other the result source identifier needs to be unique for each. The class + // name and order fields can be used for this. The structure is arbitrary as long as it is unique. + resultSource += `?class=${className}&order=${order}`; + } + + (result.warnings ??= []).push(...stylesheetResult.warnings); + if (stylesheetResult.errors) { + (result.errors ??= []).push(...stylesheetResult.errors); + + const { referencedFiles } = stylesheetResult; + if (referencedFiles) { + referencedFileTracker.add(containingFile, referencedFiles); + if (stylesheetFile) { + referencedFileTracker.add(stylesheetFile, referencedFiles); + } + } + + return ''; + } + + const { contents, outputFiles, metafile, referencedFiles } = stylesheetResult; + + additionalResults.set(resultSource, { + outputFiles, + metafile, + }); + + if (referencedFiles) { + referencedFileTracker.add(containingFile, referencedFiles); + if (stylesheetFile) { + // Angular AOT compiler needs modified direct resource files to correctly invalidate its analysis + referencedFileTracker.add(stylesheetFile, referencedFiles); + } + } + + return contents; + }, + processWebWorker(workerFile, containingFile) { + const fullWorkerPath = path.join(path.dirname(containingFile), workerFile); + // The synchronous API must be used due to the TypeScript compilation currently being + // fully synchronous and this process callback being called from within a TypeScript + // transformer. + const workerResult = bundleWebWorker(build, pluginOptions, fullWorkerPath); + + (result.warnings ??= []).push(...workerResult.warnings); + if (workerResult.errors.length > 0) { + (result.errors ??= []).push(...workerResult.errors); + // Track worker file errors to allow rebuilds on changes + referencedFileTracker.add( + containingFile, + workerResult.errors + .map((error) => error.location?.file) + .filter((file): file is string => !!file) + .map((file) => path.join(build.initialOptions.absWorkingDir ?? '', file)), + ); + additionalResults.set(fullWorkerPath, { errors: result.errors }); + + // Return the original path if the build failed + return workerFile; + } + + assert('outputFiles' in workerResult, 'Invalid web worker bundle result.'); + additionalResults.set(fullWorkerPath, { + outputFiles: workerResult.outputFiles, + metafile: workerResult.metafile, + }); + + referencedFileTracker.add( + containingFile, + Object.keys(workerResult.metafile.inputs).map((input) => + path.join(build.initialOptions.absWorkingDir ?? '', input), + ), + ); + + // Return bundled worker file entry name to be used in the built output + const workerCodeFile = workerResult.outputFiles.find((file) => + /^worker-[A-Z0-9]{8}.[cm]?js$/.test(path.basename(file.path)), + ); + assert(workerCodeFile, 'Web Worker bundled code file should always be present.'); + const workerCodePath = path.relative( + build.initialOptions.outdir ?? '', + workerCodeFile.path, + ); + + return workerCodePath.replaceAll('\\', '/'); + }, + }; + + // Initialize the Angular compilation for the current build. + // In watch mode, previous build state will be reused. + let referencedFiles; + let externalStylesheets; + try { + const initializationResult = await compilation.initialize( + pluginOptions.tsconfig, + hostOptions, + { + sourcemap: !!pluginOptions.sourcemap, + preserveSymlinks, + cachePath: pluginOptions.sourceFileCache?.persistentCachePath, + externalRuntimeStyles: pluginOptions.externalRuntimeStyles, + enableHmr: !!pluginOptions.templateUpdates, + instrumentForCoverage: !!pluginOptions.instrumentForCoverage, + includeTestMetadata: !!pluginOptions.includeTestMetadata, + customConditions: build.initialOptions.conditions, + rootFiles: pluginOptions.rootFiles, + basePath: build.initialOptions.absWorkingDir, + }, + ); + + if (initializationResult.warnings?.length) { + setupWarnings?.push(...initializationResult.warnings); + } + + angularCompilationContext.setCompilerOptions(initializationResult.compilerOptions); + shouldTsIgnoreJs = !initializationResult.compilerOptions.allowJs; + useTypeScriptTranspilation = + !!initializationResult.compilerOptions['_useTypeScriptTranspilation']; + referencedFiles = initializationResult.referencedFiles; + externalStylesheets = initializationResult.externalStylesheets; + + if (initializationResult.templateUpdates) { + // Propagate any template updates + initializationResult.templateUpdates.forEach((value, key) => + pluginOptions.templateUpdates?.set(key, value), + ); + } + } catch (error) { + (result.errors ??= []).push({ + text: 'Angular compilation initialization failed.', + location: null, + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : `${error}`, + location: null, + }, + ], + }); + + // Initialization failure prevents further compilation steps + hasCompilationErrors = true; + angularCompilationContext.markAsReady(true); + + return result; + } + + if (externalStylesheets) { + // Process any new external stylesheets + for (const [stylesheetFile, externalId] of externalStylesheets) { + await bundleExternalStylesheet( + stylesheetBundler, + stylesheetFile, + externalId, + result, + additionalResults, + ); + } + } + + // Update TypeScript file output cache for all affected files if not using on-demand transforms + if (!compilation.transformFile) { + try { + await profileAsync('NG_EMIT_TS', async () => { + for (const { filename, contents } of await compilation.emitAffectedFiles()) { + typeScriptFileCache.set(path.normalize(filename), contents); + } + }); + } catch (error) { + (result.errors ??= []).push({ + text: 'Angular compilation emit failed.', + location: null, + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : `${error}`, + location: null, + }, + ], + }); + } + + const diagnosticModes = useTypeChecking + ? DiagnosticModes.All + : DiagnosticModes.All & ~DiagnosticModes.Semantic; + diagnosticsPromise = compilation.diagnoseFiles(diagnosticModes).catch((error) => ({ + errors: [ + { + text: 'Angular compilation diagnostics failed.', + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : String(error), + }, + ], + }, + ], + })); + } + + // Add errors from failed additional results. + // This must be done after emit to capture latest web worker results. + for (const { errors } of additionalResults.values()) { + if (errors) { + (result.errors ??= []).push(...errors); + } + } + + // Store referenced files for updated file watching if enabled + if (pluginOptions.sourceFileCache) { + pluginOptions.sourceFileCache.referencedFiles = [ + ...referencedFiles, + ...referencedFileTracker.referencedFiles, + ]; + } + + hasCompilationErrors = !!result.errors?.length; + + // Reset the setup warnings so that they are only shown during the first build. + setupWarnings = undefined; + + angularCompilationContext.markAsReady(hasCompilationErrors); + + return result; + }); + + build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => { + const request = rewriteForBazel( + path.normalize(pluginOptions.fileReplacements?.[path.normalize(args.path)] ?? args.path), + ); + const isJS = /\.[cm]?js$/.test(request); + + // Skip TS load attempt if JS TypeScript compilation not enabled and file is JS + if (shouldTsIgnoreJs && isJS) { + return undefined; + } + + // The filename is currently used as a cache key. Since the cache is memory only, + // the options cannot change and do not need to be represented in the key. If the + // cache is later stored to disk, then the options that affect transform output + // would need to be added to the key as well as a check for any change of content. + let contents = typeScriptFileCache.get(request); + let directContents: string | undefined; + + if (contents === undefined && compilation?.transformFile) { + try { + directContents = await readFile(request, 'utf-8'); + const transformResult = await compilation.transformFile(request, directContents); + if (transformResult) { + contents = transformResult.contents; + if (transformResult.watchFiles) { + referencedFileTracker.add(request, transformResult.watchFiles); + } + } + } catch (error) { + return { + errors: [ + { + text: 'Angular compilation transform failed.', + location: { file: request }, + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : `${error}`, + location: null, + }, + ], + }, + ], + }; + } + } + + if (contents === undefined) { + // If the Angular compilation had errors the file may not have been emitted. + // To avoid additional errors about missing files, return empty contents. + if (hasCompilationErrors) { + return { contents: '', loader: 'js' }; + } + + // No TS result indicates the file is not part of the TypeScript program. + // If allowJs is enabled and the file is JS then defer to the next load hook. + if (!shouldTsIgnoreJs && isJS) { + return undefined; + } + + const diangosticRoot = build.initialOptions.absWorkingDir ?? ''; + + // Evaluate whether the file requires the Angular compiler transpilation. + // If not, issue a warning but allow bundler to process the file (no type-checking). + directContents ??= await readFile(request, 'utf-8'); + if (!requiresAngularCompiler(directContents)) { + return { + warnings: [createMissingFileDiagnostic(request, args.path, diangosticRoot, false)], + contents: directContents, + loader: 'ts', + resolveDir: path.dirname(request), + }; + } + + // Otherwise return an error + return { + errors: [createMissingFileDiagnostic(request, args.path, diangosticRoot, true)], + }; + } else if (typeof contents === 'string' && (useTypeScriptTranspilation || isJS)) { + // A string indicates untransformed output from the TS/NG compiler. + // This step is unneeded when using esbuild transpilation. + contents = await javascriptTransformer.transformData(request, contents, { + skipLinker: true, + sideEffects: () => hasSideEffects(request), + instrumentForCoverage: pluginOptions.instrumentForCoverage?.(request), + }); + + // Store as the returned Uint8Array to allow caching the fully transformed code + typeScriptFileCache.set(request, contents); + } + + let loader: Loader; + if (useTypeScriptTranspilation || isJS) { + // TypeScript has transpiled to JS or is already JS + loader = 'js'; + } else if (request.at(-1) === 'x') { + // TSX and TS have different syntax rules. Only set if input is a TSX file. + loader = 'tsx'; + } else { + // Otherwise, directly bundle TS + loader = 'ts'; + } + + return { + contents, + loader, + resolveDir: path.dirname(request), + }; + }); + + build.onLoad( + { filter: /\.[cm]?js$/ }, + createCachedLoad(pluginOptions.loadResultCache, async (args) => { + let request = rewriteForBazel(args.path); + if (pluginOptions.fileReplacements) { + const replacement = pluginOptions.fileReplacements[path.normalize(args.path)]; + if (replacement) { + request = path.normalize(replacement); + } + } + + return profileAsync( + 'NG_EMIT_JS*', + async () => { + const contents = await javascriptTransformer.transformFile(request, { + skipLinker: pluginOptions.jit, + sideEffects: () => hasSideEffects(request), + }); + + return { + contents, + loader: 'js', + resolveDir: path.dirname(request), + watchFiles: request !== args.path ? [request] : undefined, + }; + }, + true, + ); + }), + ); + + // Add a load handler if there are file replacement option entries for JSON files + if ( + pluginOptions.fileReplacements && + Object.keys(pluginOptions.fileReplacements).some((value) => value.endsWith('.json')) + ) { + build.onLoad( + { filter: /\.json$/ }, + createCachedLoad(pluginOptions.loadResultCache, async (args) => { + const replacement = pluginOptions.fileReplacements?.[path.normalize(args.path)]; + if (replacement) { + return { + contents: await readFile(path.normalize(replacement)), + loader: 'json' as const, + watchFiles: [replacement], + }; + } + + // If no replacement defined, let esbuild handle it directly + return null; + }), + ); + } + + // Setup bundling of component templates and stylesheets when in JIT mode + if (pluginOptions.jit) { + setupJitPluginCallbacks( + build, + stylesheetBundler, + additionalResults, + pluginOptions.loadResultCache, + ); + } + + build.onEnd(async (result) => { + // Ensure other compilations are unblocked if the main compilation throws during start + if (angularCompilationContext.isPrimary()) { + angularCompilationContext.markAsReady(hasCompilationErrors); + } + + for (const { outputFiles, metafile } of additionalResults.values()) { + // Add any additional output files to the main output files + if (outputFiles?.length) { + result.outputFiles?.push(...outputFiles); + } + + // Combine additional metafiles with main metafile + if (result.metafile && metafile) { + // Append the existing object, by appending to it we prevent unnecessary new objections creations with spread + // mitigating significant performance overhead for large apps. + // See: https://bugs.chromium.org/p/v8/issues/detail?id=11536 + Object.assign(result.metafile.inputs, metafile.inputs); + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + // Two stylesheets can bundle to the same output file. Clone the inputs on first + // insert so colliding inputs can be unioned in place with Object.assign, keeping + // every input attributed without per-collision spread allocations and leaving the + // cached `additionalResults` metafile untouched. + const existing = result.metafile.outputs[outputPath]; + if (existing) { + Object.assign(existing.inputs, output.inputs); + } else { + result.metafile.outputs[outputPath] = { + ...output, + inputs: { ...output.inputs }, + }; + } + } + } + } + + logCumulativeDurations(); + + if (diagnosticsPromise) { + try { + const diagnostics = await diagnosticsPromise; + const errors = diagnostics.errors?.length ? diagnostics.errors : undefined; + const warnings = diagnostics.warnings?.length ? diagnostics.warnings : undefined; + + if (errors || warnings) { + return { errors, warnings }; + } + } finally { + diagnosticsPromise = undefined; + } + } + }); + + build.onDispose(() => { + void angularCompilationContext.dispose(); + void javascriptTransformer.close(); + void cacheStore?.close(); + }); + + /** + * Checks if the file has side-effects when `advancedOptimizations` is enabled. + */ + async function hasSideEffects(path: string): Promise { + if (!pluginOptions.advancedOptimizations) { + return undefined; + } + + const { sideEffects } = await build.resolve(path, { + kind: 'import-statement', + resolveDir: build.initialOptions.absWorkingDir ?? '', + }); + + return sideEffects; + } + }, + }; +} + +async function bundleExternalStylesheet( + stylesheetBundler: ComponentStylesheetBundler, + stylesheetFile: string, + externalId: string | boolean, + result: OnStartResult, + additionalResults: Map< + string, + { outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] } + >, +) { + const styleResult = await stylesheetBundler.bundleFile(stylesheetFile, externalId); + + (result.warnings ??= []).push(...styleResult.warnings); + if (styleResult.errors) { + (result.errors ??= []).push(...styleResult.errors); + } else { + const { outputFiles, metafile } = styleResult; + // Clear inputs to prevent triggering a rebuild of the application code for component + // stylesheet file only changes when the dev server enables the internal-only external + // stylesheet option. This does not affect builds since only the dev server can enable + // the internal option. + metafile.inputs = {}; + additionalResults.set(stylesheetFile, { + outputFiles, + metafile, + }); + } +} + +function bundleWebWorker( + build: PluginBuild, + pluginOptions: CompilerPluginOptions, + workerFile: string, +) { + try { + return build.esbuild.buildSync({ + ...build.initialOptions, + platform: 'browser', + write: false, + bundle: true, + metafile: true, + format: 'esm', + entryNames: 'worker-[hash]', + chunkNames: 'worker-chunk-[hash]', + entryPoints: [workerFile], + sourcemap: pluginOptions.sourcemap, + // Zone.js is not used in Web workers so no need to disable + supported: undefined, + // Plugins are not supported in sync esbuild calls + plugins: undefined, + }); + } catch (error) { + if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) { + return error as BuildFailure; + } + throw error; + } +} + +function createMissingFileDiagnostic( + request: string, + original: string, + root: string, + angular: boolean, +): PartialMessage { + const relativeRequest = path.relative(root, request); + const notes: PartialNote[] = []; + + if (angular) { + notes.push({ + text: + `Files containing Angular metadata ('@Component'/'@Directive'/etc.) must be part of the TypeScript compilation.` + + ` You can ensure the file is part of the TypeScript program via the 'files' or 'include' property.`, + }); + } else { + notes.push({ + text: + `The file will be bundled and included in the output but will not be type-checked at build time.` + + ` To remove this message you can add the file to the TypeScript program via the 'files' or 'include' property.`, + }); + } + + const relativeOriginal = path.relative(root, original); + if (relativeRequest !== relativeOriginal) { + notes.push({ + text: `File is requested from a file replacement of '${relativeOriginal}'.`, + }); + } + + const diagnostic = { + text: `File '${relativeRequest}' not found in TypeScript compilation.`, + notes, + }; + + return diagnostic; +} + +const POTENTIAL_METADATA_REGEX = /@angular\/core|@Component|@Directive|@Injectable|@Pipe|@NgModule/; + +function requiresAngularCompiler(contents: string): boolean { + return POTENTIAL_METADATA_REGEX.test(contents); +} diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts new file mode 100644 index 000000000000..f8b048ce5459 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import path from 'node:path'; +import { MemoryCache } from '../../../utils/cache'; +import { createContentHash } from '../../../utils/hash'; +import { BundleContextResult, BundlerContext } from '../bundler-context'; +import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; +import { MemoryLoadResultCache } from '../load-result-cache'; +import { + BundleStylesheetOptions, + createStylesheetBundleOptions, +} from '../stylesheets/bundle-options'; + +export type ComponentStylesheetResult = BundleContextResult & { + contents: string; + referencedFiles: Set | undefined; +}; + +/** + * Bundles component stylesheets. A stylesheet can be either an inline stylesheet that + * is contained within the Component's metadata definition or an external file referenced + * from the Component's metadata definition. + */ +export class ComponentStylesheetBundler { + readonly #fileContexts = new MemoryCache(); + readonly #inlineContexts = new MemoryCache(); + readonly #loadCache = new MemoryLoadResultCache(); + + /** + * @param options An object containing the stylesheet bundling options. + * @param defaultInlineLanguage The default language to use for inline component styles. + * @param incremental True if incremental watch mode is enabled. + */ + constructor( + private readonly options: BundleStylesheetOptions, + private readonly defaultInlineLanguage: string, + private readonly incremental: boolean, + ) {} + + /** + * Bundle a file-based component stylesheet for use within an AOT compiled Angular application. + * @param entry The file path of the stylesheet. + * @param externalId Either an external identifier string for initial bundling or a boolean for rebuilds, if external. + * @param direct If true, the output will be used directly by the builder; false if used inside the compiler plugin. + * @returns A component bundle result object. + */ + async bundleFile( + entry: string, + externalId?: string | boolean, + direct?: boolean, + ): Promise { + entry = path.normalize(entry); + + const bundlerContext = await this.#fileContexts.getOrCreate(entry, () => { + return new BundlerContext( + this.options.workspaceRoot, + this.incremental, + (loadCache) => { + const buildOptions = createStylesheetBundleOptions(this.options, loadCache); + if (externalId) { + assert( + typeof externalId === 'string', + 'Initial external component stylesheets must have a string identifier', + ); + + buildOptions.entryPoints = { [externalId]: entry }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [entry]; + } + + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); + }); + + return this.extractResult( + await bundlerContext.bundle(), + bundlerContext.watchFiles, + !!externalId, + !!direct, + ); + } + + bundleAllFiles(external: boolean, direct: boolean) { + return Promise.all( + Array.from(this.#fileContexts.entries()).map(([entry]) => + this.bundleFile(entry, external, direct), + ), + ); + } + + async bundleInline( + data: string, + filename: string, + language = this.defaultInlineLanguage, + externalId?: string, + ): Promise { + filename = path.normalize(filename); + + // Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve + // to the actual stylesheet file path. + const hasher = createContentHash(); + hasher.update(data); + hasher.update(externalId ?? ''); + const id = hasher.digest(); + const entry = [language, id, filename].join(';'); + + const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { + const namespace = 'angular:styles/component'; + + return new BundlerContext( + this.options.workspaceRoot, + this.incremental, + (loadCache) => { + const buildOptions = createStylesheetBundleOptions(this.options, loadCache, { + [entry]: data, + }); + if (externalId) { + buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [`${namespace};${entry}`]; + } + + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + buildOptions.plugins.push({ + name: 'angular-component-styles', + setup(build) { + build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { + if (args.kind !== 'entry-point') { + return null; + } + + return { + path: entry, + namespace, + }; + }); + build.onLoad({ filter: /^css;/, namespace }, () => { + return { + contents: data, + loader: 'css', + resolveDir: path.dirname(filename), + }; + }); + }, + }); + + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); + }); + + const result = await bundlerContext.bundle(); + if (result.errors || result.warnings.length > 0) { + if (this.incremental && result.errors) { + bundlerContext.watchFiles.add( + path.isAbsolute(filename) ? filename : path.join(this.options.workspaceRoot, filename), + ); + } + for (const message of [...(result.errors ?? []), ...result.warnings]) { + for (const item of [message, ...(message.notes ?? [])]) { + if (item.location?.file?.startsWith('angular:styles/component:')) { + item.location.file = filename; + } + } + } + } + + // Extract the result of the bundling from the output files + return this.extractResult(result, bundlerContext.watchFiles, !!externalId, false); + } + + /** + * Invalidates both file and inline based component style bundling state for a set of modified files. + * @param files The group of files that have been modified + * @returns An array of file based stylesheet entries if any were invalidated; otherwise, undefined. + */ + invalidate(files: Iterable | ReadonlySet): string[] | undefined { + if (!this.incremental) { + return; + } + + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = path.normalize(file); + normalizedFiles.add(normalized); + if (!path.isAbsolute(normalized)) { + normalizedFiles.add(path.normalize(path.join(this.options.workspaceRoot, normalized))); + } + } + + let entries: string[] | undefined; + + for (const [entry, bundler] of this.#fileContexts.entries()) { + if (bundler.invalidate(normalizedFiles)) { + entries ??= []; + entries.push(entry); + } + } + for (const [entry, bundler] of this.#inlineContexts.entries()) { + // Entry is format: [language, id, filename].join(';') + const firstSemi = entry.indexOf(';'); + const secondSemi = firstSemi !== -1 ? entry.indexOf(';', firstSemi + 1) : -1; + const filename = secondSemi !== -1 ? entry.slice(secondSemi + 1) : ''; + if (filename && normalizedFiles.has(path.normalize(filename))) { + this.#inlineContexts.delete(entry); + void bundler.dispose().catch(() => {}); + } else { + bundler.invalidate(normalizedFiles); + } + } + + return entries; + } + + collectReferencedFiles(): string[] { + const files: string[] = []; + for (const context of this.#fileContexts.values()) { + files.push(...context.watchFiles); + } + for (const context of this.#inlineContexts.values()) { + files.push(...context.watchFiles); + } + + return files; + } + + async dispose(): Promise { + const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()]; + this.#fileContexts.clear(); + this.#inlineContexts.clear(); + this.#loadCache.clear(); + + await Promise.allSettled(contexts.map((context) => context.dispose())); + } + + private extractResult( + result: BundleContextResult, + referencedFiles: Set | undefined, + external: boolean, + direct: boolean, + ): ComponentStylesheetResult { + let contents = ''; + const outputFiles: BuildOutputFile[] = []; + + const { errors, warnings } = result; + if (errors) { + return { errors, warnings, referencedFiles, contents: '' }; + } + + for (const outputFile of result.outputFiles) { + const filename = path.basename(outputFile.path); + + if (outputFile.type === BuildOutputFileType.Media || filename.endsWith('.css.map')) { + // The output files could also contain resources (images/fonts/etc.) that were referenced and the map files. + + // Clone the output file to avoid amending the original path which would causes problems during rebuild. + const clonedOutputFile = outputFile.clone(); + + // Needed for Bazel as otherwise the files will not be written in the correct place, + // this is because esbuild will resolve the output file from the outdir which is currently set to `workspaceRoot` twice, + // once in the stylesheet and the other in the application code bundler. + // Ex: `../../../../../app.component.css.map`. + if (!direct) { + clonedOutputFile.path = path.join(this.options.workspaceRoot, outputFile.path); + } + + outputFiles.push(clonedOutputFile); + } else if (filename.endsWith('.css')) { + if (external) { + const clonedOutputFile = outputFile.clone(); + if (!direct) { + clonedOutputFile.path = path.join(this.options.workspaceRoot, outputFile.path); + } + outputFiles.push(clonedOutputFile); + contents = path.posix.join(this.options.publicPath ?? '', filename); + } else { + contents = outputFile.text; + } + } else { + throw new Error( + `Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`, + ); + } + } + + // Clone metafile to prevent mutation of the cached result by downstream plugins + const metafile = { + inputs: { ...result.metafile.inputs }, + outputs: Object.fromEntries( + Object.entries(result.metafile.outputs).map(([key, output]) => { + const cloned = { ...output, ['ng-component']: true }; + delete cloned.entryPoint; + + return [key, cloned]; + }), + ), + }; + + return { + errors, + warnings, + contents, + outputFiles, + metafile, + referencedFiles, + externalImports: result.externalImports, + platform: result.platform, + initialFiles: new Map(), + }; + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts new file mode 100644 index 000000000000..0363f2f43ba9 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { initializeHash } from '../../../utils/hash'; +import { ComponentStylesheetBundler } from './component-stylesheets'; + +describe('ComponentStylesheetBundler', () => { + let tempRoot: string; + + beforeAll(async () => { + await initializeHash(); + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-component-stylesheets-')); + }); + + afterAll(async () => { + await rm(tempRoot, { force: true, recursive: true }); + }); + + it('uses the component filename instead of angular:styles/component virtual URI in bundleInline error locations', async () => { + const bundler = new ComponentStylesheetBundler( + { + workspaceRoot: tempRoot, + optimization: false, + inlineFonts: false, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + target: ['es2022'], + cacheOptions: { enabled: false, path: '', basePath: tempRoot }, + }, + 'css', + true, + ); + + const componentFile = join(tempRoot, 'src', 'lib', 'lib.component.ts'); + + try { + const result = await bundler.bundleInline( + '.icon { background: url("./missing.png"); }', + componentFile, + ); + + assert(result.errors, 'Expected bundleInline to report a resolution error'); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].location?.file).toBe(componentFile); + expect(result.referencedFiles).toContain(componentFile); + } finally { + await bundler.dispose(); + } + }); + + it('uses the component filename instead of angular:styles/component virtual URI in bundleInline warning locations', async () => { + const bundler = new ComponentStylesheetBundler( + { + workspaceRoot: tempRoot, + optimization: false, + inlineFonts: false, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + target: ['es2022'], + cacheOptions: { enabled: false, path: '', basePath: tempRoot }, + }, + 'css', + false, + ); + + const componentFile = join(tempRoot, 'src', 'lib', 'lib.component.ts'); + + try { + const result = await bundler.bundleInline('.box { color: red', componentFile); + + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0].location?.file).toBe(componentFile); + } finally { + await bundler.dispose(); + } + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/file-reference-tracker.ts b/packages/angular/build/src/tools/esbuild/angular/file-reference-tracker.ts new file mode 100644 index 000000000000..7d420679e67a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/file-reference-tracker.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { normalize } from 'node:path'; + +export class FileReferenceTracker { + #referencingFiles = new Map>(); + + get referencedFiles() { + return this.#referencingFiles.keys(); + } + + add(containingFile: string, referencedFiles: Iterable): void { + const normalizedContainingFile = normalize(containingFile); + for (const file of referencedFiles) { + const normalizedReferencedFile = normalize(file); + if (normalizedReferencedFile === normalizedContainingFile) { + // Containing file is already known to the AOT compiler + continue; + } + + const referencing = this.#referencingFiles.get(normalizedReferencedFile); + if (referencing === undefined) { + this.#referencingFiles.set(normalizedReferencedFile, new Set([normalizedContainingFile])); + } else { + referencing.add(normalizedContainingFile); + } + } + } + + /** + * + * @param changed The set of changed files. + */ + update(changed: Set): Set { + // Lazily initialized to avoid unneeded copying if there are no additions to return + let allChangedFiles: Set | undefined; + + // Add referencing files to fully notify the AOT compiler of required component updates + for (const modifiedFile of changed) { + const normalizedModifiedFile = normalize(modifiedFile); + const referencing = this.#referencingFiles.get(normalizedModifiedFile); + if (referencing) { + allChangedFiles ??= new Set(changed); + for (const referencingFile of referencing) { + allChangedFiles.add(referencingFile); + } + // Cleanup the stale record which will be updated by new resource transforms + this.#referencingFiles.delete(normalizedModifiedFile); + } + } + + return allChangedFiles ?? changed; + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.ts b/packages/angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.ts new file mode 100644 index 000000000000..6b99b92dd1fa --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile, OutputFile, PluginBuild } from 'esbuild'; +import { readFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import { + JIT_NAMESPACE_REGEXP, + JIT_STYLE_NAMESPACE, + JIT_TEMPLATE_NAMESPACE, + parseJitUri, +} from '../../angular/uri'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; +import { ComponentStylesheetBundler } from './component-stylesheets'; + +/** + * Loads/extracts the contents from a load callback Angular JIT entry. + * An Angular JIT entry represents either a file path for a component resource or base64 + * encoded data for an inline component resource. + * @param entry The value that represents content to load. + * @param root The absolute path for the root of the build (typically the workspace root). + * @param skipRead If true, do not attempt to read the file; if false, read file content from disk. + * This option has no effect if the entry does not originate from a file. Defaults to false. + * @returns An object containing the absolute path of the contents and optionally the actual contents. + * For inline entries the contents will always be provided. + */ +async function loadEntry( + entry: string, + root: string, + skipRead?: boolean, +): Promise<{ path: string; contents?: string }> { + if (entry.startsWith('inline:')) { + const [importer, data] = entry.slice(7).split(';', 2); + + return { + path: join(root, importer), + contents: Buffer.from(data, 'base64').toString(), + }; + } + + const path = join(root, entry); + + return { + path, + contents: skipRead ? undefined : await readFile(path, 'utf-8'), + }; +} + +/** + * Sets up esbuild resolve and load callbacks to support Angular JIT mode processing + * for both Component stylesheets and templates. These callbacks work alongside the JIT + * resource TypeScript transformer to convert and then bundle Component resources as + * static imports. + * @param build An esbuild {@link PluginBuild} instance used to add callbacks. + * @param styleOptions The options to use when bundling stylesheets. + * @param additionalResultFiles A Map where stylesheet resources will be added. + */ +export function setupJitPluginCallbacks( + build: PluginBuild, + stylesheetBundler: ComponentStylesheetBundler, + additionalResultFiles: Map, + loadCache?: LoadResultCache, +): void { + const root = build.initialOptions.absWorkingDir ?? ''; + + // Add a resolve callback to capture and parse any JIT URIs that were added by the + // JIT resource TypeScript transformer. + // Resources originating from a file are resolved as relative from the containing file (importer). + build.onResolve({ filter: JIT_NAMESPACE_REGEXP }, (args) => { + const parsed = parseJitUri(args.path); + if (!parsed) { + return undefined; + } + + const { namespace, origin, specifier } = parsed; + + if (origin === 'file') { + return { + // Use a relative path to prevent fully resolved paths in the metafile (JSON stats file). + // This is only necessary for custom namespaces. esbuild will handle the file namespace. + path: relative(root, join(dirname(args.importer), specifier)), + namespace, + }; + } else { + // Inline data may need the importer to resolve imports/references within the content + const importer = relative(root, args.importer); + + return { + path: `inline:${importer};${specifier}`, + namespace, + }; + } + }); + + // Add a load callback to handle Component stylesheets (both inline and external) + build.onLoad( + { filter: /./, namespace: JIT_STYLE_NAMESPACE }, + createCachedLoad(loadCache, async (args) => { + // skipRead is used here because the stylesheet bundling will read a file stylesheet + // directly either via a preprocessor or esbuild itself. + const entry = await loadEntry(args.path, root, true /* skipRead */); + + let stylesheetResult; + + // Stylesheet contents only exist for internal stylesheets + if (entry.contents === undefined) { + stylesheetResult = await stylesheetBundler.bundleFile(entry.path); + } else { + stylesheetResult = await stylesheetBundler.bundleInline(entry.contents, entry.path); + } + + const { errors, warnings, referencedFiles } = stylesheetResult; + if (stylesheetResult.errors) { + return { + errors, + warnings, + watchFiles: referencedFiles && [...referencedFiles], + }; + } + + const { contents, outputFiles, metafile } = stylesheetResult; + + additionalResultFiles.set(entry.path, { outputFiles, metafile }); + + return { + errors, + warnings, + contents, + loader: 'text', + watchFiles: referencedFiles && [...referencedFiles], + }; + }), + ); + + // Add a load callback to handle Component templates + // NOTE: While this callback supports both inline and external templates, the transformer + // currently only supports generating URIs for external templates. + build.onLoad( + { filter: /./, namespace: JIT_TEMPLATE_NAMESPACE }, + createCachedLoad(loadCache, async (args) => { + const { contents, path } = await loadEntry(args.path, root); + + return { + contents, + loader: 'text', + watchFiles: [path], + }; + }), + ); +} diff --git a/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts b/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts new file mode 100644 index 000000000000..b7cd376d560e --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { join, relative, sep } from 'node:path'; + +const bazelBinDirectory = process.env['BAZEL_BINDIR']; +const bazelExecRoot = process.env['JS_BINARY__EXECROOT']; +const execRootMarker = `${sep}execroot${sep}`; + +/** + * Sandboxed actions run in a copy of the execroot whose absolute path differs from + * `JS_BINARY__EXECROOT`, so the effective execroot is derived from the working directory. + */ +function effectiveExecRoot(): string | undefined { + const cwd = process.cwd(); + const markerIndex = cwd.indexOf(execRootMarker); + if (markerIndex === -1) { + return bazelExecRoot; + } + + const workspaceEnd = cwd.indexOf(sep, markerIndex + execRootMarker.length); + + return workspaceEnd === -1 ? cwd : cwd.slice(0, workspaceEnd); +} + +export function rewriteForBazel(path: string): string { + if (!bazelBinDirectory || !bazelExecRoot) { + return path; + } + + const execRoot = effectiveExecRoot() ?? bazelExecRoot; + + const fromExecRoot = relative(execRoot, path); + if (!fromExecRoot.startsWith('..')) { + return path; + } + + const fromBinDirectory = relative(bazelBinDirectory, path); + if (!fromBinDirectory.startsWith('..')) { + return join(execRoot, fromBinDirectory); + } + + // A path that realpath resolved out of a symlink-staged sandbox into the + // unsandboxed execroot: re-anchor its execroot-relative tail. + const markerIndex = path.indexOf(execRootMarker); + if (markerIndex !== -1) { + const tail = path.slice(markerIndex + execRootMarker.length); + const workspaceEnd = tail.indexOf(sep); + if (workspaceEnd !== -1) { + return join(execRoot, tail.slice(workspaceEnd + 1)); + } + } + + return path; +} diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts new file mode 100644 index 000000000000..136fbb651ba2 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import * as path from 'node:path'; +import { MemoryLoadResultCache } from '../load-result-cache'; + +export class SourceFileCache { + readonly modifiedFiles = new Set(); + readonly typeScriptFileCache = new Map(); + readonly loadResultCache = new MemoryLoadResultCache(); + + referencedFiles?: readonly string[]; + + constructor(readonly persistentCachePath?: string) {} + + /** + * Releases all cached content. The cached data is only needed for incremental + * rebuilds and can include the emitted contents of every TypeScript file in the + * program. The cache is repopulated if a build is performed after this is called. + */ + clear(): void { + this.modifiedFiles.clear(); + this.typeScriptFileCache.clear(); + this.loadResultCache.clear(); + this.referencedFiles = undefined; + } + + invalidate(files: Iterable): boolean { + if (files !== this.modifiedFiles) { + this.modifiedFiles.clear(); + } + + const extraWatchFiles = new Set(this.referencedFiles?.map(path.normalize)); + + let invalid = false; + for (let file of files) { + file = path.normalize(file); + invalid = this.loadResultCache.invalidate(file) || invalid; + invalid = extraWatchFiles.has(file) || invalid; + this.modifiedFiles.add(file); + } + + return invalid; + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache_spec.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache_spec.ts new file mode 100644 index 000000000000..1a3b2c402618 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache_spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { SourceFileCache } from './source-file-cache'; + +describe('SourceFileCache', () => { + let cache: SourceFileCache; + + beforeEach(() => { + cache = new SourceFileCache(); + }); + + it('should clean up all caches on clear()', async () => { + cache.typeScriptFileCache.set('/test/app.component.ts', 'console.log("test");'); + cache.modifiedFiles.add('/test/app.component.ts'); + cache.referencedFiles = ['/test/app.component.ts']; + await cache.loadResultCache.put('file:/test/app.component.ts', { + contents: 'test', + loader: 'js', + }); + + cache.clear(); + + expect(cache.typeScriptFileCache.size).toBe(0); + expect(cache.modifiedFiles.size).toBe(0); + expect(cache.referencedFiles).toBeUndefined(); + expect(cache.loadResultCache.get('file:/test/app.component.ts')).toBeUndefined(); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts new file mode 100644 index 000000000000..6cd2b09f5a67 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -0,0 +1,790 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuildOptions, Plugin } from 'esbuild'; +import assert from 'node:assert'; +import { extname, relative } from 'node:path'; +import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { Platform } from '../../builders/application/schema'; +import { allowMangle } from '../../utils/environment-options'; +import { calculateHash } from '../../utils/hash'; +import { toPosixPath } from '../../utils/path'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + SERVER_APP_MANIFEST_FILENAME, + SERVER_GENERATED_EXTERNALS, +} from '../../utils/server-rendering/manifest'; +import { AngularCompilationContext } from './angular/compilation-state'; +import { createCompilerPlugin } from './angular/compiler-plugin'; +import { ComponentStylesheetBundler } from './angular/component-stylesheets'; +import { SourceFileCache } from './angular/source-file-cache'; +import { createAngularLocalizeInitWarningPlugin } from './angular-localize-init-warning-plugin'; +import { BundlerOptionsFactory } from './bundler-context'; +import { createCompilerPluginOptions } from './compiler-plugin-options'; +import { createExternalPackagesPlugin } from './external-packages-plugin'; +import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; +import type { LoadResultCache } from './load-result-cache'; +import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin'; +import { + createPlatformServerDominoExposePlugin, + createPlatformServerDominoRedirectPlugin, +} from './platform-server-domino-plugin'; +import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; +import { createServerBundleMetadata } from './server-bundle-metadata-plugin'; +import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; +import { getFeatureSupport, isZonelessApp } from './utils'; +import { createVirtualModulePlugin } from './virtual-module-plugin'; +import { createWasmPlugin } from './wasm-plugin'; + +export function createBrowserCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, + templateUpdates: Map | undefined, +): BundlerOptionsFactory { + return (loadCache) => { + const { entryPoints, outputNames, polyfills } = options; + const zoneless = isZonelessApp(polyfills); + + const pluginOptions = createCompilerPluginOptions( + options, + sourceFileCache, + loadCache, + templateUpdates, + ); + + const buildOptions: BuildOptions = { + ...getEsBuildCommonOptions(options), + platform: 'browser', + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'], + entryNames: outputNames.bundles, + entryPoints, + target, + supported: getFeatureSupport(zoneless), + }; + + if (options.disableCodeSplitting) { + // Splitting emits shared chunks that are read across chunk boundaries as live ESM bindings, + // which the unit-test runners' module loading does not reliably preserve. + buildOptions.splitting = false; + // In unit test builds, package.json "sideEffects": false annotations can cause esbuild + // to incorrectly elide statically-referenced barrel module bodies across multiple entry points. + buildOptions.ignoreAnnotations = true; + } + + buildOptions.plugins ??= []; + buildOptions.plugins.push( + createWasmPlugin({ allowAsync: zoneless, cache: loadCache }), + createAngularLocalizeInitWarningPlugin(), + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet bundler + stylesheetBundler, + ), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; + }; +} + +export function createBrowserPolyfillBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, +): BuildOptions | BundlerOptionsFactory | undefined { + const namespace = 'angular:polyfills'; + const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( + options, + namespace, + true, + sourceFileCache.loadResultCache, + ); + if (!polyfillBundleOptions) { + return; + } + + const { outputNames, polyfills } = options; + const hasTypeScriptEntries = polyfills?.some((entry) => /\.[cm]?tsx?$/.test(entry)); + + const buildOptions: BuildOptions = { + ...polyfillBundleOptions, + platform: 'browser', + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'], + entryNames: outputNames.bundles, + target, + entryPoints: { + 'polyfills': namespace, + }, + }; + + // Only add the Angular TypeScript compiler if TypeScript files are provided in the polyfills + if (hasTypeScriptEntries) { + buildOptions.plugins ??= []; + const pluginOptions = createCompilerPluginOptions( + options, + sourceFileCache, + sourceFileCache.loadResultCache, + ); + buildOptions.plugins.push( + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet options are unused for polyfills but required by the plugin + stylesheetBundler, + ), + ); + } + + // Use an options factory to allow fully incremental bundling when no TypeScript files are present. + // The TypeScript compilation is not currently integrated into the bundler invalidation so + // cannot be used with fully incremental bundling yet. + return hasTypeScriptEntries ? buildOptions : () => buildOptions; +} + +export function createServerPolyfillBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + loadResultCache: LoadResultCache | undefined, +): BundlerOptionsFactory | undefined { + const serverPolyfills: string[] = []; + const polyfillsFromConfig = new Set(options.polyfills); + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + if (!isZonelessApp(options.polyfills)) { + serverPolyfills.push(isNodePlatform ? 'zone.js/node' : 'zone.js'); + } + + if ( + polyfillsFromConfig.has('@angular/localize') || + polyfillsFromConfig.has('@angular/localize/init') + ) { + serverPolyfills.push('@angular/localize/init'); + } + + serverPolyfills.push('@angular/platform-server/init'); + + const namespace = 'angular:polyfills-server'; + const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( + { + ...options, + polyfills: serverPolyfills, + }, + namespace, + false, + loadResultCache, + ); + + if (!polyfillBundleOptions) { + return; + } + + const jsBanner: string[] = []; + if (polyfillBundleOptions.external?.length) { + jsBanner.push(`globalThis['ngServerMode'] = true;`); + } + + if (isNodePlatform) { + // Note: Needed as esbuild does not provide require shims / proxy from ESModules. + // See: https://github.com/evanw/esbuild/issues/1921. + // Use an alias to avoid colliding with any `createRequire` import that may + // already exist in the bundled user code. ESBuild processes banner content + // as raw text outside of its module graph, so it cannot deduplicate or + // rename banner imports the way it does for user imports. Without the alias, + // a duplicate `import { createRequire }` binding would cause a runtime error. + jsBanner.push( + `import { createRequire as __ngCreateRequire } from 'node:module';`, + `globalThis['require'] ??= __ngCreateRequire(import.meta.url);`, + ); + } + + const buildOptions: BuildOptions = { + ...polyfillBundleOptions, + platform: isNodePlatform ? 'node' : 'neutral', + outExtension: { '.js': '.mjs' }, + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'module', 'main'], + entryNames: '[name]', + banner: { + js: jsBanner.join('\n'), + }, + target, + entryPoints: { + 'polyfills.server': namespace, + }, + }; + + buildOptions.plugins ??= []; + buildOptions.plugins.push(createServerBundleMetadata(), createPlatformServerDominoExposePlugin()); + + return () => buildOptions; +} + +export function createServerMainCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, +): BundlerOptionsFactory { + const { + serverEntryPoint: mainServerEntryPoint, + workspaceRoot, + outputMode, + externalPackages, + ssrOptions, + polyfills, + } = options; + + assert( + mainServerEntryPoint, + 'createServerCodeBundleOptions should not be called without a defined serverEntryPoint.', + ); + + return (loadResultCache) => { + const pluginOptions = createCompilerPluginOptions(options, sourceFileCache, loadResultCache); + const mainServerNamespace = 'angular:main-server'; + const mainServerInjectManifestNamespace = 'angular:main-server-inject-manifest'; + const zoneless = isZonelessApp(polyfills); + const entryPoints: Record = { + 'main.server': mainServerNamespace, + }; + + const ssrEntryPoint = ssrOptions?.entry; + const isOldBehaviour = !outputMode; + + if (ssrEntryPoint && isOldBehaviour) { + // Old behavior: 'server.ts' was bundled together with the SSR (Server-Side Rendering) code. + // This approach combined server-side logic and rendering into a single bundle. + entryPoints['server'] = ssrEntryPoint; + } + + const buildOptions: BuildOptions = { + ...getEsBuildServerCommonOptions(options), + target, + banner: { + js: `import './polyfills.server.mjs';`, + }, + entryPoints, + supported: getFeatureSupport(zoneless), + }; + + buildOptions.plugins ??= []; + buildOptions.plugins.push( + createWasmPlugin({ allowAsync: zoneless, cache: loadResultCache }), + createAngularLocalizeInitWarningPlugin(), + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet bundler + stylesheetBundler, + ), + ); + + if (!externalPackages) { + buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); + } + + // Mark manifest and polyfills file as external as these are generated by a different bundle step. + (buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS); + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + if (!isNodePlatform) { + // `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client. + // Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms. + // Note: The framework already issues a warning when using XHR with SSR. + buildOptions.external.push('xhr2'); + } + + buildOptions.plugins.push( + createServerBundleMetadata(), + createPlatformServerDominoRedirectPlugin(), + createVirtualModulePlugin({ + namespace: mainServerInjectManifestNamespace, + cache: loadResultCache, + entryPointOnly: false, + loadContent: async () => { + const contents: string[] = [ + // Configure `@angular/ssr` manifest. + `import manifest from './${SERVER_APP_MANIFEST_FILENAME}';`, + `import { ɵsetAngularAppManifest } from '@angular/ssr';`, + `ɵsetAngularAppManifest(manifest);`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: mainServerNamespace, + cache: loadResultCache, + loadContent: async () => { + const mainServerEntryPointJsImport = entryFileToWorkspaceRelative( + workspaceRoot, + mainServerEntryPoint, + ); + + const contents: string[] = [ + // Inject manifest + `import '${mainServerInjectManifestNamespace}';`, + + // Add @angular/ssr exports + `export { + ɵdestroyAngularServerApp, + ɵextractRoutesAndCreateRouteTree, + ɵgetOrCreateAngularServerApp, + } from '@angular/ssr';`, + + // Need for HMR + `export { ɵresetCompiledComponents } from '@angular/core';`, + + // Re-export all symbols including default export from 'main.server.ts' + `export { default } from ${JSON.stringify(mainServerEntryPointJsImport)};`, + `export * from ${JSON.stringify(mainServerEntryPointJsImport)};`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; + }; +} + +export function createSsrEntryCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, +): BundlerOptionsFactory { + const { workspaceRoot, ssrOptions, externalPackages } = options; + const serverEntryPoint = ssrOptions?.entry; + assert( + serverEntryPoint, + 'createSsrEntryCodeBundleOptions should not be called without a defined serverEntryPoint.', + ); + + return (loadResultCache) => { + const pluginOptions = createCompilerPluginOptions(options, sourceFileCache, loadResultCache); + const ssrEntryNamespace = 'angular:ssr-entry'; + const ssrInjectManifestNamespace = 'angular:ssr-entry-inject-manifest'; + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + const jsBanner: string[] = []; + if (options.externalDependencies?.length) { + jsBanner.push(`globalThis['ngServerMode'] = true;`); + } + + if (isNodePlatform) { + // Note: Needed as esbuild does not provide require shims / proxy from ESModules. + // See: https://github.com/evanw/esbuild/issues/1921. + // Use an alias to avoid colliding with any `createRequire` import that may + // already exist in the bundled user code. ESBuild processes banner content + // as raw text outside of its module graph, so it cannot deduplicate or + // rename banner imports the way it does for user imports. Without the alias, + // a duplicate `import { createRequire }` binding would cause a runtime error. + jsBanner.push( + `import { createRequire as __ngCreateRequire } from 'node:module';`, + `globalThis['require'] ??= __ngCreateRequire(import.meta.url);`, + ); + } + + const buildOptions: BuildOptions = { + ...getEsBuildServerCommonOptions(options), + target, + banner: { + js: jsBanner.join('\n'), + }, + entryPoints: { + 'server': ssrEntryNamespace, + }, + supported: getFeatureSupport(true), + }; + + buildOptions.plugins ??= []; + buildOptions.plugins.push( + createAngularLocalizeInitWarningPlugin(), + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet bundler + stylesheetBundler, + ), + ); + + if (!externalPackages) { + buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); + } + + // Mark manifest file as external. As this will be generated later on. + (buildOptions.external ??= []).push('*/main.server.mjs', ...SERVER_GENERATED_EXTERNALS); + + if (!isNodePlatform) { + // `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client. + // Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms. + // Note: The framework already issues a warning when using XHR with SSR. + buildOptions.external.push('xhr2'); + } + + buildOptions.plugins.push( + createServerBundleMetadata({ ssrEntryBundle: true }), + createVirtualModulePlugin({ + namespace: ssrInjectManifestNamespace, + cache: loadResultCache, + entryPointOnly: false, + loadContent: () => { + const contents: string[] = [ + // Configure `@angular/ssr` app engine manifest. + `import manifest from './${SERVER_APP_ENGINE_MANIFEST_FILENAME}';`, + `import { ɵsetAngularAppEngineManifest } from '@angular/ssr';`, + `ɵsetAngularAppEngineManifest(manifest);`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: ssrEntryNamespace, + cache: loadResultCache, + loadContent: () => { + const serverEntryPointJsImport = entryFileToWorkspaceRelative( + workspaceRoot, + serverEntryPoint, + ); + const contents: string[] = [ + // Configure `@angular/ssr` app engine manifest. + `import '${ssrInjectManifestNamespace}';`, + + // Re-export all symbols including default export + `import * as server from ${JSON.stringify(serverEntryPointJsImport)};`, + `export * from ${JSON.stringify(serverEntryPointJsImport)};`, + // The below is needed to avoid + // `Import "default" will always be undefined because there is no matching export` warning when no default is present. + `const defaultExportName = 'default';`, + `export default server[defaultExportName];`, + + // Add @angular/ssr exports + `export { AngularAppEngine } from '@angular/ssr';`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; + }; +} + +function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + const commonOptions = getEsBuildCommonOptions(options); + commonOptions.define ??= {}; + commonOptions.define['ngServerMode'] = 'true'; + + return { + ...commonOptions, + platform: isNodePlatform ? 'node' : 'neutral', + outExtension: { '.js': '.mjs' }, + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'module', 'main'], + entryNames: '[name]', + }; +} + +function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { + const { + workspaceRoot, + outExtension, + optimizationOptions, + sourcemapOptions, + tsconfig, + externalDependencies, + outputNames, + preserveSymlinks, + jit, + loaderExtensions, + jsonLogs, + i18nOptions, + customConditions, + frameworkVersion, + } = options; + + // Ensure unique hashes for i18n translation changes when using post-process inlining. + // This hash value is added as a footer to each file and ensures that the output file names (with hashes) + // change when translation files have changed. If this is not done the post processed files may have + // different content but would retain identical production file names which would lead to browser caching problems. + let footer; + if (i18nOptions.shouldInline) { + // Update file hashes to include translation file content + const i18nHash = Object.values(i18nOptions.locales).reduce( + (data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'), + '', + ); + + footer = { js: `/**i18n:${calculateHash(i18nHash)}*/` }; + } + + // Core conditions that are always included + const conditions = [ + // Required to support rxjs 7.x which will use es5 code if this condition is not present + 'es2015', + 'es2020', + ]; + // The pre-linked code is not used with JIT for two reasons: + // 1) The pre-linked code may not have the metadata included that is required for JIT + // 2) The CLI is otherwise setup to use runtime linking for JIT to match the application template compilation + if (!jit) { + // The pre-linked package condition is based on the framework version. + // Currently this is specific to each patch version of the framework. + conditions.push('angular:linked-' + frameworkVersion); + } + + // Append custom conditions if present + if (customConditions) { + conditions.push(...customConditions); + } else { + // Include default conditions + conditions.push('module', optimizationOptions.scripts ? 'production' : 'development'); + } + + const plugins: Plugin[] = [ + createLoaderImportAttributePlugin(), + createSourcemapIgnorelistPlugin(), + ]; + + let packages: BuildOptions['packages'] = 'bundle'; + if (options.externalPackages) { + // Package files affected by a customized loader should not be implicitly marked as external + if ( + options.loaderExtensions || + options.plugins || + typeof options.externalPackages === 'object' + ) { + // Plugin must be added after custom plugins to ensure any added loader options are considered + plugins.push( + createExternalPackagesPlugin( + options.externalPackages !== true ? options.externalPackages : undefined, + ), + ); + + packages = 'bundle'; + } else { + // Safe to use the packages external option directly + packages = 'external'; + } + } + + return { + absWorkingDir: workspaceRoot, + format: 'esm', + bundle: true, + packages, + assetNames: outputNames.media, + conditions, + resolveExtensions: ['.ts', '.tsx', '.mjs', '.js', '.cjs'], + metafile: true, + legalComments: options.extractLicenses + ? 'none' + : optimizationOptions.scripts + ? 'eof' + : 'inline', + logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', + minifyIdentifiers: optimizationOptions.scripts && allowMangle, + minifySyntax: optimizationOptions.scripts, + minifyWhitespace: optimizationOptions.scripts, + pure: ['forwardRef'], + outdir: workspaceRoot, + outExtension: outExtension ? { '.js': `.${outExtension}` } : undefined, + sourcemap: sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + sourcesContent: sourcemapOptions.sourcesContent, + splitting: true, + chunkNames: options.namedChunks ? '[name]-[hash]' : 'chunk-[hash]', + tsconfig, + external: externalDependencies ? [...externalDependencies] : undefined, + write: false, + preserveSymlinks, + define: { + ...options.define, + // Only set to false when script optimizations are enabled. It should not be set to true because + // Angular turns `ngDevMode` into an object for development debugging purposes when not defined + // which a constant true value would break. + ...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined), + 'ngJitMode': jit ? 'true' : 'false', + 'ngServerMode': 'false', + 'ngHmrMode': options.templateUpdates ? 'true' : 'false', + }, + loader: loaderExtensions, + footer, + plugins, + }; +} + +function getEsBuildCommonPolyfillsOptions( + options: NormalizedApplicationBuildOptions, + namespace: string, + tryToResolvePolyfillsAsRelative: boolean, + loadResultCache: LoadResultCache | undefined, +): BuildOptions | undefined { + const { jit, workspaceRoot, i18nOptions, externalPackages } = options; + + let polyfills = options.polyfills ? [...options.polyfills] : []; + + const buildOptions = getEsBuildCommonOptions({ + ...options, + // If any polyfills are local files, disable external packages for the polyfills build. + // This ensures that local files are properly bundled. + externalPackages: polyfills.some(isLocalFile) ? false : externalPackages, + }); + buildOptions.splitting = false; + buildOptions.plugins ??= []; + + // Angular JIT mode requires the runtime compiler + if (jit) { + polyfills.unshift('@angular/compiler'); + } + + // Add Angular's global locale data if i18n options are present. + let needLocaleDataPlugin = false; + if (i18nOptions.shouldInline) { + // Remove localize polyfill when i18n inline transformation have been applied to all the packages. + polyfills = polyfills.filter((path) => !path.startsWith('@angular/localize')); + } else if (i18nOptions.hasDefinedSourceLocale) { + // When not inlining and a source locale is present, use the source locale data directly + polyfills.unshift(`angular:locale/data:${i18nOptions.sourceLocale}`); + needLocaleDataPlugin = true; + } + if (needLocaleDataPlugin) { + buildOptions.plugins.unshift(createAngularLocaleDataPlugin()); + } + + if (polyfills.length === 0 && !i18nOptions.shouldInline && !i18nOptions.hasDefinedSourceLocale) { + return; + } + + buildOptions.plugins.push( + createVirtualModulePlugin({ + namespace, + cache: loadResultCache, + loadContent: async (_, build) => { + let polyfillPaths = polyfills; + + if (tryToResolvePolyfillsAsRelative) { + polyfillPaths = await Promise.all( + polyfills.map(async (path) => { + if (path.startsWith('zone.js') || !extname(path)) { + return path; + } + + const potentialPathRelative = './' + path; + const result = await build.resolve(potentialPathRelative, { + kind: 'import-statement', + resolveDir: workspaceRoot, + }); + + return result.path ? potentialPathRelative : path; + }), + ); + } + + // Generate module contents with an import statement per defined polyfill + let contents = polyfillPaths + .map((file) => `import ${JSON.stringify(toPosixPath(file))};`) + .join('\n'); + + // The below should be done after loading `$localize` as otherwise the locale will be overridden. + if (i18nOptions.shouldInline) { + // When inlining, a placeholder is used to allow the post-processing step to inject the $localize locale identifier. + contents += '(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n'; + } else if (i18nOptions.hasDefinedSourceLocale) { + // If not inlining translations and source locale is defined, inject the locale specifier. + contents += `(globalThis.$localize ??= {}).locale = "${i18nOptions.sourceLocale}";\n`; + } + + return { + contents, + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + return buildOptions; +} + +function entryFileToWorkspaceRelative(workspaceRoot: string, entryFile: string): string { + return './' + toPosixPath(relative(workspaceRoot, entryFile).replace(/\.[mc]?ts$/, '')); +} + +/** + * Determines if a polyfill path is a local file. + * A local file is defined as a path starting with a `.` or having a TypeScript/JavaScript extension. + * `zone.js` and its subpaths are specifically excluded and treated as packages. + * @param path The polyfill path to check. + * @returns true if the path is a local file; false otherwise. + */ +function isLocalFile(path: string): boolean { + if (path.startsWith('zone.js')) { + return false; + } + + return path[0] === '.' || /\.[mc]?[jt]sx?$/.test(path); +} diff --git a/packages/angular/build/src/tools/esbuild/budget-stats.ts b/packages/angular/build/src/tools/esbuild/budget-stats.ts new file mode 100644 index 000000000000..a14d9a24f1b6 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/budget-stats.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile } from 'esbuild'; +import type { BudgetStats } from '../../utils/bundle-calculator'; +import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files'; +import { getChunkNameFromMetafile } from './utils'; + +/** + * Generates a bundle budget calculator compatible stats object that provides + * the necessary information for the Webpack-based bundle budget code to + * interoperate with the esbuild-based builders. + * @param metafile The esbuild metafile of a build to use. + * @param initialFiles The records of all initial files of a build. + * @returns A bundle budget compatible stats object. + */ +export function generateBudgetStats( + metafile: Metafile, + outputFiles: BuildOutputFile[], + initialFiles: Map, +): BudgetStats { + const stats: Required = { + chunks: [], + assets: [], + }; + + for (const { path: file, size, type } of outputFiles) { + // Exclude server bundles + if (type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot) { + continue; + } + + if (!file.endsWith('.js') && !file.endsWith('.css')) { + continue; + } + + const initialRecord = initialFiles.get(file); + const name = initialRecord?.name ?? getChunkNameFromMetafile(metafile, file); + + stats.chunks.push({ + files: [file], + initial: !!initialRecord, + names: name ? [name] : undefined, + }); + + stats.assets.push({ + name: file, + size, + }); + } + + // Add component styles from metafile + // TODO: Provide this information directly from the AOT compiler + for (const [file, entry] of Object.entries(metafile.outputs)) { + if (!file.endsWith('.css')) { + continue; + } + // 'ng-component' is set by the angular plugin's component stylesheet bundler + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const componentStyle: boolean = (entry as any)['ng-component']; + if (!componentStyle) { + continue; + } + + stats.assets.push({ + // Component styles use the input file + name: Object.keys(entry.inputs)[0], + size: entry.bytes, + componentStyle, + }); + } + + return stats; +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts new file mode 100644 index 000000000000..106619be2360 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -0,0 +1,601 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + BuildContext, + BuildFailure, + BuildOptions, + BuildResult, + Message, + Metafile, + build, + context, +} from 'esbuild'; +import assert from 'node:assert'; +import { basename, extname, isAbsolute, join, normalize, relative } from 'node:path'; +import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest'; +import { + type BuildOutputFile, + BuildOutputFileType, + type InitialFileRecord, + convertOutputFile, + findTransitiveInitialFiles, +} from './bundler-files'; +import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; + +export type BundleContextResult = + | { errors: Message[]; warnings: Message[] } + | { + errors: undefined; + warnings: Message[]; + metafile: Metafile; + platform: 'browser' | 'server'; + outputFiles: BuildOutputFile[]; + initialFiles: Map; + externalImports: Set; + externalConfiguration?: string[]; + }; + +export type BundleMergedContextResult = + | { errors: Message[]; warnings: Message[] } + | { + errors: undefined; + warnings: Message[]; + metafiles: { + browser: Metafile; + server: Metafile; + }; + outputFiles: BuildOutputFile[]; + initialFiles: Map; + externalImports: { + server: Set; + browser: Set; + }; + externalConfiguration?: string[]; + }; + +export type BundlerOptionsFactory = ( + loadCache: LoadResultCache | undefined, +) => T; + +/** + * Determines if an unknown value is an esbuild BuildFailure error object thrown by esbuild. + * @param value A potential esbuild BuildFailure error object. + * @returns `true` if the object is determined to be a BuildFailure object; otherwise, `false`. + */ +function isEsBuildFailure(value: unknown): value is BuildFailure { + return !!value && typeof value === 'object' && 'errors' in value && 'warnings' in value; +} + +export class BundlerContext { + #esbuildContext?: BuildContext<{ metafile: true; write: false }>; + #esbuildOptions?: BuildOptions & { metafile: true; write: false }; + #esbuildResult?: BundleContextResult; + #activeBundlePromise?: Promise; + #disposed = false; + #optionsFactory: BundlerOptionsFactory; + #shouldCacheResult: boolean; + #loadCache?: LoadResultCache; + #loadCacheKeys?: Set; + #invalidationEpoch = 0; + readonly watchFiles = new Set(); + + constructor( + private workspaceRoot: string, + private incremental: boolean, + options: BuildOptions | BundlerOptionsFactory, + private useContext = incremental, + private initialFilter?: (initial: Readonly) => boolean, + sharedLoadCache?: LoadResultCache, + ) { + this.#loadCache = sharedLoadCache; + // To cache the results an option factory is needed to capture the full set of dependencies + this.#shouldCacheResult = incremental && typeof options === 'function'; + this.#optionsFactory = (...args) => { + const baseOptions = typeof options === 'function' ? options(...args) : options; + + return { + ...baseOptions, + metafile: true, + write: false, + }; + }; + } + + static bundleAll( + contexts: Iterable, + changedFiles?: Iterable | ReadonlySet, + ): Promise { + return Promise.all( + [...contexts].map((context) => { + if (changedFiles) { + context.invalidate(changedFiles); + } + + return context.bundle(); + }), + ); + } + + static mergeResults(results: BundleContextResult[]): BundleMergedContextResult { + let errors: Message[] | undefined; + const warnings: Message[] = []; + const browserMetafile: Metafile = { inputs: {}, outputs: {} }; + const serverMetafile: Metafile = { inputs: {}, outputs: {} }; + const initialFiles = new Map(); + const externalImportsBrowser = new Set(); + const externalImportsServer = new Set(); + + const outputFiles: BuildOutputFile[] = []; + let externalConfiguration: Set | undefined; + for (const result of results) { + warnings.push(...result.warnings); + if (result.errors) { + errors ??= []; + errors.push(...result.errors); + continue; + } + + const platformIsBrowser = result.platform === 'browser'; + + // Combine metafiles used for the bundle budgets and console output + if (result.metafile) { + const metafile = platformIsBrowser ? browserMetafile : serverMetafile; + Object.assign(metafile.inputs, result.metafile.inputs); + Object.assign(metafile.outputs, result.metafile.outputs); + } + + const externalImports = platformIsBrowser ? externalImportsBrowser : externalImportsServer; + result.externalImports?.forEach((value) => externalImports.add(value)); + + result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); + outputFiles.push(...result.outputFiles); + + if (result.externalConfiguration) { + externalConfiguration ??= new Set(); + for (const value of result.externalConfiguration) { + externalConfiguration.add(value); + } + } + } + + if (errors !== undefined) { + return { errors, warnings }; + } + + return { + errors, + warnings, + initialFiles, + outputFiles, + externalImports: { + browser: externalImportsBrowser, + server: externalImportsServer, + }, + metafiles: { + browser: browserMetafile, + server: serverMetafile, + }, + externalConfiguration: externalConfiguration ? [...externalConfiguration] : undefined, + }; + } + + /** + * Executes the esbuild build function and normalizes the build result in the event of a + * build failure that results in no output being generated. + * All builds use the `write` option with a value of `false` to allow for the output files + * build result array to be populated. + * + * @param force If true, always rebundle. + * @returns If output files are generated, the full esbuild BuildResult; if not, the + * warnings and errors for the attempted build. + */ + async bundle(force?: boolean): Promise { + // Return existing result if present + if (!force && this.#esbuildResult) { + return this.#esbuildResult; + } + + if (!force && this.#activeBundlePromise) { + return this.#activeBundlePromise; + } + + const bundleEpoch = this.#invalidationEpoch; + const bundlePromise = this.#performBundle().finally(() => { + if (this.#activeBundlePromise === bundlePromise) { + this.#activeBundlePromise = undefined; + } + }); + this.#activeBundlePromise = bundlePromise; + + const result = await bundlePromise; + if (this.#shouldCacheResult && bundleEpoch === this.#invalidationEpoch) { + this.#esbuildResult = result; + } + + return result; + } + + // eslint-disable-next-line max-lines-per-function + async #performBundle(): Promise { + this.#loadCacheKeys?.clear(); + + // Create esbuild options if not present + if (this.#esbuildOptions === undefined) { + if (this.incremental && !this.#loadCache) { + this.#loadCache = new MemoryLoadResultCache(); + } + const loadCache = this.#loadCache; + const scopedLoadCache: LoadResultCache | undefined = + this.incremental && loadCache + ? { + get: (path) => { + (this.#loadCacheKeys ??= new Set()).add(path); + + return loadCache.get(path); + }, + put: (path, result) => { + (this.#loadCacheKeys ??= new Set()).add(path); + + return loadCache.put(path, result); + }, + invalidate: (path) => loadCache.invalidate(path), + get watchFiles() { + return loadCache.watchFiles; + }, + } + : loadCache; + this.#esbuildOptions = this.#optionsFactory(scopedLoadCache); + } + + let result: BuildResult<{ metafile: true; write: false }>; + try { + if (this.#esbuildContext) { + // Rebuild using the existing incremental build context + result = await this.#esbuildContext.rebuild(); + } else if (this.useContext) { + // Create a build context and perform the build. + // Context creation does not perform a build. + const esbuildContext = await context(this.#esbuildOptions); + if (this.#disposed) { + await esbuildContext.dispose(); + throw new Error('BundlerContext was disposed during build.'); + } + this.#esbuildContext = esbuildContext; + result = await this.#esbuildContext.rebuild(); + } else { + // For non-incremental builds, perform a single build + if (this.#disposed) { + throw new Error('BundlerContext was disposed during build.'); + } + result = await build(this.#esbuildOptions); + if (this.#disposed) { + throw new Error('BundlerContext was disposed during build.'); + } + } + } catch (failure) { + // Build failures will throw an exception which contains errors/warnings + if (isEsBuildFailure(failure)) { + this.#addErrorsToWatch(failure); + await this.#addLoadCacheFilesToWatch(); + + return failure; + } else { + throw failure; + } + } + + // Update files that should be watched. + // While this should technically not be linked to incremental mode, incremental is only + // currently enabled with watch mode where watch files are needed. + if (this.incremental) { + this.watchFiles.clear(); + + // Add input files except virtual angular files which do not exist on disk + for (const input of Object.keys(result.metafile.inputs)) { + const isInternal = isInternalAngularFile(input) || isInternalBundlerFile(input); + + // Input file paths are always relative to the workspace root unless already absolute + const normalizedAbsoluteInput = isAbsolute(input) + ? normalize(input) + : join(this.workspaceRoot, input); + + if (!isInternal) { + this.watchFiles.add(normalizedAbsoluteInput); + } + + if (this.#loadCache) { + const cachedLoad = + (await this.#loadCache.get(input)) ?? + (await this.#loadCache.get(input.replace(';', ':'))) ?? + (await this.#loadCache.get('file:' + normalizedAbsoluteInput)); + if (cachedLoad?.watchFiles) { + for (const file of cachedLoad.watchFiles) { + if (!isInternalAngularFile(file)) { + this.watchFiles.add( + isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file), + ); + } + } + } + } + } + } + + // Return if the build encountered any errors + if (result.errors.length) { + this.#addErrorsToWatch(result); + await this.#addLoadCacheFilesToWatch(); + + return { + errors: result.errors, + warnings: result.warnings, + }; + } + + const { + 'ng-platform-server': isPlatformServer = false, + 'ng-ssr-entry-bundle': isSsrEntryBundle = false, + } = result.metafile as Metafile & { + 'ng-platform-server'?: boolean; + 'ng-ssr-entry-bundle'?: boolean; + }; + + // Find all initial files + const initialFiles = new Map(); + for (const outputFile of result.outputFiles) { + // Entries in the metafile are relative to the `absWorkingDir` option which is set to the workspaceRoot + // `outputFile.path` may already be relative if the `OutputFile` instance was cached in `additionalResults` + // across an incremental rebuild. + const relativeFilePath = isAbsolute(outputFile.path) + ? relative(this.workspaceRoot, outputFile.path) + : outputFile.path; + const entryPoint = result.metafile.outputs[relativeFilePath]?.entryPoint; + + outputFile.path = relativeFilePath; + + if (entryPoint) { + // The first part of the filename is the name of file (e.g., "polyfills" for "polyfills-7S5G3MDY.js") + const name = basename(relativeFilePath).replace(/(?:-[\dA-Z]{8})?\.[a-z]{2,3}$/, ''); + // Entry points are only styles or scripts + const type = extname(relativeFilePath) === '.css' ? 'style' : 'script'; + + // Only entrypoints with an entry in the options are initial files. + // Dynamic imports also have an entryPoint value in the meta file. + if ((this.#esbuildOptions.entryPoints as Record)?.[name]) { + // An entryPoint value indicates an initial file + const record: InitialFileRecord = { + name, + type, + entrypoint: true, + serverFile: isPlatformServer, + depth: 0, + }; + + if (!this.initialFilter || this.initialFilter(record)) { + initialFiles.set(relativeFilePath, record); + } + } + } + } + + // Analyze for transitive initial files + findTransitiveInitialFiles(result.metafile, initialFiles, undefined, this.initialFilter); + + // Collect all external package names + const externalImports = new Set(); + for (const { imports } of Object.values(result.metafile.outputs)) { + for (const { external, kind, path } of imports) { + if ( + !external || + SERVER_GENERATED_EXTERNALS.has(path) || + isInternalAngularFile(path) || + (kind !== 'import-statement' && kind !== 'dynamic-import' && kind !== 'require-call') + ) { + continue; + } + + externalImports.add(path); + } + } + + assert(this.#esbuildOptions, 'esbuild options cannot be undefined.'); + + const outputFiles = result.outputFiles.map((file) => { + let fileType: BuildOutputFileType; + // All files that are not JS, CSS, WASM, or sourcemaps for them are considered media + if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) { + fileType = BuildOutputFileType.Media; + } else if (isPlatformServer) { + fileType = isSsrEntryBundle + ? BuildOutputFileType.ServerRoot + : BuildOutputFileType.ServerApplication; + } else { + fileType = BuildOutputFileType.Browser; + } + + return convertOutputFile(file, fileType); + }); + + let externalConfiguration = this.#esbuildOptions.external; + if (isPlatformServer && externalConfiguration) { + externalConfiguration = externalConfiguration.filter( + (dep) => !SERVER_GENERATED_EXTERNALS.has(dep), + ); + + if (!externalConfiguration.length) { + externalConfiguration = undefined; + } + } + + // Return the successful build results + return { + ...result, + outputFiles, + initialFiles, + externalImports, + platform: isPlatformServer ? 'server' : 'browser', + externalConfiguration, + errors: undefined, + }; + } + + #addErrorsToWatch(result: BuildFailure | BuildResult): void { + const entryPoints = this.#esbuildOptions?.entryPoints; + if (entryPoints) { + const entries = Array.isArray(entryPoints) + ? entryPoints.map((entry) => (typeof entry === 'string' ? entry : entry.in)) + : Object.values(entryPoints); + for (const entry of entries) { + if (isInternalAngularFile(entry) || isInternalBundlerFile(entry)) { + continue; + } + + this.watchFiles.add(isAbsolute(entry) ? normalize(entry) : join(this.workspaceRoot, entry)); + } + } + + for (const error of result.errors) { + let file = error.location?.file; + if (file && !isInternalAngularFile(file)) { + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + for (const note of error.notes ?? []) { + file = note.location?.file; + if (file && !isInternalAngularFile(file)) { + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + } + } + } + + async #addLoadCacheFilesToWatch(): Promise { + const loadCache = this.#loadCache; + if (!this.incremental || !loadCache || !this.#loadCacheKeys) { + return; + } + + const results = await Promise.all(Array.from(this.#loadCacheKeys, (key) => loadCache.get(key))); + for (const cachedResult of results) { + if (!cachedResult?.watchFiles) { + continue; + } + + for (const file of cachedResult.watchFiles) { + if (isInternalAngularFile(file)) { + continue; + } + + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + } + } + + /** + * Invalidate a stored bundler result based on the previous watch files + * and a list of changed files. + * The context must be created with incremental mode enabled for results + * to be stored. + * @returns True, if the result was invalidated; False, otherwise. + */ + invalidate(files: Iterable | ReadonlySet): boolean { + if (!this.incremental) { + return false; + } + + let candidateFiles: ReadonlySet; + if (files instanceof Set) { + let isCandidateReady = true; + for (const file of files) { + if ( + file !== normalize(file) || + (!isAbsolute(file) && !files.has(normalize(join(this.workspaceRoot, file)))) + ) { + isCandidateReady = false; + break; + } + } + + if (isCandidateReady) { + candidateFiles = files; + } else { + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = normalize(file); + normalizedFiles.add(normalized); + if (!isAbsolute(normalized)) { + normalizedFiles.add(normalize(join(this.workspaceRoot, normalized))); + } + } + candidateFiles = normalizedFiles; + } + } else { + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = normalize(file); + normalizedFiles.add(normalized); + if (!isAbsolute(normalized)) { + normalizedFiles.add(normalize(join(this.workspaceRoot, normalized))); + } + } + candidateFiles = normalizedFiles; + } + + if (this.#loadCache) { + for (const file of candidateFiles) { + this.#loadCache.invalidate(file); + } + } + + const invalid = !this.watchFiles.isDisjointFrom(candidateFiles); + + if (invalid) { + this.#invalidationEpoch++; + this.#esbuildResult = undefined; + } + + return invalid; + } + + /** + * Disposes incremental build resources present in the context. + * + * @returns A promise that resolves when disposal is complete. + */ + async dispose(): Promise { + this.#disposed = true; + try { + this.#esbuildOptions = undefined; + this.#esbuildResult = undefined; + this.#activeBundlePromise = undefined; + this.#loadCache = undefined; + this.#loadCacheKeys = undefined; + await this.#esbuildContext?.dispose(); + } finally { + this.#esbuildContext = undefined; + } + } +} + +function isInternalAngularFile(file: string) { + return file.startsWith('angular:'); +} + +function isInternalBundlerFile(file: string) { + // Bundler virtual files such as "" or "" + if (file[0] === '<' && file.at(-1) === '>') { + return true; + } + + // Any (disabled): path is a virtual esbuild entry that doesn't exist on disk + if (file.includes('(disabled):')) { + return true; + } + + return false; +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts new file mode 100644 index 000000000000..b98f266e36a7 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { BundlerContext } from './bundler-context'; +import { MemoryLoadResultCache, createCachedLoad } from './load-result-cache'; + +describe('BundlerContext', () => { + let tempRoot: string; + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-bundler-context-test-')); + }); + + describe('invalidate', () => { + it('should return false when incremental is disabled', () => { + const context = new BundlerContext('/workspace', /* incremental */ false, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/app.css'])).toBeFalse(); + }); + + it('should return true when a watch file matches changed files as an array', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue(); + }); + + it('should return true when a watch file matches changed files as a ReadonlySet', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + const changedSet: ReadonlySet = new Set(['/workspace/src/app.css']); + expect(context.invalidate(changedSet)).toBeTrue(); + }); + + it('should return false when changed files do not intersect with watchFiles', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/other.css'])).toBeFalse(); + }); + + it('should correctly handle relative changed file paths', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['src/app.css'])).toBeTrue(); + }); + + it('should correctly handle relative paths inside a ReadonlySet', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + const set: ReadonlySet = new Set(['src/app.css']); + expect(context.invalidate(set)).toBeTrue(); + }); + + it('should invalidate shared load cache when files change', async () => { + const loadCache = new MemoryLoadResultCache(); + await loadCache.put('file:/workspace/src/app.css', { + contents: 'body {}', + loader: 'css', + watchFiles: ['/workspace/src/app.css'], + }); + + const context = new BundlerContext( + '/workspace', + /* incremental */ true, + () => ({ entryPoints: ['main.js'] }), + /* useContext */ false, + /* initialFilter */ undefined, + loadCache, + ); + context.watchFiles.add('/workspace/src/app.css'); + + expect(loadCache.get('file:/workspace/src/app.css')).toBeDefined(); + expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue(); + expect(loadCache.get('file:/workspace/src/app.css')).toBeUndefined(); + }); + + it('should not invalidate unrelated contexts sharing a load cache', async () => { + const workspaceDir = await mkdtemp(join(tempRoot, 'shared-cache-')); + const compAPath = join(workspaceDir, 'comp-a.css'); + const compBPath = join(workspaceDir, 'comp-b.css'); + await writeFile(compAPath, '.a { color: red; }'); + await writeFile(compBPath, '.b { color: blue; }'); + + const sharedCache = new MemoryLoadResultCache(); + const createCssContext = (entry: string) => + new BundlerContext( + workspaceDir, + /* incremental */ true, + (loadCache) => ({ + absWorkingDir: workspaceDir, + bundle: true, + entryPoints: [entry], + outdir: workspaceDir, + plugins: [ + { + name: 'test-cached-css', + setup(build) { + build.onLoad( + { filter: /\.css$/ }, + createCachedLoad(loadCache, async (args) => ({ + contents: await readFile(args.path, 'utf8'), + loader: 'css', + watchFiles: [args.path], + })), + ); + }, + }, + ], + }), + /* useContext */ false, + /* initialFilter */ undefined, + sharedCache, + ); + + const contextA = createCssContext(compAPath); + const contextB = createCssContext(compBPath); + + const resultA1 = await contextA.bundle(); + const resultB1 = await contextB.bundle(); + expect(resultA1.errors).toBeUndefined(); + expect(resultB1.errors).toBeUndefined(); + + await writeFile(compAPath, '.a { color: green; }'); + + // Even if contextB.invalidate runs first against the shared cache, contextB must not be invalidated + expect(contextB.invalidate([compAPath])).toBeFalse(); + expect(contextA.invalidate([compAPath])).toBeTrue(); + + const resultB2 = await contextB.bundle(); + const resultA2 = await contextA.bundle(); + expect(resultB2).toBe(resultB1); + expect(resultA2).not.toBe(resultA1); + assert(!resultA2.errors); + expect(resultA2.outputFiles[0].text).toContain('green'); + }); + + it('should work when watchFiles is smaller than changed files', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/file10.css'); + + const changedFiles = Array.from({ length: 100 }, (_, i) => `/workspace/src/file${i}.css`); + expect(context.invalidate(new Set(changedFiles))).toBeTrue(); + }); + + it('should work when changed files is smaller than watchFiles', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + for (let i = 0; i < 100; i++) { + context.watchFiles.add(`/workspace/src/file${i}.css`); + } + + expect(context.invalidate(new Set(['/workspace/src/file50.css']))).toBeTrue(); + }); + }); + + describe('bundle', () => { + it('should recover when fixing a broken @import in plain CSS in watch mode', async () => { + const workspaceDir = await mkdtemp(join(tempRoot, 'css-import-recovery-')); + const compCssPath = join(workspaceDir, 'comp.css'); + const subCssPath = join(workspaceDir, 'sub.css'); + await writeFile(compCssPath, "@import './sub.css';"); + await writeFile(subCssPath, "@import './non-existent.css';"); + + const context = new BundlerContext( + workspaceDir, + /* incremental */ true, + () => ({ + absWorkingDir: workspaceDir, + bundle: true, + entryPoints: [compCssPath], + outdir: workspaceDir, + logLevel: 'silent', + }), + /* useContext */ false, + ); + + const failedResult = await context.bundle(); + expect(failedResult.errors).toBeDefined(); + expect(context.watchFiles.has(subCssPath)).toBeTrue(); + expect(context.watchFiles.has(compCssPath)).toBeTrue(); + + // Fix sub.css and verify invalidation + recovery + await writeFile(subCssPath, '.sub { color: purple; }'); + expect(context.invalidate([subCssPath])).toBeTrue(); + + const recoveredResult = await context.bundle(); + assert(!recoveredResult.errors); + expect(recoveredResult.outputFiles[0].text).toContain('purple'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts new file mode 100644 index 000000000000..991ad988662b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Message, PartialMessage } from 'esbuild'; +import { normalize } from 'node:path'; +import type { ChangedFiles } from '../../utils/watcher'; +import type { ComponentStylesheetBundler } from './angular/component-stylesheets'; +import type { SourceFileCache } from './angular/source-file-cache'; +import type { BundlerContext } from './bundler-context'; +import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; + +export interface BuildOutputAsset { + source: string; + destination: string; +} + +export interface RebuildState { + rebuildContexts: { + typescriptContexts: BundlerContext[]; + otherContexts: BundlerContext[]; + }; + componentStyleBundler: ComponentStylesheetBundler; + codeBundleCache?: SourceFileCache; + fileChanges: ChangedFiles; + previousOutputInfo: ReadonlyMap< + string, + { hash: string; type: BuildOutputFileType; path: string } + >; + previousAssetsInfo: ReadonlyMap; + templateUpdates?: Map; +} + +export interface ExternalResultMetadata { + implicitBrowser: string[]; + implicitServer: string[]; + explicit: string[]; +} + +export type PrerenderedRoutesRecord = Record }>; + +/** + * Represents the result of a single builder execute call. + */ +export class ExecutionResult { + outputFiles: BuildOutputFile[] = []; + assetFiles: BuildOutputAsset[] = []; + errors: (Message | PartialMessage)[] = []; + prerenderedRoutes: PrerenderedRoutesRecord = {}; + warnings: (Message | PartialMessage)[] = []; + logs: string[] = []; + externalMetadata?: ExternalResultMetadata; + extraWatchFiles: string[] = []; + htmlIndexPath?: string; + htmlBaseHref?: string; + + constructor( + private rebuildContexts: { + typescriptContexts: BundlerContext[]; + otherContexts: BundlerContext[]; + }, + private componentStyleBundler: ComponentStylesheetBundler, + private codeBundleCache?: SourceFileCache, + readonly templateUpdates?: Map, + ) {} + + addOutputFile(path: string, content: string | Uint8Array, type: BuildOutputFileType): void { + this.outputFiles.push(createOutputFile(path, content, type)); + } + + addAssets(assets: BuildOutputAsset[]): void { + this.assetFiles.push(...assets); + } + + addLog(value: string): void { + this.logs.push(value); + } + + addError(error: PartialMessage | string): void { + if (typeof error === 'string') { + this.errors.push({ text: error, location: null }); + } else { + this.errors.push(error); + } + } + + addErrors(errors: (PartialMessage | string)[]): void { + for (const error of errors) { + this.addError(error); + } + } + + addPrerenderedRoutes(routes: PrerenderedRoutesRecord): void { + Object.assign(this.prerenderedRoutes, routes); + + // Sort the prerendered routes. + const sortedObj: PrerenderedRoutesRecord = {}; + for (const key of Object.keys(this.prerenderedRoutes).sort()) { + sortedObj[key] = this.prerenderedRoutes[key]; + } + + this.prerenderedRoutes = sortedObj; + } + + addWarning(error: PartialMessage | string): void { + if (typeof error === 'string') { + this.warnings.push({ text: error, location: null }); + } else { + this.warnings.push(error); + } + } + + addWarnings(errors: (PartialMessage | string)[]): void { + for (const error of errors) { + this.addWarning(error); + } + } + + /** + * Add external JavaScript import metadata to the result. This is currently used + * by the development server to optimize the prebundling process. + * @param implicitBrowser External dependencies for the browser bundles due to the external packages option. + * @param implicitServer External dependencies for the server bundles due to the external packages option. + * @param explicit External dependencies due to explicit project configuration. + */ + setExternalMetadata( + implicitBrowser: string[], + implicitServer: string[], + explicit: string[], + ): void { + this.externalMetadata = { implicitBrowser, implicitServer, explicit }; + } + + get output() { + return { + success: this.errors.length === 0, + }; + } + + get outputWithFiles() { + return { + success: this.errors.length === 0, + outputFiles: this.outputFiles, + assetFiles: this.assetFiles, + errors: this.errors, + externalMetadata: this.externalMetadata, + }; + } + + get watchFiles(): Readonly { + const { typescriptContexts, otherContexts } = this.rebuildContexts; + + return [ + // Bundler contexts internally normalize file dependencies. + ...typescriptContexts.flatMap((context) => [...context.watchFiles]), + ...otherContexts.flatMap((context) => [...context.watchFiles]), + // These files originate from TS/NG and can have POSIX path separators even on Windows. + // To ensure path comparisons are valid, all these paths must be normalized. + ...(this.codeBundleCache?.referencedFiles?.map(normalize) ?? []), + // The assets source files. + ...this.assetFiles.map(({ source }) => source), + ...this.extraWatchFiles, + ]; + } + + createRebuildState(fileChanges: ChangedFiles): RebuildState { + return { + rebuildContexts: this.rebuildContexts, + codeBundleCache: this.codeBundleCache, + componentStyleBundler: this.componentStyleBundler, + fileChanges, + previousOutputInfo: new Map( + this.outputFiles.map(({ path, hash, type }) => [`${type}:${path}`, { hash, type, path }]), + ), + previousAssetsInfo: new Map( + this.assetFiles.map(({ source, destination }) => [source, destination]), + ), + templateUpdates: this.templateUpdates, + }; + } + + findChangedFiles( + previousOutputHashes: ReadonlyMap, + ): Set { + const changed = new Set(); + for (const file of this.outputFiles) { + const previousHash = previousOutputHashes.get(`${file.type}:${file.path}`)?.hash; + if (previousHash === undefined || previousHash !== file.hash) { + changed.add(file.path); + } + } + + return changed; + } + + #disposal?: Promise; + + dispose(): Promise { + return (this.#disposal ??= this.#dispose()); + } + + async #dispose(): Promise { + try { + await Promise.allSettled([ + ...this.rebuildContexts.typescriptContexts.map((context) => context.dispose()), + ...this.rebuildContexts.otherContexts.map((context) => context.dispose()), + this.componentStyleBundler.dispose(), + ]); + } finally { + this.codeBundleCache?.clear(); + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-files.ts b/packages/angular/build/src/tools/esbuild/bundler-files.ts new file mode 100644 index 000000000000..af4b04ad9e56 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-files.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile, OutputFile } from 'esbuild'; +import { calculateHash } from '../../utils/hash'; + +export interface InitialFileRecord { + entrypoint: boolean; + name?: string; + type: 'script' | 'style'; + external?: boolean; + serverFile: boolean; + depth: number; +} + +/** + * Analyzes a metafile to find and record all transitive initial files starting from the given + * initial entries using breadth-first traversal so each file is recorded at its minimum depth. + * + * @param metafile The esbuild metafile containing output import relationships. + * @param initialFiles The map of initial files to populate. + * @param entriesToAnalyze Optional starting entries to traverse; defaults to all current `initialFiles`. + * Entries must share the same starting depth (or be ordered by non-decreasing depth) to guarantee + * that each transitive file is visited at its minimum depth first. + * @param initialFilter Optional filter predicate to determine whether a discovered record should be added. + */ +export function findTransitiveInitialFiles( + metafile: Metafile, + initialFiles: Map, + entriesToAnalyze: Iterable<[string, InitialFileRecord]> = initialFiles, + initialFilter?: (initial: Readonly) => boolean, +): void { + const queue = [...entriesToAnalyze]; + for (let i = 0; i < queue.length; i++) { + const [entryPath, entryRecord] = queue[i]; + const entryOutput = metafile.outputs[entryPath]; + if (!entryOutput) { + continue; + } + + for (const initialImport of entryOutput.imports) { + if (initialFiles.has(initialImport.path)) { + continue; + } + + if (initialImport.kind === 'import-statement' || initialImport.kind === 'import-rule') { + const record: InitialFileRecord = { + type: initialImport.kind === 'import-rule' ? 'style' : 'script', + entrypoint: false, + external: initialImport.external, + serverFile: entryRecord.serverFile, + depth: entryRecord.depth + 1, + }; + + if (!initialFilter || initialFilter(record)) { + initialFiles.set(initialImport.path, record); + + if (!initialImport.external) { + queue.push([initialImport.path, record]); + } + } + } + } + } +} + +export enum BuildOutputFileType { + Browser, + Media, + ServerApplication, + ServerRoot, + Root, +} + +export interface BuildOutputFile extends OutputFile { + type: BuildOutputFileType; + readonly size: number; + clone: () => BuildOutputFile; +} + +export function createOutputFile( + path: string, + data: string | Uint8Array, + type: BuildOutputFileType, +): BuildOutputFile { + if (typeof data === 'string') { + let cachedContents: Uint8Array | null = null; + let cachedText: string | null = data; + let cachedHash: string | null = null; + + return { + path, + type, + get contents(): Uint8Array { + cachedContents ??= new TextEncoder().encode(data); + + return cachedContents; + }, + set contents(value: Uint8Array) { + cachedContents = value; + cachedText = null; + }, + get text(): string { + cachedText ??= new TextDecoder('utf-8').decode(this.contents); + + return cachedText; + }, + get size(): number { + return this.contents.byteLength; + }, + get hash(): string { + cachedHash ??= calculateHash(cachedText ?? this.contents); + + return cachedHash; + }, + clone(): BuildOutputFile { + return createOutputFile(this.path, cachedText ?? this.contents, this.type); + }, + }; + } else { + let cachedContents = data; + let cachedText: string | null = null; + let cachedHash: string | null = null; + + return { + get contents(): Uint8Array { + return cachedContents; + }, + set contents(value: Uint8Array) { + cachedContents = value; + cachedText = null; + }, + path, + type, + get size(): number { + return this.contents.byteLength; + }, + get text(): string { + cachedText ??= new TextDecoder('utf-8').decode(this.contents); + + return cachedText; + }, + get hash(): string { + cachedHash ??= calculateHash(this.contents); + + return cachedHash; + }, + clone(): BuildOutputFile { + return createOutputFile(this.path, this.contents, this.type); + }, + }; + } +} + +export function convertOutputFile(file: OutputFile, type: BuildOutputFileType): BuildOutputFile { + let { contents: cachedContents } = file; + let cachedText: string | null = null; + + return { + get contents(): Uint8Array { + return cachedContents; + }, + set contents(value: Uint8Array) { + cachedContents = value; + cachedText = null; + }, + hash: file.hash, + path: file.path, + type, + get size(): number { + return this.contents.byteLength; + }, + get text(): string { + cachedText ??= new TextDecoder('utf-8').decode(this.contents); + + return cachedText; + }, + clone(): BuildOutputFile { + return convertOutputFile(this, this.type); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-files_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-files_spec.ts new file mode 100644 index 000000000000..be21f7ded608 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-files_spec.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { type InitialFileRecord, findTransitiveInitialFiles } from './bundler-files'; + +function createMetafile( + outputs: Record< + string, + { + imports: { + path: string; + kind: 'import-statement' | 'import-rule' | 'dynamic-import'; + external?: boolean; + }[]; + } + >, +): Metafile { + return { + inputs: {}, + outputs: Object.fromEntries( + Object.entries(outputs).map(([key, value]) => [ + key, + { + bytes: 0, + inputs: {}, + imports: value.imports, + exports: [], + }, + ]), + ), + }; +} + +describe('findTransitiveInitialFiles', () => { + it('should compute minimum depth and propagate it to transitive children in a DAG', () => { + // main (0) -> [a (1), b (1)] + // b (1) -> c (2) -> d (via b: 3, via a: 2) -> e (should be 3, not 4) + // a (1) -> d (2) + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'chunk-b.js', kind: 'import-statement' }, + ], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-d.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [{ path: 'chunk-c.js', kind: 'import-statement' }], + }, + 'chunk-c.js': { + imports: [{ path: 'chunk-d.js', kind: 'import-statement' }], + }, + 'chunk-d.js': { + imports: [{ path: 'chunk-e.js', kind: 'import-statement' }], + }, + 'chunk-e.js': { + imports: [], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.get('chunk-a.js')?.depth).toBe(1); + expect(initialFiles.get('chunk-b.js')?.depth).toBe(1); + expect(initialFiles.get('chunk-c.js')?.depth).toBe(2); + expect(initialFiles.get('chunk-d.js')?.depth).toBe(2); + expect(initialFiles.get('chunk-e.js')?.depth).toBe(3); + }); + + it('should record files in breadth-first forward import order', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'chunk-b.js', kind: 'import-statement' }, + ], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-a1.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [{ path: 'chunk-b1.js', kind: 'import-statement' }], + }, + 'chunk-a1.js': { imports: [] }, + 'chunk-b1.js': { imports: [] }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect([...initialFiles.keys()]).toEqual([ + 'main.js', + 'chunk-a.js', + 'chunk-b.js', + 'chunk-a1.js', + 'chunk-b1.js', + ]); + }); + + it('should ignore dynamic imports and handle external and style imports', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'lazy.js', kind: 'dynamic-import' }, + { path: '@angular/core', kind: 'import-statement', external: true }, + ], + }, + 'styles.css': { + imports: [{ path: 'https://example.com/font.css', kind: 'import-rule', external: true }], + }, + 'lazy.js': { + imports: [{ path: 'lazy-dep.js', kind: 'import-statement' }], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + [ + 'styles.css', + { entrypoint: true, name: 'styles', type: 'style', serverFile: false, depth: 0 }, + ], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.has('lazy.js')).toBeFalse(); + expect(initialFiles.has('lazy-dep.js')).toBeFalse(); + expect(initialFiles.get('@angular/core')).toEqual({ + type: 'script', + entrypoint: false, + external: true, + serverFile: false, + depth: 1, + }); + expect(initialFiles.get('https://example.com/font.css')).toEqual({ + type: 'style', + entrypoint: false, + external: true, + serverFile: false, + depth: 1, + }); + }); + + it('should only traverse from provided entriesToAnalyze while preserving existing initialFiles', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [{ path: 'chunk-main.js', kind: 'import-statement' }], + }, + 'polyfills.js': { + imports: [{ path: 'chunk-polyfills.js', kind: 'import-statement' }], + }, + 'chunk-main.js': { imports: [] }, + 'chunk-polyfills.js': { imports: [] }, + }); + + const mainRecord: InitialFileRecord = { + entrypoint: true, + name: 'main', + type: 'script', + serverFile: false, + depth: 0, + }; + const polyfillsRecord: InitialFileRecord = { + entrypoint: true, + name: 'polyfills', + type: 'script', + serverFile: false, + depth: 0, + }; + const initialFiles = new Map([ + ['main.js', mainRecord], + ['polyfills.js', polyfillsRecord], + ]); + + const entriesToAnalyze: [string, InitialFileRecord][] = [['main.js', mainRecord]]; + findTransitiveInitialFiles(metafile, initialFiles, entriesToAnalyze); + + expect(entriesToAnalyze.length).toBe(1); + expect(initialFiles.has('main.js')).toBeTrue(); + expect(initialFiles.has('polyfills.js')).toBeTrue(); + expect(initialFiles.has('chunk-main.js')).toBeTrue(); + expect(initialFiles.has('chunk-polyfills.js')).toBeFalse(); + }); + + it('should filter discovered records using initialFilter when provided', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'external-lib', kind: 'import-statement', external: true }, + ], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-b.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [{ path: 'chunk-c.js', kind: 'import-statement' }], + }, + 'chunk-c.js': { + imports: [{ path: 'chunk-b.js', kind: 'import-statement' }], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles( + metafile, + initialFiles, + undefined, + (record) => !record.external && record.depth <= 1, + ); + + expect(initialFiles.has('chunk-a.js')).toBeTrue(); + expect(initialFiles.has('external-lib')).toBeFalse(); + expect(initialFiles.has('chunk-b.js')).toBeFalse(); + expect(initialFiles.has('chunk-c.js')).toBeFalse(); + }); + + it('should handle circular static imports between chunks without infinite recursion', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [{ path: 'chunk-a.js', kind: 'import-statement' }], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-b.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'main.js', kind: 'import-statement' }, + ], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.get('main.js')?.depth).toBe(0); + expect(initialFiles.get('chunk-a.js')?.depth).toBe(1); + expect(initialFiles.get('chunk-b.js')?.depth).toBe(2); + }); + + it('should propagate serverFile flag from entry record to transitive imports', () => { + const metafile = createMetafile({ + 'server.mjs': { + imports: [{ path: 'chunk-server-1.mjs', kind: 'import-statement' }], + }, + 'chunk-server-1.mjs': { + imports: [{ path: 'chunk-server-2.mjs', kind: 'import-statement' }], + }, + 'chunk-server-2.mjs': { imports: [] }, + }); + + const initialFiles = new Map([ + [ + 'server.mjs', + { entrypoint: true, name: 'server', type: 'script', serverFile: true, depth: 0 }, + ], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.get('chunk-server-1.mjs')).toEqual({ + type: 'script', + entrypoint: false, + external: undefined, + serverFile: true, + depth: 1, + }); + expect(initialFiles.get('chunk-server-2.mjs')).toEqual({ + type: 'script', + entrypoint: false, + external: undefined, + serverFile: true, + depth: 2, + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/commonjs-checker.ts b/packages/angular/build/src/tools/esbuild/commonjs-checker.ts new file mode 100644 index 000000000000..305d6d74a44c --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/commonjs-checker.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile, PartialMessage } from 'esbuild'; + +/** + * Checks the input files of a build to determine if any of the files included + * in the build are not ESM. ESM files can be tree-shaken and otherwise optimized + * in ways that CommonJS and other module formats cannot. The esbuild metafile + * information is used as the basis for the analysis as it contains information + * for each input file including its respective format. + * + * If any allowed dependencies are provided via the `allowedCommonJsDependencies` + * parameter, both the direct import and any deep imports will be ignored and no + * diagnostic will be generated. Use `'*'` as entry to skip the check. + * + * If a module has been issued a diagnostic message, then all descendant modules + * will not be checked. This prevents a potential massive amount of inactionable + * messages since the initial module import is the cause of the problem. + * + * @param metafile An esbuild metafile object to check. + * @param allowedCommonJsDependencies An optional list of allowed dependencies. + * @returns Zero or more diagnostic messages for any non-ESM modules. + */ +export function checkCommonJSModules( + metafile: Metafile, + allowedCommonJsDependencies?: string[], +): PartialMessage[] { + const messages: PartialMessage[] = []; + const allowedRequests = new Set(allowedCommonJsDependencies); + + if (allowedRequests.has('*')) { + return messages; + } + + // Ignore Angular locale definitions which are currently UMD + allowedRequests.add('@angular/common/locales'); + + // Ignore zone.js due to it currently being built with a UMD like structure. + // Once the build output is updated to be fully ESM, this can be removed. + allowedRequests.add('zone.js'); + + // Used by '@angular/platform-server' and is in a seperate chunk that is unused when + // using `provideHttpClient(withFetch())`. + allowedRequests.add('xhr2'); + + // Packages used by @angular/ssr. + // While beasties is ESM it has a number of direct and transtive CJS deps. + allowedRequests.add('express'); + allowedRequests.add('beasties'); + + // Find all entry points that contain code (JS/TS) + const files: string[] = []; + for (const { entryPoint } of Object.values(metafile.outputs)) { + if (!entryPoint) { + continue; + } + if (!isPathCode(entryPoint)) { + continue; + } + + files.push(entryPoint); + } + + // Track seen files so they are only analyzed once. + // Bundler runtime code is also ignored since it cannot be actionable. + const seenFiles = new Set(['']); + + // Analyze the files present by walking the import graph + let currentFile: string | undefined; + while ((currentFile = files.shift())) { + const input = metafile.inputs[currentFile]; + + for (const imported of input.imports) { + // Ignore imports that were already seen or not originally in the code (bundler injected) + if (!imported.original || seenFiles.has(imported.path)) { + continue; + } + + seenFiles.add(imported.path); + + // If the dependency is allowed ignore all other checks + if (allowedRequests.has(imported.original)) { + continue; + } + + // Only check actual code files + if (!isPathCode(imported.path)) { + continue; + } + + // Check if non-relative import is ESM format and issue a diagnostic if the file is not allowed + if ( + !isPotentialRelative(imported.original) && + metafile.inputs[imported.path].format !== 'esm' + ) { + const request = imported.original; + + let notAllowed = true; + if (allowedRequests.has(request)) { + notAllowed = false; + } else { + // Check for deep imports of allowed requests + for (const allowed of allowedRequests) { + if (request.startsWith(allowed + '/')) { + notAllowed = false; + break; + } + } + } + + if (notAllowed) { + // Issue a diagnostic message for CommonJS module + messages.push(createCommonJSModuleError(request, currentFile)); + } + + // Skip all descendants since they are also most likely not ESM but solved by addressing this import + continue; + } + + // Add the path so that its imports can be checked + files.push(imported.path); + } + } + + return messages; +} + +/** + * Determines if a file path has an extension that is a JavaScript or TypeScript + * code file. + * + * @param name A path to check for code file extensions. + * @returns True, if a code file path; false, otherwise. + */ +function isPathCode(name: string): boolean { + return /\.[cm]?[jt]sx?$/.test(name); +} + +/** + * Test an import module specifier to determine if the string potentially references a relative file. + * npm packages should not start with a period so if the first character is a period than it is not a + * package. While this is sufficient for the use case in the CommmonJS checker, only checking the + * first character does not definitely indicate the specifier is a relative path. + * + * @param specifier An import module specifier. + * @returns True, if specifier is potentially relative; false, otherwise. + */ +function isPotentialRelative(specifier: string): boolean { + return specifier[0] === '.'; +} + +/** + * Creates an esbuild diagnostic message for a given non-ESM module request. + * + * @param request The requested non-ESM module name. + * @param importer The path of the file containing the import. + * @returns A message representing the diagnostic. + */ +function createCommonJSModuleError(request: string, importer: string): PartialMessage { + const error = { + text: `Module '${request}' used by '${importer}' is not ESM`, + notes: [ + { + text: + 'CommonJS or AMD dependencies can cause optimization bailouts.\n' + + 'For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies', + }, + ], + }; + + return error; +} diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts new file mode 100644 index 000000000000..573b8616da60 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import type { createCompilerPlugin } from './angular/compiler-plugin'; +import type { SourceFileCache } from './angular/source-file-cache'; +import type { LoadResultCache } from './load-result-cache'; + +type CreateCompilerPluginParameters = Parameters; + +export function createCompilerPluginOptions( + options: NormalizedApplicationBuildOptions, + sourceFileCache: SourceFileCache, + loadResultCache?: LoadResultCache, + templateUpdates?: Map, +): CreateCompilerPluginParameters[0] { + const { + sourcemapOptions, + tsconfig, + fileReplacements, + advancedOptimizations, + jit, + externalRuntimeStyles, + instrumentForCoverage, + optimizationOptions, + rootFiles, + } = options; + const incremental = !!options.watch; + + return { + sourcemap: !!sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + thirdPartySourcemaps: sourcemapOptions.vendor, + tsconfig, + jit, + advancedOptimizations, + fileReplacements, + sourceFileCache, + loadResultCache, + incremental, + externalRuntimeStyles, + instrumentForCoverage, + templateUpdates, + includeTestMetadata: !optimizationOptions.scripts, + rootFiles, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/external-packages-plugin.ts b/packages/angular/build/src/tools/esbuild/external-packages-plugin.ts new file mode 100644 index 000000000000..b968493005ac --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/external-packages-plugin.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; +import { extname } from 'node:path'; + +const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION'); + +/** + * Creates a plugin that marks any resolved path as external if it is within a node modules directory. + * This is used instead of the esbuild `packages` option to avoid marking files that should be loaded + * via customized loaders. This is necessary to prevent Vite development server pre-bundling errors. + * + * @returns An esbuild plugin. + */ +export function createExternalPackagesPlugin(options?: { exclude?: string[] }): Plugin { + const exclusions = new Set(options?.exclude); + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = options?.exclude?.map((exclusion) => exclusion + '/') ?? []; + const seenExclusions: Set = new Set(); + const seenExternals = new Set(); + const seenNonExclusions: Set = new Set(); + + return { + name: 'angular-external-packages', + setup(build) { + // Find all loader keys that are not using the 'file' loader. + // The 'file' loader is automatically handled by Vite and does not need exclusion. + const loaderOptionKeys = + build.initialOptions.loader && + Object.entries(build.initialOptions.loader) + .filter(([, value]) => value !== 'file') + .map(([key]) => key); + + // Safe to use native packages external option if no loader options or exclusions present + if (!exclusions.size && !loaderOptionKeys?.length) { + build.initialOptions.packages = 'external'; + + return; + } + + const loaderFileExtensions = new Set(loaderOptionKeys); + + // Only attempt resolve of non-relative and non-absolute paths + build.onResolve({ filter: /^[^./]/ }, async (args) => { + if (args.pluginData?.[EXTERNAL_PACKAGE_RESOLUTION]) { + return null; + } + + if (seenExternals.has(args.path)) { + return { external: true }; + } + + if (exclusions.has(args.path) || seenExclusions.has(args.path)) { + return null; + } + + if (!seenNonExclusions.has(args.path)) { + for (const exclusion of exclusionsPrefixes) { + if (args.path.startsWith(exclusion)) { + seenExclusions.add(args.path); + + return null; + } + } + + seenNonExclusions.add(args.path); + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + // Return result if unable to resolve + if (!result.path) { + return result; + } + + // Return if explicitly marked external (externalDependencies option) + if (result.external) { + seenExternals.add(args.path); + + return { external: true }; + } + + // Allow customized loaders to run against configured paths regardless of location + if (loaderFileExtensions.has(extname(result.path))) { + return result; + } + + // Mark paths from a node modules directory as external + if (/[\\/]node_modules[\\/]/.test(result.path)) { + seenExternals.add(args.path); + + return { external: true }; + } + + // Otherwise return original result + return result; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/global-scripts.ts b/packages/angular/build/src/tools/esbuild/global-scripts.ts new file mode 100644 index 000000000000..2b6c581a572f --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/global-scripts.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Bundle, MagicString } from 'magic-string'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { assertIsError } from '../../utils/error'; +import { BundlerOptionsFactory } from './bundler-context'; +import { createCachedLoad } from './load-result-cache'; +import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; +import { createVirtualModulePlugin } from './virtual-module-plugin'; + +/** + * Create an esbuild 'build' options object for all global scripts defined in the user provied + * build options. + * @param options The builder's user-provider normalized options. + * @returns An esbuild BuildOptions object. + */ +export function createGlobalScriptsBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + initial: boolean, +): BundlerOptionsFactory | undefined { + const { + globalScripts, + optimizationOptions, + outputNames, + preserveSymlinks, + sourcemapOptions, + jsonLogs, + workspaceRoot, + define, + } = options; + + const namespace = 'angular:script/global'; + const entryPoints: Record = {}; + let found = false; + for (const script of globalScripts) { + if (script.initial === initial) { + found = true; + entryPoints[script.name] = `${namespace}:${script.name}`; + } + } + + // Skip if there are no entry points for the style loading type + if (found === false) { + return; + } + + return (loadCache) => { + return { + absWorkingDir: workspaceRoot, + bundle: false, + splitting: false, + entryPoints, + entryNames: initial ? outputNames.bundles : '[name]', + assetNames: outputNames.media, + mainFields: ['script', 'browser', 'main'], + conditions: ['script', optimizationOptions.scripts ? 'production' : 'development'], + resolveExtensions: ['.mjs', '.js', '.cjs'], + logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', + metafile: true, + minify: optimizationOptions.scripts, + outdir: workspaceRoot, + sourcemap: sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + write: false, + platform: 'neutral', + target, + preserveSymlinks, + define, + plugins: [ + createSourcemapIgnorelistPlugin(), + createVirtualModulePlugin({ + namespace, + external: true, + // Add the `js` extension here so that esbuild generates an output file with the extension + transformPath: (path) => path.slice(namespace.length + 1) + '.js', + loadContent: (args, build) => + createCachedLoad(loadCache, async (args) => { + const files = globalScripts.find( + ({ name }) => name === args.path.slice(0, -3), + )?.files; + assert(files, `Invalid operation: global scripts name not found [${args.path}]`); + + // Global scripts are concatenated using magic-string instead of bundled via esbuild. + const bundleContent = new Bundle(); + const watchFiles = []; + for (const filename of files) { + let fileContent; + try { + // Attempt to read as a relative path from the workspace root + const fullPath = path.join(workspaceRoot, filename); + fileContent = await readFile(fullPath, 'utf-8'); + watchFiles.push(fullPath); + } catch (e) { + assertIsError(e); + if (e.code !== 'ENOENT') { + throw e; + } + + // If not found, attempt to resolve as a module specifier + const resolveResult = await build.resolve(filename, { + kind: 'entry-point', + resolveDir: workspaceRoot, + }); + + if (resolveResult.errors.length) { + // Remove resolution failure notes about marking as external since it doesn't apply + // to global scripts. + resolveResult.errors.forEach((error) => (error.notes = [])); + + return { + errors: resolveResult.errors, + warnings: resolveResult.warnings, + }; + } + + watchFiles.push(resolveResult.path); + fileContent = await readFile(resolveResult.path, 'utf-8'); + } + + bundleContent.addSource(new MagicString(fileContent, { filename })); + } + + return { + contents: bundleContent.toString(), + loader: 'js', + watchFiles, + }; + }).call(build, args), + }), + ], + }; + }; +} diff --git a/packages/angular/build/src/tools/esbuild/global-styles.ts b/packages/angular/build/src/tools/esbuild/global-styles.ts new file mode 100644 index 000000000000..79afff2008af --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/global-styles.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { toPosixPath } from '../../utils/path'; +import { BundlerOptionsFactory } from './bundler-context'; +import { createStylesheetBundleOptions } from './stylesheets/bundle-options'; +import { createVirtualModulePlugin } from './virtual-module-plugin'; + +export function createGlobalStylesBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + initial: boolean, +): BundlerOptionsFactory | undefined { + const { + workspaceRoot, + optimizationOptions, + sourcemapOptions, + outputNames, + globalStyles, + preserveSymlinks, + externalDependencies, + stylePreprocessorOptions, + tailwindConfiguration, + postcssConfiguration, + cacheOptions, + } = options; + + const namespace = 'angular:styles/global'; + const entryPoints: Record = {}; + let found = false; + for (const style of globalStyles) { + if (style.initial === initial) { + found = true; + entryPoints[style.name] = `${namespace};${style.name}`; + } + } + + // Skip if there are no entry points for the style loading type + if (found === false) { + return; + } + + return (loadCache) => { + const buildOptions = createStylesheetBundleOptions( + { + workspaceRoot, + optimization: !!optimizationOptions.styles.minify, + inlineFonts: !!optimizationOptions.fonts.inline, + sourcemap: !!sourcemapOptions.styles && (sourcemapOptions.hidden ? 'external' : true), + sourcesContent: sourcemapOptions.sourcesContent, + preserveSymlinks, + target, + externalDependencies, + outputNames: initial + ? outputNames + : { + ...outputNames, + bundles: '[name]', + }, + includePaths: stylePreprocessorOptions?.includePaths, + // string[] | undefined' is not assignable to type '(Version | DeprecationOrId)[] | undefined'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sass: stylePreprocessorOptions?.sass as any, + tailwindConfiguration, + postcssConfiguration, + cacheOptions, + }, + loadCache, + ); + + // Keep special CSS comments `/*! comment */` in place when `removeSpecialComments` is disabled. + // These comments are special for a number of CSS tools such as Beasties and PurgeCSS. + buildOptions.legalComments = optimizationOptions.styles?.removeSpecialComments + ? 'none' + : 'inline'; + + buildOptions.entryPoints = entryPoints; + + buildOptions.plugins.unshift( + createVirtualModulePlugin({ + namespace, + transformPath: (path) => path.split(';', 2)[1], + loadContent: (args) => { + const files = globalStyles.find(({ name }) => name === args.path)?.files; + assert(files, `global style name should always be found [${args.path}]`); + + return { + contents: files + .map((file) => `@import ${JSON.stringify(toPosixPath(file))};`) + .join('\n'), + loader: 'css', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + return buildOptions; + }; +} diff --git a/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts new file mode 100644 index 000000000000..b4c89cf644c1 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; +import { createProjectResolver } from '../../utils/resolve-project'; +import { + LOCALE_DATA_NAMESPACE, + type LoadedLocaleData, + type LocaleDataResolution, + loadLocaleData, + resolveLocaleDataPath, +} from '../i18n/locale-data'; + +export { + LOCALE_DATA_NAMESPACE, + LOCALE_DATA_BASE_MODULE, + type LoadedLocaleData, + type LocaleDataResolution, + loadLocaleData, + resolveLocaleDataPath, +} from '../i18n/locale-data'; + +/** + * Creates an esbuild plugin that resolves Angular locale data files from `@angular/common`. + * + * @returns An esbuild plugin. + */ +export function createAngularLocaleDataPlugin(): Plugin { + return { + name: 'angular-locale-data', + setup(build): void { + build.onResolve({ filter: /^angular:locale\/data:/ }, async ({ path }) => { + const rawLocaleTag = path.split(':', 3)[2]; + const { absWorkingDir } = build.initialOptions; + let projectResolve: ((packageName: string) => string) | undefined; + + const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { + projectResolve ??= createProjectResolver(absWorkingDir ?? process.cwd()); + try { + return projectResolve(potentialPath); + } catch { + return undefined; + } + }); + + if (resolution.error) { + return { + path: rawLocaleTag, + namespace: LOCALE_DATA_NAMESPACE, + errors: [{ text: resolution.error }], + }; + } + + if (!resolution.path) { + return { + path: rawLocaleTag, + namespace: LOCALE_DATA_NAMESPACE, + warnings: resolution.warning + ? [{ location: null, text: resolution.warning }] + : undefined, + }; + } + + return { + path: resolution.path, + warnings: resolution.warning ? [{ location: null, text: resolution.warning }] : undefined, + }; + }); + + // Locales that cannot be found or are en/en-US will be loaded as empty content + build.onLoad({ filter: /./, namespace: LOCALE_DATA_NAMESPACE }, () => ({ + contents: '', + loader: 'empty', + })); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/license-extractor.ts b/packages/angular/build/src/tools/esbuild/license-extractor.ts new file mode 100644 index 000000000000..4c73012033bb --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/license-extractor.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * The path segment used to signify that a file is part of a package. + */ +const NODE_MODULE_SEGMENT = 'node_modules'; + +/** + * String constant for the NPM recommended custom license wording. + * + * See: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#license + * + * Example: + * ``` + * { + * "license" : "SEE LICENSE IN " + * } + * ``` + */ +const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN '; + +/** + * A regular expression for commonly named license files found within packages. + */ +const LICENSE_FILE_REGEXP = /^(?:mit-)?licen[cs]e(?:$|[-._])/i; + +/** + * Header text that will be added to the top of the output license extraction file. + */ +const EXTRACTION_FILE_HEADER = ''; + +/** + * The package entry separator to use within the output license extraction file. + */ +const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n'; + +/** + * Extracts license information for each node module package included in the output + * files of the built code. This includes JavaScript and CSS output files. The esbuild + * metafile generated during the bundling steps is used as the source of information + * regarding what input files where included and where they are located. A path segment + * of `node_modules` is used to indicate that a file belongs to a package and its license + * should be include in the output licenses file. + * + * The package name and license field are extracted from the `package.json` file for the + * package. If a license file (e.g., `LICENSE`) is present in the root of the package, it + * will also be included in the output licenses file. + * + * @param metafile An esbuild metafile object. + * @param rootDirectory The root directory of the workspace. + * @returns A string containing the content of the output licenses file. + */ +export async function extractLicenses( + metafiles: Metafile[], + rootDirectory: string, +): Promise { + let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`; + + const seenPaths = new Set(); + const seenPackageDirectories = new Set(); + const seenPackages = new Set(); + + for (const metafile of metafiles) { + for (const entry of Object.values(metafile.outputs)) { + for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) { + // Skip if not included in output + if (bytesInOutput <= 0) { + continue; + } + + // Skip already processed paths + if (seenPaths.has(inputPath)) { + continue; + } + seenPaths.add(inputPath); + + // Skip non-package paths + if (!inputPath.includes(NODE_MODULE_SEGMENT)) { + continue; + } + + // Extract the package name from the path + let baseDirectory = path.join(rootDirectory, inputPath); + let nameOrScope, nameOrFile; + let found = false; + while (baseDirectory !== path.dirname(baseDirectory)) { + const segment = path.basename(baseDirectory); + if (segment === NODE_MODULE_SEGMENT) { + found = true; + break; + } + + nameOrFile = nameOrScope; + nameOrScope = segment; + baseDirectory = path.dirname(baseDirectory); + } + + // Skip non-package path edge cases that are not caught in the includes check above + if (!found || !nameOrScope) { + continue; + } + + const packageName = nameOrScope.startsWith('@') + ? `${nameOrScope}/${nameOrFile}` + : nameOrScope; + const packageDirectory = path.join(baseDirectory, packageName); + + if (seenPackageDirectories.has(packageDirectory)) { + continue; + } + seenPackageDirectories.add(packageDirectory); + + // Load the package's metadata to find the package's name, version, and license type + const packageJsonPath = path.join(packageDirectory, 'package.json'); + let packageJson; + try { + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as { + name: string; + version: string; + // The object form is deprecated and should only be present in old packages + license?: string | { type: string }; + }; + } catch { + // Invalid package + continue; + } + + // Skip already processed packages + const packageId = `${packageName}@${packageJson.version}`; + if (seenPackages.has(packageId)) { + continue; + } + seenPackages.add(packageId); + + // Attempt to find license text inside package + let licenseText = ''; + if ( + typeof packageJson.license === 'string' && + packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT) + ) { + // Attempt to load the package's custom license + let customLicensePath; + const customLicenseFile = path.normalize( + packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(), + ); + if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) { + // Path is attempting to access files outside of the package + // TODO: Issue warning? + } else { + customLicensePath = path.join(packageDirectory, customLicenseFile); + try { + licenseText = await readFile(customLicensePath, 'utf-8'); + } catch {} + } + } else { + // Search for a license file within the root of the package + const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []); + + for (const entry of entries) { + if ( + (entry.isFile() || entry.isSymbolicLink()) && + LICENSE_FILE_REGEXP.test(entry.name) + ) { + const packageLicensePath = path.join(packageDirectory, entry.name); + try { + licenseText = await readFile(packageLicensePath, 'utf-8'); + break; + } catch {} + } + } + } + + // Generate the package's license entry in the output content + extractedLicenseContent += `Package: ${packageJson.name}\n`; + extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`; + extractedLicenseContent += `\n${licenseText}\n`; + extractedLicenseContent += EXTRACTION_FILE_SEPARATOR; + } + } + } + + return extractedLicenseContent; +} diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts new file mode 100644 index 000000000000..402069cff1a5 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { OnLoadResult, PluginBuild } from 'esbuild'; +import { normalize } from 'node:path'; + +export interface LoadResultCache { + get(path: string): OnLoadResult | Promise | undefined; + put(path: string, result: OnLoadResult): Promise; + invalidate(path: string): boolean; + readonly watchFiles: ReadonlyArray; +} + +export function createCachedLoad( + cache: LoadResultCache | undefined, + callback: Parameters[1], +): Parameters[1] { + if (cache === undefined) { + return callback; + } + + return async (args) => { + const loadCacheKey = `${args.namespace}:${args.path}`; + let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey); + + if (result === undefined) { + result = await callback(args); + + // Do not cache null or undefined + if (result) { + // Ensure requested path is included if it was a resolved file + if (args.namespace === 'file') { + result.watchFiles ??= []; + if (!result.watchFiles.includes(args.path)) { + result.watchFiles.push(args.path); + } + } + await cache.put(loadCacheKey, result); + } + } + + return result; + }; +} + +export class MemoryLoadResultCache implements LoadResultCache { + #loadResults = new Map(); + #fileDependencies = new Map>(); + #watchFilesPerKey = new Map>(); + + get(path: string): OnLoadResult | undefined { + return this.#loadResults.get(path); + } + + async put(path: string, result: OnLoadResult): Promise { + const previousWatchFiles = this.#watchFilesPerKey.get(path); + + if (result.errors && result.errors.length > 0) { + if (previousWatchFiles) { + result.watchFiles = Array.from( + new Set([...(result.watchFiles ?? []), ...previousWatchFiles]), + ); + } + } + + const currentNormalizedWatchFiles = new Set(result.watchFiles?.map(normalize) ?? []); + + // Clean up any previous file dependencies that are no longer referenced + if (previousWatchFiles) { + for (const watchFile of previousWatchFiles) { + const normalizedWatchFile = normalize(watchFile); + if (!currentNormalizedWatchFiles.has(normalizedWatchFile)) { + const affected = this.#fileDependencies.get(normalizedWatchFile); + if (affected) { + affected.delete(path); + if (affected.size === 0) { + this.#fileDependencies.delete(normalizedWatchFile); + } + } + } + } + } + + if (result.watchFiles && result.watchFiles.length > 0) { + this.#watchFilesPerKey.set(path, [...result.watchFiles]); + } else { + this.#watchFilesPerKey.delete(path); + } + + this.#loadResults.set(path, result); + if (result.watchFiles) { + for (const watchFile of result.watchFiles) { + // Normalize the watch file path to ensure OS consistent paths + const normalizedWatchFile = normalize(watchFile); + let affected = this.#fileDependencies.get(normalizedWatchFile); + if (affected === undefined) { + affected = new Set(); + this.#fileDependencies.set(normalizedWatchFile, affected); + } + affected.add(path); + } + } + } + + invalidate(path: string): boolean { + const affectedPaths = this.#fileDependencies.get(path); + if (!affectedPaths) { + return false; + } + + for (const affected of affectedPaths) { + this.#loadResults.delete(affected); + } + + return true; + } + + get watchFiles(): string[] { + // this.#loadResults.keys() is not included here because the keys + // are namespaced request paths and not disk-based file paths. + return [...this.#fileDependencies.keys()]; + } + + clear(): void { + this.#loadResults.clear(); + this.#fileDependencies.clear(); + this.#watchFilesPerKey.clear(); + } +} diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts new file mode 100644 index 000000000000..8a8682beed61 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { MemoryLoadResultCache } from './load-result-cache'; + +describe('MemoryLoadResultCache', () => { + let cache: MemoryLoadResultCache; + + beforeEach(() => { + cache = new MemoryLoadResultCache(); + }); + + it('should store and retrieve results', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + }; + + await cache.put('file:/test/styles.css', result); + const cached = cache.get('file:/test/styles.css'); + + expect(cached).toBe(result); + }); + + it('should track watch files in fileDependencies', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/theme.json'], + }; + + await cache.put('file:/test/styles.css', result); + + expect(cache.watchFiles).toContain('/test/styles.css'); + expect(cache.watchFiles).toContain('/test/theme.json'); + }); + + it('should invalidate cached results when a dependency changes', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/theme.json'], + }; + + await cache.put('file:/test/styles.css', result); + expect(cache.get('file:/test/styles.css')).toBe(result); + + const invalidated = cache.invalidate('/test/theme.json'); + expect(invalidated).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + }); + + it('should preserve previous watch files when caching an error result', async () => { + const successResult = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/theme.json'], + }; + + await cache.put('file:/test/styles.css', successResult); + cache.invalidate('/test/theme.json'); + + // Simulate an incremental rebuild error result that only has the entry file in watchFiles + const errorResult = { + errors: [{ text: 'Syntax error in theme.json' }], + watchFiles: ['/test/styles.css'], + }; + + await cache.put('file:/test/styles.css', errorResult); + + // Both the entry file and the previous dependency should be tracked + expect(cache.watchFiles).toContain('/test/styles.css'); + expect(cache.watchFiles).toContain('/test/theme.json'); + + // Invalidating the dependency should clear the cached error result + const invalidated = cache.invalidate('/test/theme.json'); + expect(invalidated).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + }); + + it('should clean up previous file dependencies when put updates watchFiles', async () => { + const initialResult = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/old-dep.json'], + }; + + await cache.put('file:/test/styles.css', initialResult); + expect(cache.watchFiles).toContain('/test/old-dep.json'); + + // Update with new watch files (not containing old-dep.json) + const updatedResult = { + contents: 'body { color: blue; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/new-dep.json'], + }; + + await cache.put('file:/test/styles.css', updatedResult); + expect(cache.watchFiles).not.toContain('/test/old-dep.json'); + expect(cache.watchFiles).toContain('/test/new-dep.json'); + + // Invalidating old dependency should not affect the cache + expect(cache.invalidate('/test/old-dep.json')).toBeFalse(); + expect(cache.get('file:/test/styles.css')).toBe(updatedResult); + + // Invalidating new dependency should invalidate the cache + expect(cache.invalidate('/test/new-dep.json')).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + // Invalidating a file marks its cached results stale, but preserves watch file tracking + // so the file watcher continues monitoring the dependency for subsequent changes until + // a new build pass (via put) updates the active dependencies. + expect(cache.watchFiles).toContain('/test/new-dep.json'); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/loader-import-attribute-plugin.ts b/packages/angular/build/src/tools/esbuild/loader-import-attribute-plugin.ts new file mode 100644 index 000000000000..8a9588b63a7b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/loader-import-attribute-plugin.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Loader, Plugin } from 'esbuild'; +import { readFile } from 'node:fs/promises'; + +const SUPPORTED_LOADERS: Loader[] = ['base64', 'binary', 'dataurl', 'file', 'text']; + +export function createLoaderImportAttributePlugin(): Plugin { + return { + name: 'angular-loader-import-attributes', + setup(build) { + build.onLoad({ filter: /./ }, async (args) => { + const loader = args.with['loader'] as Loader | undefined; + if (!loader) { + return undefined; + } + + if (!SUPPORTED_LOADERS.includes(loader)) { + return { + errors: [ + { + text: 'Unsupported loader import attribute', + notes: [ + { text: 'Attribute value must be one of: ' + SUPPORTED_LOADERS.join(', ') }, + ], + }, + ], + }; + } + + return { + contents: await readFile(args.path), + loader, + }; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts new file mode 100644 index 000000000000..7eaf11806f1d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview + * Implements a generic, two-tier (L1 memory + L2 persistent disk store) caching system for + * esbuild plugin `OnLoadResult` outcomes across the `@angular/build` compiler pipeline. + * + * Supported Module Types: + * 1. **Disk File Modules** (`file:` namespace): Standard disk-based source files (TS, JS, CSS, Sass, Less). + * Cache keys are computed using the root `globalConfigHash`, file path, and file content. Validity is + * verified via fast-path metadata (`mtimeMs` + `size`) with fallback to content hashing (`sha256`). + * 2. **Custom Plugin Namespace Modules** (e.g. `angular:script/global`, `sass:`): Modules loaded through + * custom esbuild namespaces that resolve to disk source files. + * 3. **Virtual Modules & Remote Resources** (e.g. `angular:styles/component`, `css-inline-fonts`): Synthetic + * in-memory modules or remote asset declarations whose compiled outcomes depend on parent source file + * dependencies (`watchFiles`) or global configuration options (`globalConfigHash`). + * + * Key Exported Types: + * - {@link PersistentLoadResultCache}: Primary two-tier cache manager implementing `LoadResultCache`. + * - {@link CachedLoadResultEntry}: Serialized structure persisted to disk for cached esbuild `OnLoadResult` items. + * - {@link CachedDependencyMetadata}: Per-dependency file metadata (`hash`, `mtimeMs`, `size`) used for cache validation and healing. + */ + +import type { Loader, OnLoadResult, PartialMessage } from 'esbuild'; +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Cache as PersistentCacheStore } from '../../utils/cache'; +import { MAX_CONCURRENT_READS, mapConcurrent, runConcurrent } from '../../utils/concurrency'; +import { calculateHash, createContentHash } from '../../utils/hash'; +import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; + +/** + * Metadata for a single watch file dependency. + */ +export interface CachedDependencyMetadata { + hash: string; + mtimeMs: number; + size: number; +} + +/** + * Serialized representation of any esbuild load result stored in persistent cache. + */ +export interface CachedLoadResultEntry { + /** Compiled output string or binary data */ + contents: string | Uint8Array; + + /** esbuild loader type */ + loader?: Loader; + + /** Absolute paths of all imported/watched dependency files */ + watchFiles: string[]; + + /** Absolute paths of all watched directories */ + watchDirs?: string[]; + + /** Map of watchFile absolute paths to dependency metadata */ + watchFilesMetadata: Record; + + /** Warnings emitted during load processing */ + warnings?: PartialMessage[]; + + /** Errors emitted during load processing */ + errors?: PartialMessage[]; +} + +/** + * Calculates a unique cache key from the global configuration hash and path. + */ +function calculateCacheKey(globalConfigHash: string, path: string): string { + const hasher = createContentHash(); + hasher.update(globalConfigHash); + hasher.update('\0'); + hasher.update(path); + + return hasher.digest(); +} + +/** + * Normalizes a namespaced cache key into a valid disk file path if one exists. + * Handles 'file:' URIs, OS platform differences, and custom plugin namespaces. + */ +export function extractDiskFilePath(path: string): string | undefined { + if (path.startsWith('file:')) { + const urlStr = path.startsWith('file://') ? path : 'file://' + path.slice(5); + try { + return fileURLToPath(urlStr); + } catch { + const candidate = path.slice(5); + + return isAbsolute(candidate) ? candidate : undefined; + } + } + + // Handle custom namespace prefix (e.g. "sass:/path/to/file") + // Ensure colonIndex > 1 to avoid treating Windows drive letters (e.g. "C:/") as namespace prefixes. + const colonIndex = path.indexOf(':'); + if (colonIndex > 1) { + const candidatePath = path.slice(colonIndex + 1); + if (isAbsolute(candidatePath)) { + return candidatePath; + } + } + + return isAbsolute(path) ? path : undefined; +} + +/** + * Validates that all imported watch files exist on disk and their contents match. + * Performs a fast-path metadata check (mtime + size) first, falling back to content hashing. + * Heals/updates the cached metadata on disk if the content hash was valid but the metadata changed. + */ +async function validateAndHealCacheEntry( + watchFilesMetadata: Record | undefined, + store: PersistentCacheStore, + cacheKey: string, + cached: CachedLoadResultEntry, +): Promise { + if (!watchFilesMetadata) { + return false; + } + + const watchFiles = Object.keys(watchFilesMetadata); + let healed = false; + + const isValidResults = await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { + try { + const stats = await stat(filePath); + const expected = watchFilesMetadata[filePath]; + if (!expected) { + return false; + } + + // 1. Fast Path: size and mtime match + if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) { + return true; + } + + // 2. Slow Path: content hash fallback + const currentContent = await readFile(filePath); + const currentHash = calculateHash(currentContent); + if (currentHash === expected.hash) { + // Heal cache entry with new metadata + watchFilesMetadata[filePath] = { + ...expected, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + healed = true; + + return true; + } + + return false; + } catch { + return false; + } + }); + + if (isValidResults.some((isValid) => !isValid)) { + return false; + } + + if (healed) { + try { + await store.put(cacheKey, cached); + } catch { + // Ignore errors writing healed entries + } + } + + return true; +} + +/** + * Computes metadata (content hashes, mtime, size) for an array of watch file paths. + * Processes files with a sliding worker pool of 16 concurrent operations. + */ +async function computeMetadataForWatchFiles( + watchFiles: string[], + knownContents?: Map, +): Promise> { + const watchFilesMetadata: Record = {}; + + await runConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { + try { + const knownContent = knownContents?.get(filePath); + const [content, stats] = await Promise.all([ + knownContent !== undefined ? knownContent : readFile(filePath), + stat(filePath), + ]); + const hash = calculateHash(content); + watchFilesMetadata[filePath] = { + hash, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + } catch { + // Ignore unreadable files + } + }); + + return watchFilesMetadata; +} + +export class PersistentLoadResultCache implements LoadResultCache { + private readonly memoryCache = new MemoryLoadResultCache(); + + constructor( + private readonly persistentStore?: PersistentCacheStore, + private readonly globalConfigHash: string = '', + ) {} + + /** + * Retrieves a load result from cache. + * Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk + * store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata. + */ + async get(path: string): Promise { + // 1. Check L1 Memory Cache + const memoryResult = this.memoryCache.get(path); + if (memoryResult) { + return memoryResult; + } + + if (!this.persistentStore) { + return undefined; + } + + // 2. Check L2 Persistent Disk Cache + const cacheKey = calculateCacheKey(this.globalConfigHash, path); + const cached = await this.persistentStore.get(cacheKey); + + if ( + cached && + (await validateAndHealCacheEntry( + cached.watchFilesMetadata, + this.persistentStore, + cacheKey, + cached, + )) + ) { + const result: OnLoadResult = { + contents: cached.contents, + loader: cached.loader, + watchFiles: cached.watchFiles, + watchDirs: cached.watchDirs, + warnings: cached.warnings, + errors: cached.errors, + }; + + // Populate L1 Memory Cache for subsequent lookups + await this.memoryCache.put(path, result); + + return result; + } + + return undefined; + } + + /** + * Stores a load result in both L1 memory cache and L2 persistent disk store. + */ + async put(path: string, result: OnLoadResult): Promise { + await this.memoryCache.put(path, result); + + // Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers) + if (this.persistentStore && result.contents !== undefined) { + let content: string | Uint8Array = ''; + const filePath = extractDiskFilePath(path); + if (filePath) { + try { + content = await readFile(filePath); + } catch { + // Skip L2 persistent store if target disk file cannot be read + return; + } + } + + const cacheKey = calculateCacheKey(this.globalConfigHash, path); + + // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile) + // during dependency watch file metadata computation. + const knownContents = filePath + ? new Map([[filePath, content]]) + : undefined; + const allWatchFiles = Array.from( + new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), + ); + const watchFilesMetadata = await computeMetadataForWatchFiles(allWatchFiles, knownContents); + + await this.persistentStore.put(cacheKey, { + contents: result.contents, + loader: result.loader, + watchFiles: result.watchFiles ?? [], + watchDirs: result.watchDirs, + watchFilesMetadata, + warnings: result.warnings, + errors: result.errors, + }); + } + } + + /** + * Invalidates cached entries affected by a modified dependency file during watch mode. + * + * Note: Invalidation of L1 memory cache is sufficient for active watch mode. + * Cross-process/cold start stale entries in L2 persistent store are automatically handled + * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`). + */ + invalidate(path: string): boolean { + return this.memoryCache.invalidate(path); + } + + get watchFiles(): ReadonlyArray { + return this.memoryCache.watchFiles; + } +} diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts new file mode 100644 index 000000000000..b41add55031d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { OnLoadResult } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Cache as PersistentCacheStore } from '../../utils/cache'; +import { initializeHash } from '../../utils/hash'; +import { + type CachedLoadResultEntry, + PersistentLoadResultCache, + extractDiskFilePath, +} from './persistent-load-result-cache'; + +describe('extractDiskFilePath', () => { + it('should extract disk file path for file: URIs', () => { + const filePath = '/Users/test/project/file.js'; + expect(extractDiskFilePath(`file://${filePath}`)).toBe(filePath); + }); + + it('should extract disk file path for custom plugin namespaces', () => { + const filePath = '/Users/test/project/file.js'; + expect(extractDiskFilePath(`sass:${filePath}`)).toBe(filePath); + }); + + it('should not strip Windows drive letter as a namespace prefix', () => { + const winPath = 'C:/Users/test/project/file.js'; + expect(extractDiskFilePath(winPath)).not.toBe('/Users/test/project/file.js'); + }); +}); + +describe('PersistentLoadResultCache', () => { + let mockStore: Map; + let persistentStore: PersistentCacheStore; + let tmpDir: string; + let file1: string; + + beforeAll(async () => { + await initializeHash(); + }); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-')); + file1 = path.join(tmpDir, 'test.js'); + fs.writeFileSync(file1, 'console.log("hello");'); + + mockStore = new Map(); + persistentStore = { + async get(key: string) { + return mockStore.get(key); + }, + async put(key: string, value: CachedLoadResultEntry) { + mockStore.set(key, value); + }, + } as unknown as PersistentCacheStore; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return undefined on L1 and L2 cache miss', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const result = await cache.get(file1); + expect(result).toBeUndefined(); + }); + + it('should hit L2 persistent store and return cached output when dependencies are valid', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Create a second cache instance (simulating cold start) + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('console.log("hello");'); + expect(hit?.loader).toBe('js'); + }); + + it('should hit L2 persistent store and return cached output for empty file contents', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: '', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Create a second cache instance (simulating cold start) + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe(''); + expect(hit?.loader).toBe('css'); + }); + + it('should preserve watchDirs when hitting L2 persistent store', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + watchDirs: [tmpDir], + }; + + await cache.put(file1, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.watchDirs).toEqual([tmpDir]); + }); + + it('should hit L2 persistent store for custom plugin namespaces backed by disk files', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const customPath = `sass:${file1}`; + const sampleResult: OnLoadResult = { + contents: '.btn { color: red; }', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(customPath, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(customPath); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('.btn { color: red; }'); + expect(hit?.loader).toBe('css'); + }); + + it('should hit L2 persistent store for virtual modules without disk representation', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const virtualPath = 'angular:styles/component:css;0;data'; + const sampleResult: OnLoadResult = { + contents: 'h1 { margin: 0; }', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(virtualPath, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(virtualPath); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('h1 { margin: 0; }'); + expect(hit?.loader).toBe('css'); + }); + + it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Modify dependency file content + fs.writeFileSync(file1, 'console.log("world");'); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeUndefined(); + }); + + it('should heal the cache entry with new metadata if content hash is still valid after mtime changes', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Retrieve the cache key from store + const keys = Array.from(mockStore.keys()); + expect(keys.length).toBe(1); + const cacheKey = keys[0]; + + const initialEntry = mockStore.get(cacheKey); + const initialMtime = initialEntry?.watchFilesMetadata[file1].mtimeMs; + expect(initialMtime).toBeDefined(); + + // Artificially change file modification time without changing content + const futureTime = new Date(Date.now() + 50000); + fs.utimesSync(file1, futureTime, futureTime); + + const statsAfter = fs.statSync(file1); + expect(statsAfter.mtimeMs).not.toEqual(initialMtime as number); + + // Query cold cache to trigger fallback content hash check and healing + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('console.log("hello");'); + + // Verify metadata was healed in-place in the persistent store + const healedEntry = mockStore.get(cacheKey); + expect(healedEntry?.watchFilesMetadata[file1].mtimeMs).toEqual(statsAfter.mtimeMs); + }); + + it('should safely handle corrupted cache entries with missing watchFilesMetadata', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Corrupt entry in store by removing watchFilesMetadata + const keys = Array.from(mockStore.keys()); + expect(keys.length).toBe(1); + const entry = mockStore.get(keys[0]); + expect(entry).toBeDefined(); + if (entry) { + delete (entry as Partial).watchFilesMetadata; + } + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeUndefined(); + }); + + it('should skip L2 store put if target file cannot be read from disk', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const nonExistentFile = path.join(tmpDir, 'non-existent.js'); + const sampleResult: OnLoadResult = { + contents: 'console.log("missing");', + loader: 'js', + watchFiles: [], + }; + + await cache.put(nonExistentFile, sampleResult); + + expect(mockStore.size).toBe(0); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts new file mode 100644 index 000000000000..198d44f0c6c4 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; +import { toPosixPath } from '../../utils/path'; + +/** + * Filter matching the Domino bundle import inside `@angular/platform-server`. + */ +const BUNDLED_DOMINO_FILTER = /[\\/]third_party[\\/]domino[\\/]bundled-domino\.mjs$/; + +/** + * The property name on `globalThis` where the Domino instance is stored. + */ +const DOMINO_GLOBAL_PROPERTY = 'ɵdomino'; + +/** + * Creates an esbuild plugin that attaches the Domino instance to `globalThis` when + * `@angular/platform-server/init` is bundled into the server polyfills bundle. + * + * @returns An esbuild plugin. + */ +export function createPlatformServerDominoExposePlugin(): Plugin { + return { + name: 'angular-platform-server-domino-expose', + setup(build) { + build.onResolve({ filter: BUNDLED_DOMINO_FILTER }, (args) => + args.namespace !== 'angular:domino-expose' && args.kind === 'import-statement' + ? { + path: 'angular:platform-server-domino', + namespace: 'angular:domino-expose', + pluginData: { + importPath: args.path, + resolveDir: args.resolveDir, + }, + } + : null, + ); + + build.onLoad({ filter: /./, namespace: 'angular:domino-expose' }, (args) => { + const { importPath, resolveDir } = args.pluginData as { + importPath: string; + resolveDir: string; + }; + + return { + contents: [ + `import domino from ${JSON.stringify(toPosixPath(importPath))};`, + `globalThis['${DOMINO_GLOBAL_PROPERTY}'] = domino;`, + `export default domino;`, + ].join('\n'), + loader: 'js', + resolveDir, + }; + }); + }, + }; +} + +/** + * Creates an esbuild plugin that redirects imports of Domino in `@angular/platform-server` + * to the single copy attached to `globalThis` by the server polyfills bundle. + * + * @returns An esbuild plugin. + */ +export function createPlatformServerDominoRedirectPlugin(): Plugin { + return { + name: 'angular-platform-server-domino-redirect', + setup(build) { + build.onResolve({ filter: BUNDLED_DOMINO_FILTER }, (args) => + args.kind === 'import-statement' + ? { + path: 'angular:platform-server-domino', + namespace: 'angular:platform-server-domino', + } + : null, + ); + + build.onLoad({ filter: /./, namespace: 'angular:platform-server-domino' }, () => ({ + contents: `export default globalThis['${DOMINO_GLOBAL_PROPERTY}'];`, + loader: 'js', + })); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.ts b/packages/angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.ts new file mode 100644 index 000000000000..9f813d4e9a70 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; + +const RXJS_ESM_RESOLUTION = Symbol('RXJS_ESM_RESOLUTION'); + +/** + * Creates a plugin that forces ESM resolution of rxjs. + * This is needed as when targeting node, the CJS version is used to the current package conditional exports. + * @see: https://github.com/ReactiveX/rxjs/blob/2947583bb33e97f3db9e6d9f6cea70c62a173060/package.json#L19. + * + * NOTE: This can be removed when and if rxjs adds an import condition that allows ESM usage on Node.js. + * + * @returns An esbuild plugin. + */ +export function createRxjsEsmResolutionPlugin(): Plugin { + return { + name: 'angular-rxjs-resolution', + setup(build) { + build.onResolve({ filter: /^rxjs/ }, async (args) => { + if (args.pluginData?.[RXJS_ESM_RESOLUTION]) { + return null; + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[RXJS_ESM_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + result.path = result.path.replace(/([\\/]dist[\\/])cjs([\\/])/, '$1esm$2'); + + return result; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts b/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts new file mode 100644 index 000000000000..8d26782017c5 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; + +/** + * Generates an esbuild plugin that appends metadata to the output bundle, + * marking it with server-side rendering (SSR) details for Angular SSR scenarios. + * + * @param options Optional configuration object. + * - `ssrEntryBundle`: If `true`, marks the bundle as an SSR entry point. + * + * @remarks We can't rely on `platform: node` or `platform: neutral`, as the latter + * is used for non-SSR-related code too (e.g., global scripts). + * @returns An esbuild plugin that injects SSR metadata into the build result's metafile. + */ +export function createServerBundleMetadata(options?: { ssrEntryBundle?: boolean }): Plugin { + return { + name: 'angular-server-bundle-metadata', + setup(build) { + build.onEnd((result) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const metafile = result.metafile as any; + if (metafile) { + metafile['ng-ssr-entry-bundle'] = !!options?.ssrEntryBundle; + metafile['ng-platform-server'] = true; + } + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts b/packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts new file mode 100644 index 000000000000..7bfc4c4a9f6a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'esbuild'; + +/** + * The field identifier for the sourcemap Chrome Devtools ignore list extension. + * + * Following the naming conventions from https://sourcemaps.info/spec.html#h.ghqpj1ytqjbm + */ +const IGNORE_LIST_ID = 'x_google_ignoreList'; + +/** + * The UTF-8 bytes for the node modules check text used to avoid unnecessary parsing + * of a full source map if not present in the source map data. + */ +const NODE_MODULE_BYTES = Buffer.from('node_modules/', 'utf-8'); + +/** + * The UTF-8 bytes for the "sources" property key used to locate the sources array. + */ +const SOURCES_KEY_BYTES = Buffer.from('"sources"', 'utf-8'); + +/** + * The UTF-8 bytes for the ignore list identifier to check if already present. + */ +const IGNORE_LIST_BYTES = Buffer.from(`"${IGNORE_LIST_ID}"`, 'utf-8'); + +/** + * Minimal sourcemap object required to create the ignore list. + */ +interface SourceMap { + sources: string[]; + [IGNORE_LIST_ID]?: number[]; +} + +function extractSources(contents: Buffer): string[] | undefined { + const sourcesKeyIndex = contents.indexOf(SOURCES_KEY_BYTES); + if (sourcesKeyIndex === -1) { + return undefined; + } + + // Find the ':' after "sources" + let colonIndex = sourcesKeyIndex + SOURCES_KEY_BYTES.length; + while (colonIndex < contents.length && contents[colonIndex] <= 0x20) { + colonIndex++; + } + if (contents[colonIndex] !== 0x3a /* : */) { + return undefined; + } + + // Find the '[' for the array + let arrayStartIndex = colonIndex + 1; + while (arrayStartIndex < contents.length && contents[arrayStartIndex] <= 0x20) { + arrayStartIndex++; + } + if (contents[arrayStartIndex] !== 0x5b /* [ */) { + return undefined; + } + + // Scan until matching ']' + let depth = 0; + let inString = false; + for (let i = arrayStartIndex; i < contents.length; i++) { + const byte = contents[i]; + if (inString) { + if (byte === 0x5c /* \ */) { + i++; // skip escaped character + } else if (byte === 0x22 /* " */) { + inString = false; + } + } else if (byte === 0x22 /* " */) { + inString = true; + } else if (byte === 0x5b /* [ */) { + depth++; + } else if (byte === 0x5d /* ] */) { + depth--; + if (depth === 0) { + try { + const slice = contents.toString('utf-8', arrayStartIndex, i + 1); + const parsed = JSON.parse(slice); + + return Array.isArray(parsed) && parsed.every((s) => typeof s === 'string') + ? (parsed as string[]) + : undefined; + } catch { + return undefined; + } + } + } + } + + return undefined; +} + +function updateSourcemapFast(contents: Buffer, ignoreList: readonly number[]): Buffer | undefined { + let braceIndex = 0; + while (braceIndex < contents.length && contents[braceIndex] <= 0x20) { + braceIndex++; + } + if (contents[braceIndex] !== 0x7b /* { */) { + return undefined; + } + + const injection = Buffer.from(`"${IGNORE_LIST_ID}":${JSON.stringify(ignoreList)},`, 'utf-8'); + + return Buffer.concat([ + contents.subarray(0, braceIndex + 1), + injection, + contents.subarray(braceIndex + 1), + ]); +} + +/** + * Creates an esbuild plugin that updates generated sourcemaps to include the Chrome + * DevTools ignore list extension. All source files that originate from a node modules + * directory are added to the ignore list by this plugin. + * + * For more information, see https://developer.chrome.com/articles/x-google-ignore-list/ + * @returns An esbuild plugin. + */ +export function createSourcemapIgnorelistPlugin(): Plugin { + return { + name: 'angular-sourcemap-ignorelist', + setup(build): void { + if (!build.initialOptions.sourcemap) { + return; + } + + build.onEnd((result) => { + if (!result.outputFiles) { + return; + } + + for (const file of result.outputFiles) { + // Only process sourcemap files + if (!file.path.endsWith('.map')) { + continue; + } + + // Create a Buffer object that shares the memory of the output file contents + const contents = Buffer.from( + file.contents.buffer, + file.contents.byteOffset, + file.contents.byteLength, + ); + + // Avoid parsing sourcemaps that have no node modules references + if (!contents.includes(NODE_MODULE_BYTES)) { + continue; + } + + let fastPathSuccess = false; + if (!contents.includes(IGNORE_LIST_BYTES)) { + const sources = extractSources(contents); + if (sources) { + const ignoreList: number[] = []; + for (let index = 0; index < sources.length; ++index) { + const location = sources[index].indexOf('node_modules/'); + if (location === 0 || (location > 0 && sources[index][location - 1] === '/')) { + ignoreList.push(index); + } + } + + if (ignoreList.length === 0) { + continue; + } + + const updated = updateSourcemapFast(contents, ignoreList); + if (updated) { + file.contents = updated; + fastPathSuccess = true; + } + } + } + + if (fastPathSuccess) { + continue; + } + + // Fallback to full JSON parse/stringify if fast scanning or splicing fails + const map = JSON.parse(contents.toString('utf-8')) as SourceMap; + if (map[IGNORE_LIST_ID]) { + continue; + } + + const ignoreList = []; + + // Check and store the index of each source originating from a node modules directory + for (let index = 0; index < map.sources.length; ++index) { + const location = map.sources[index].indexOf('node_modules/'); + if (location === 0 || (location > 0 && map.sources[index][location - 1] === '/')) { + ignoreList.push(index); + } + } + + // Avoid regenerating the source map if nothing changed + if (ignoreList.length === 0) { + continue; + } + + // Update the sourcemap in the output file + map[IGNORE_LIST_ID] = ignoreList; + file.contents = Buffer.from(JSON.stringify(map), 'utf-8'); + } + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts new file mode 100644 index 000000000000..bb5c945bab7d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { BuildOptions, Plugin } from 'esbuild'; +import path from 'node:path'; +import { NormalizedCachedOptions } from '../../../utils/normalize-cache'; +import { PostcssConfiguration } from '../../../utils/postcss-configuration'; +import { LoadResultCache } from '../load-result-cache'; +import { createCssInlineFontsPlugin } from './css-inline-fonts-plugin'; +import { CssStylesheetLanguage } from './css-language'; +import { createCssResourcePlugin } from './css-resource-plugin'; +import { LessStylesheetLanguage } from './less-language'; +import { SassStylesheetLanguage } from './sass-language'; +import { StylesheetPluginFactory, StylesheetPluginsass } from './stylesheet-plugin-factory'; + +export interface BundleStylesheetOptions { + workspaceRoot: string; + optimization: boolean; + inlineFonts: boolean; + dataurl?: boolean; + preserveSymlinks?: boolean; + sourcemap: boolean | 'external' | 'inline' | 'linked'; + sourcesContent?: boolean; + outputNames: { bundles: string; media: string }; + includePaths?: string[]; + sass?: StylesheetPluginsass; + externalDependencies?: string[]; + target: string[]; + tailwindConfiguration?: { file: string; package: string }; + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + publicPath?: string; + cacheOptions: NormalizedCachedOptions; +} + +export function createStylesheetBundleOptions( + options: BundleStylesheetOptions, + cache?: LoadResultCache, + inlineComponentData?: Record, +): BuildOptions & { plugins: NonNullable } { + // Ensure preprocessor include paths are absolute based on the workspace root + const includePaths = options.includePaths?.map((includePath) => + path.resolve(options.workspaceRoot, includePath), + ); + + const pluginFactory = new StylesheetPluginFactory( + { + sourcemap: !!options.sourcemap, + includePaths, + inlineComponentData, + tailwindConfiguration: options.tailwindConfiguration, + postcssConfiguration: options.postcssConfiguration, + sass: options.sass, + }, + cache, + ); + + const plugins: Plugin[] = [ + pluginFactory.create(SassStylesheetLanguage), + pluginFactory.create(LessStylesheetLanguage), + pluginFactory.create(CssStylesheetLanguage), + createCssResourcePlugin(cache, options.dataurl), + ]; + + if (options.inlineFonts) { + plugins.unshift(createCssInlineFontsPlugin({ cache, cacheOptions: options.cacheOptions })); + } + + return { + absWorkingDir: options.workspaceRoot, + bundle: true, + entryNames: options.outputNames.bundles, + assetNames: options.outputNames.media, + logLevel: 'silent', + minify: options.optimization, + metafile: true, + sourcemap: options.sourcemap, + sourcesContent: options.sourcesContent, + outdir: options.workspaceRoot, + write: false, + platform: 'browser', + target: options.target, + preserveSymlinks: options.preserveSymlinks, + external: options.externalDependencies, + publicPath: options.publicPath, + conditions: ['style', 'sass', 'less', options.optimization ? 'production' : 'development'], + mainFields: ['style', 'sass'], + // Unlike JS, CSS does not have implicit file extensions in the general case. + // Preprocessor specific behavior is handled in each stylesheet language plugin. + resolveExtensions: [], + plugins, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts new file mode 100644 index 000000000000..cbdba93c4940 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin, PluginBuild } from 'esbuild'; +import { InlineFontsProcessor } from '../../../utils/index-file/inline-fonts'; +import { NormalizedCachedOptions } from '../../../utils/normalize-cache'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +/** + * Options for the createCssInlineFontsPlugin + * @see createCssInlineFontsPlugin + */ +export interface CssInlineFontsPluginOptions { + /** Disk cache normalized options */ + cacheOptions?: NormalizedCachedOptions; + + /** Load results cache. */ + cache?: LoadResultCache; +} + +/** + * Creates an esbuild {@link Plugin} that inlines fonts imported via import-rule. + * within the build configuration. + */ +export function createCssInlineFontsPlugin({ + cache, + cacheOptions, +}: CssInlineFontsPluginOptions): Plugin { + return { + name: 'angular-css-inline-fonts-plugin', + setup(build: PluginBuild): void { + const inlineFontsProcessor = new InlineFontsProcessor({ cache: cacheOptions, minify: false }); + + build.onResolve({ filter: /fonts\.googleapis\.com|use\.typekit\.net/ }, (args) => { + // Only attempt to resolve import-rule tokens which only exist inside CSS. + if (args.kind !== 'import-rule') { + return null; + } + + if (!inlineFontsProcessor.canInlineRequest(args.path)) { + return null; + } + + return { + path: args.path, + namespace: 'css-inline-fonts', + }; + }); + + build.onLoad( + { filter: /./, namespace: 'css-inline-fonts' }, + createCachedLoad(cache, async (args) => { + try { + return { + contents: await inlineFontsProcessor.processURL(args.path), + loader: 'css', + }; + } catch (error) { + return { + loader: 'css', + errors: [ + { + text: `Failed to inline external stylesheet '${args.path}'.`, + notes: error instanceof Error ? [{ text: error.toString() }] : undefined, + }, + ], + }; + } + }), + ); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-language.ts new file mode 100644 index 000000000000..f631bab2247a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-language.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { StylesheetLanguage } from './stylesheet-plugin-factory'; + +export const CssStylesheetLanguage = Object.freeze({ + name: 'css', + componentFilter: /^css;/, + fileFilter: /\.css$/, +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts new file mode 100644 index 000000000000..ced6422c3fd3 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin, PluginBuild } from 'esbuild'; +import { readFile } from 'node:fs/promises'; +import { extname, join, relative } from 'node:path'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +const CSS_RESOURCE_NAMESPACE = 'angular:css-resource'; + +/** + * Symbol marker used to indicate CSS resource resolution is being attempted. + * This is used to prevent an infinite loop within the plugin's resolve hook. + */ +const CSS_RESOURCE_RESOLUTION = Symbol('CSS_RESOURCE_RESOLUTION'); + +/** + * Creates an esbuild {@link Plugin} that loads all CSS url token references using the + * built-in esbuild `file` loader. A plugin is used to allow for all file extensions + * and types to be supported without needing to manually specify all extensions + * within the build configuration. + * + * @param cache An optional load result cache. + * @param dataurl If true, resources will be loaded with the 'dataurl' loader to inline them as base64 data URIs. + * @returns An esbuild {@link Plugin} instance. + */ +export function createCssResourcePlugin(cache?: LoadResultCache, dataurl?: boolean): Plugin { + return { + name: 'angular-css-resource', + setup(build: PluginBuild): void { + build.onResolve({ filter: /.*/ }, async (args) => { + const { importer, path, kind, resolveDir, namespace, pluginData = {} } = args; + + // Only attempt to resolve url tokens which only exist inside CSS. + // Also, skip this plugin if already attempting to resolve the url-token. + if (kind !== 'url-token' || pluginData[CSS_RESOURCE_RESOLUTION]) { + return null; + } + + let [containingDir, resourceUrl] = path.split('||file:', 2); + if (resourceUrl === undefined) { + // This can happen due to early exit checks in rebasing-importer + // logic such as when the url is an external URL. + resourceUrl = containingDir; + containingDir = ''; + } + + // If root-relative, absolute or protocol relative url, mark as external to leave the + // path/URL in place. + if (/^((?:\w+:)?\/\/|data:|chrome:|#|\/)/.test(resourceUrl)) { + return { + path: resourceUrl, + external: true, + }; + } + + pluginData[CSS_RESOURCE_RESOLUTION] = true; + + const result = await build.resolve(resourceUrl, { + importer, + kind, + namespace, + pluginData, + resolveDir: join(resolveDir, containingDir), + }); + + if (result.errors.length) { + const error = result.errors[0]; + if (resourceUrl[0] === '~') { + error.notes = [ + { + location: null, + text: 'You can remove the tilde and use a relative path to reference it, which should remove this error.', + }, + ]; + } else if (resourceUrl[0] === '^') { + error.notes = [ + { + location: null, + text: + 'You can remove the caret and add the path to the `externalDependencies` build option,' + + ' which should remove this error.', + }, + ]; + } + + const extension = importer && extname(importer); + if (extension !== '.css') { + error.notes.push({ + location: null, + text: 'Preprocessor stylesheets may not show the exact file location of the error.', + }); + } + } + + // Return results that are not files since these are most likely specific to another plugin + // and cannot be loaded by this plugin. + if (result.namespace !== 'file') { + return result; + } + + // All file results are considered CSS resources and will be loaded via the file loader + return { + ...result, + // Use a relative path to prevent fully resolved paths in the metafile (JSON stats file). + // This is only necessary for custom namespaces. esbuild will handle the file namespace. + path: relative(build.initialOptions.absWorkingDir ?? '', result.path), + namespace: CSS_RESOURCE_NAMESPACE, + }; + }); + + build.onLoad( + { filter: /./, namespace: CSS_RESOURCE_NAMESPACE }, + createCachedLoad(cache, async (args) => { + const resourcePath = join(build.initialOptions.absWorkingDir ?? '', args.path); + + return { + contents: await readFile(resourcePath), + loader: dataurl ? 'dataurl' : 'file', + watchFiles: [resourcePath], + }; + }), + ); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/less-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/less-language.ts new file mode 100644 index 000000000000..68cacd576b10 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/less-language.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Location, OnLoadResult, PluginBuild } from 'esbuild'; +import { readFile } from 'node:fs/promises'; +import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; + +/** + * The lazy-loaded instance of the less stylesheet preprocessor. + * It is only imported and initialized if a less stylesheet is used. + */ +let lessPreprocessor: typeof import('less') | undefined; + +interface LessException extends Error { + filename: string; + line: number; + column: number; + extract?: string[]; +} + +function isLessException(error: unknown): error is LessException { + return !!error && typeof error === 'object' && 'column' in error; +} + +export const LessStylesheetLanguage = Object.freeze({ + name: 'less', + componentFilter: /^less;/, + fileFilter: /\.less$/, + process(data, file, _, options, build) { + return compileString(data, file, options, build.resolve.bind(build)); + }, +}); + +async function compileString( + data: string, + filename: string, + options: StylesheetPluginOptions, + resolver: PluginBuild['resolve'], +): Promise { + try { + lessPreprocessor ??= (await import('less')).default; + } catch { + return { + errors: [ + { + text: 'Unable to load the "less" stylesheet preprocessor.', + location: null, + notes: [ + { + text: + 'Ensure that the "less" Node.js package is installed within the project. ' + + "If not present, installation via the project's package manager should resolve the error.", + }, + ], + }, + ], + }; + } + const less = lessPreprocessor; + + const resolverPlugin: Less.Plugin = { + install({ FileManager }, pluginManager): void { + const resolverFileManager = new (class extends FileManager { + override supportsSync(): boolean { + return false; + } + + override supports(): boolean { + return true; + } + + override async loadFile( + filename: string, + currentDirectory: string, + options: Less.LoadFileOptions, + environment: Less.Environment, + ): Promise { + // Attempt direct loading as a relative path to avoid resolution overhead + try { + return await super.loadFile(filename, currentDirectory, options, environment); + } catch (error) { + // Attempt a full resolution if not found + const fullResult = await resolver(filename, { + kind: 'import-rule', + resolveDir: currentDirectory, + }); + if (fullResult.path) { + return { + filename: fullResult.path, + contents: await readFile(fullResult.path, 'utf-8'), + }; + } + // Otherwise error by throwing the failing direct result + throw error; + } + } + })(); + + pluginManager.addFileManager(resolverFileManager); + }, + }; + + try { + const { imports, css } = await less.render(data, { + filename, + paths: options.includePaths, + plugins: [resolverPlugin], + rewriteUrls: 'all', + sourceMap: options.sourcemap + ? { + sourceMapFileInline: true, + outputSourceFiles: true, + } + : undefined, + } as Less.Options); + + return { + contents: css, + loader: 'css', + watchFiles: [filename, ...imports], + }; + } catch (error) { + if (isLessException(error)) { + const location = convertExceptionLocation(error); + + return { + errors: [ + { + text: error.message, + location, + }, + ], + loader: 'css', + watchFiles: location.file ? [filename, location.file] : [filename], + }; + } + + throw error; + } +} + +function convertExceptionLocation(exception: LessException): Partial { + return { + file: exception.filename, + line: exception.line, + column: exception.column, + // Middle element represents the line containing the exception + lineText: exception.extract && exception.extract[Math.trunc(exception.extract.length / 2)], + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts new file mode 100644 index 000000000000..5c09413bbb9b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts @@ -0,0 +1,302 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Location, OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { CompileResult, Exception, SourceSpan, Syntax } from 'sass-embedded'; +import { MemoryCache } from '../../../utils/cache'; +import type { SassCompiler } from '../../sass/sass-service'; +import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; + +let sassService: SassCompiler | undefined; +let sassServicePromise: Promise | undefined; +let resolutionCache: MemoryCache | undefined; +let packageRootCache: MemoryCache | undefined; + +function isSassException(error: unknown): error is Exception { + return !!error && typeof error === 'object' && 'sassMessage' in error; +} + +export function resetSassWorkerPoolCaches(): void { + resolutionCache?.clear(); + packageRootCache?.clear(); + if (sassService) { + sassService.clearCache(); + } else if (sassServicePromise) { + void sassServicePromise.then((service) => service.clearCache()); + } +} + +export function shutdownSassWorkerPool(): void { + resetSassWorkerPoolCaches(); + if (sassService) { + void sassService.close(); + sassService = undefined; + } else if (sassServicePromise) { + void sassServicePromise.then(shutdownSassWorkerPool); + } + sassServicePromise = undefined; +} + +export const SassStylesheetLanguage = Object.freeze({ + name: 'sass', + componentFilter: /^s[ac]ss;/, + fileFilter: /\.s[ac]ss$/, + process(data, file, format, options, build) { + const syntax = format === 'sass' ? 'indented' : 'scss'; + const resolveUrl = async (url: string, resolveDir: string | undefined) => { + const path = url.startsWith('pkg:') ? url.slice(4) : url; + const result = await build.resolve(path, { + kind: 'import-rule', + resolveDir, + }); + + return result; + }; + + return compileString( + data, + file, + syntax, + options, + resolveUrl, + build.initialOptions.absWorkingDir, + ); + }, +}); + +export function isPackageUrl(url: string): boolean { + if (url.startsWith('pkg:')) { + return true; + } + + return ( + url.length > 0 && + !url.startsWith('.') && + !url.startsWith('/') && + !url.startsWith('\\') && + !url.includes(':') + ); +} + +function parsePackageName(url: string): { packageName: string; readonly pathSegments: string[] } { + const parts = (url.startsWith('pkg:') ? url.slice(4) : url).split('/'); + const hasScope = parts.length >= 2 && parts[0][0] === '@'; + const [nameOrScope, nameOrFirstPath, ...pathPart] = parts; + const packageName = hasScope ? `${nameOrScope}/${nameOrFirstPath}` : nameOrScope; + + return { + packageName, + get pathSegments() { + return !hasScope && nameOrFirstPath ? [nameOrFirstPath, ...pathPart] : pathPart; + }, + }; +} + +/** + * Returns the scope that qualifies the cached package resolutions of a stylesheet. A stylesheet + * within `node_modules` uses the root of its enclosing package, since every file of a package + * resolves its dependencies against the same `node_modules` directories. All other stylesheets use + * the working directory, allowing component stylesheets to share package resolutions. + */ +export function getPackageScope( + containingPath: string | undefined, + workingDirectory: string | undefined, +): string { + // The directory segments of the stylesheet, excluding the file name + const segments = containingPath?.split(/[\\/]/).slice(0, -1) ?? []; + const index = segments.lastIndexOf('node_modules'); + if (index === -1) { + return workingDirectory ?? ''; + } + + const packageNameLength = segments[index + 1]?.[0] === '@' ? 2 : 1; + + return segments.slice(0, index + 1 + packageNameLength).join('/'); +} + +async function compileString( + data: string, + filePath: string, + syntax: Syntax, + options: StylesheetPluginOptions, + resolveUrl: (url: string, resolveDir: string | undefined) => Promise, + workingDirectory: string | undefined, +): Promise { + // Lazily load Sass when a Sass file is found + if (sassService === undefined) { + if (sassServicePromise === undefined) { + sassServicePromise = import('../../sass/sass-service').then( + (sassService) => new sassService.SassCompiler(true), + ); + } + try { + sassService = await sassServicePromise; + } finally { + sassServicePromise = undefined; + } + } + + // Caching follows Sass behavior where a given package url will always resolve to the same value + // regardless of its importer's path, except for importers within `node_modules`, which are + // scoped to their enclosing package. Relative paths are qualified with the containing URL. + // A null value indicates that the cached resolution attempt failed to find a location and + // later stage resolution should be attempted. This avoids potentially expensive repeat + // failing resolution attempts. + resolutionCache ??= new MemoryCache(); + packageRootCache ??= new MemoryCache(); + const currentResolutionCache = resolutionCache; + const currentPackageRootCache = packageRootCache; + const warnings: PartialMessage[] = []; + const { silenceDeprecations, futureDeprecations, fatalDeprecations } = options.sass ?? {}; + + try { + const { css, sourceMap, loadedUrls } = await sassService.compileStringAsync(data, { + url: pathToFileURL(filePath), + style: 'expanded', + syntax, + loadPaths: options.includePaths, + sourceMap: options.sourcemap, + sourceMapIncludeSources: options.sourcemap, + silenceDeprecations, + fatalDeprecations, + futureDeprecations, + quietDeps: true, + importers: [ + { + findFileUrl: (url, options) => { + const containingPath = + options.containingUrl?.protocol === 'file:' + ? fileURLToPath(options.containingUrl) + : undefined; + const resolveDir = containingPath ? dirname(containingPath) : workingDirectory; + const isPackage = isPackageUrl(url); + const scope = getPackageScope(containingPath, workingDirectory); + const cacheKey = isPackage + ? `${scope}:${url}` + : `${options.containingUrl?.href ?? ''}:${url}`; + + return currentResolutionCache.getOrCreate(cacheKey, async () => { + const result = await resolveUrl(url, resolveDir); + if (result.path) { + return pathToFileURL(result.path); + } + + // Check for package deep imports + if (!isPackage) { + return null; + } + + const { packageName, pathSegments } = parsePackageName(url); + + // Caching package root locations is particularly beneficial for `@material/*` packages + // which extensively use deep imports. + const packageRoot = await currentPackageRootCache.getOrCreate( + `${scope}:${packageName}`, + async () => { + // Use the required presence of a package root `package.json` file to resolve the location + const packageResult = await resolveUrl(packageName + '/package.json', resolveDir); + + return packageResult.path ? dirname(packageResult.path) : null; + }, + ); + + // Package not found could be because of an error or the specifier is intended to be found + // via a later stage of the resolution process (`loadPaths`, etc.). + // Errors are reported after the full completion of the resolution process. Exceptions for + // not found packages should not be raised here. + if (packageRoot) { + return pathToFileURL(join(packageRoot, ...pathSegments)); + } + + // Not found + return null; + }); + }, + }, + ], + logger: { + warn: (text, { deprecation, stack, span }) => { + const notes: PartialNote[] = []; + if (deprecation) { + notes.push({ text }); + } + if (stack && !span) { + notes.push({ text: stack }); + } + + warnings.push({ + text: deprecation ? 'Deprecation' : text, + location: span && spanToLocation(span), + notes, + }); + }, + }, + }); + + return { + loader: 'css', + contents: sourceMap ? `${css}\n${sourceMapToUrlComment(sourceMap)}` : css, + watchFiles: loadedUrls.map((url) => fileURLToPath(url)), + warnings, + }; + } catch (error) { + if (isSassException(error)) { + const location = spanToLocation(error.span); + const watchFiles = [filePath, ...extractFilesFromStack(error.sassStack)]; + if (location.file) { + watchFiles.push(location.file); + } + + return { + loader: 'css', + errors: [ + { + text: error.message, + location, + }, + ], + warnings, + watchFiles, + }; + } + + throw error; + } +} + +function spanToLocation(span: SourceSpan): Partial { + return { + file: span.url && fileURLToPath(span.url), + lineText: span.context, + // Sass line numbers are 0-based while esbuild's are 1-based + line: span.start.line + 1, + column: span.start.column, + }; +} + +function sourceMapToUrlComment(sourceMap: Exclude): string { + const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64'); + + return `/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${urlSourceMap} */`; +} + +function* extractFilesFromStack(stack: string): Iterable { + const lines = stack.split('\n'); + const cwd = process.cwd(); + + // Stack line has format of ` : ` + for (const line of lines) { + const match = line.trim().match(/^(.+?)\s+\d+:\d+/); + if (match) { + // Stack paths from sass are relative to the current working directory (not input file or workspace root) + yield join(cwd, match[1]); + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts new file mode 100644 index 000000000000..ec424208eadc --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts @@ -0,0 +1,425 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { PluginBuild } from 'esbuild'; +import assert from 'node:assert'; +import { statSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { SassCompiler } from '../../sass/sass-service'; +import { + SassStylesheetLanguage, + getPackageScope, + isPackageUrl, + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from './sass-language'; + +describe('sass-language', () => { + describe('isPackageUrl', () => { + it('should identify pkg: scheme URLs as package URLs', () => { + expect(isPackageUrl('pkg:@angular/material')).toBeTrue(); + expect(isPackageUrl('pkg:bootstrap')).toBeTrue(); + expect(isPackageUrl('pkg:@material/button/button')).toBeTrue(); + }); + + it('should identify bare specifiers as package URLs', () => { + expect(isPackageUrl('@angular/material')).toBeTrue(); + expect(isPackageUrl('@angular/material/button')).toBeTrue(); + expect(isPackageUrl('@material/button/button.scss')).toBeTrue(); + expect(isPackageUrl('bootstrap')).toBeTrue(); + expect(isPackageUrl('bootstrap/scss/bootstrap')).toBeTrue(); + }); + + it('should not identify relative paths as package URLs', () => { + expect(isPackageUrl('./styles.scss')).toBeFalse(); + expect(isPackageUrl('../shared/variables')).toBeFalse(); + expect(isPackageUrl('.hidden')).toBeFalse(); + expect(isPackageUrl('.\\styles.scss')).toBeFalse(); + expect(isPackageUrl('..\\shared\\variables')).toBeFalse(); + }); + + it('should not identify absolute paths or non-pkg URLs as package URLs', () => { + expect(isPackageUrl('/styles/theme.scss')).toBeFalse(); + expect(isPackageUrl('\\styles\\theme.scss')).toBeFalse(); + expect(isPackageUrl('file:///path/to/theme.scss')).toBeFalse(); + expect(isPackageUrl('http://example.com/styles.css')).toBeFalse(); + expect(isPackageUrl('https://example.com/styles.css')).toBeFalse(); + expect(isPackageUrl('C:\\path\\to\\theme.scss')).toBeFalse(); + expect(isPackageUrl('C:/path/to/theme.scss')).toBeFalse(); + }); + + it('should not identify empty string as a package URL', () => { + expect(isPackageUrl('')).toBeFalse(); + }); + }); + + describe('getPackageScope', () => { + const scope = (containingPath?: string) => getPackageScope(containingPath, '/app'); + + it('should use the working directory for a stylesheet outside node_modules', () => { + expect(scope('/app/src/app.scss')).toBe('/app'); + expect(scope('/app/src/foo-node_modules/a.scss')).toBe('/app'); + expect(scope('/app/src/node_modules.scss')).toBe('/app'); + expect(scope(undefined)).toBe('/app'); + }); + + it('should use the enclosing package root for a stylesheet within node_modules', () => { + expect(scope('/app/node_modules/pkg/a.scss')).toBe('/app/node_modules/pkg'); + expect(scope('/app/node_modules/pkg/sub/a.scss')).toBe('/app/node_modules/pkg'); + expect(scope('/app/node_modules/@scope/pkg/sub/a.scss')).toBe('/app/node_modules/@scope/pkg'); + expect(scope('/app/node_modules/foo-node_modules/a.scss')).toBe( + '/app/node_modules/foo-node_modules', + ); + expect(scope('/app/node_modules/a.scss')).toBe('/app/node_modules'); + }); + + it('should use the innermost package root for a nested dependency', () => { + expect(scope('/app/node_modules/dep/node_modules/pkg/a.scss')).toBe( + '/app/node_modules/dep/node_modules/pkg', + ); + expect(scope('/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/a.scss')).toBe( + '/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg', + ); + }); + + it('should support Windows path separators', () => { + expect(getPackageScope('C:\\app\\node_modules\\pkg\\sub\\a.scss', 'C:\\app')).toBe( + 'C:/app/node_modules/pkg', + ); + expect(getPackageScope('C:\\app\\src\\app.scss', 'C:\\app')).toBe('C:\\app'); + }); + }); + + describe('package resolution caching', () => { + let temporaryRoot: string; + let projectRoot: string; + let buttonStylesheet: string; + let cardStylesheet: string; + let dependencyStylesheet: string; + let resolveRequests: string[]; + + /** + * Creates a build stub that resolves a package specifier by searching the `node_modules` + * directories visible from the resolve directory, which is how esbuild resolves the + * package specifiers of a stylesheet. + */ + function createBuildStub(): PluginBuild { + return { + initialOptions: { absWorkingDir: projectRoot }, + resolve: async (path: string, options: { resolveDir: string }) => { + resolveRequests.push(`${options.resolveDir}:${path}`); + + for (let directory = options.resolveDir; ; directory = dirname(directory)) { + // A package specifier resolves to the index file of the package, and an explicit file + // within it to that file. A deeper subpath is left unresolved, as esbuild leaves one + // that the `exports` of the package does not name; the Sass importer then resolves it + // against the package root instead. + for (const candidate of [ + join(directory, 'node_modules', path, '_index.scss'), + join(directory, 'node_modules', path), + ]) { + if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) { + return { path: candidate, errors: [], warnings: [] }; + } + } + + if (dirname(directory) === directory) { + return { path: undefined, errors: [], warnings: [] }; + } + } + }, + } as unknown as PluginBuild; + } + + async function compile(stylesheet: string, source = "@use 'theme';"): Promise { + const result = await SassStylesheetLanguage.process?.( + source, + stylesheet, + 'scss', + { sourcemap: false }, + createBuildStub(), + ); + if (!result) { + throw new Error('The Sass stylesheet language has no process function.'); + } + + if (result.errors?.length) { + return `error: ${result.errors[0].text}`; + } + + return (result.contents as string).trim(); + } + + async function writePackage(directory: string, marker: string): Promise { + await mkdir(join(directory, 'sub'), { recursive: true }); + await writeFile(join(directory, 'package.json'), '{}'); + await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`); + await writeFile( + join(directory, 'sub', '_other.scss'), + `.deep { content: "${marker} deep"; }`, + ); + } + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + temporaryRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-sass-language-')); + projectRoot = join(temporaryRoot, 'project'); + const dependencyRoot = join(projectRoot, 'node_modules', 'dependency'); + + // An application using a `theme` package, and a dependency with its own nested version of + // `theme` plus an `extra` package that only the dependency can see. + await writePackage(join(projectRoot, 'node_modules', 'theme'), 'project'); + await writePackage(join(dependencyRoot, 'node_modules', 'theme'), 'dependency'); + await writePackage(join(dependencyRoot, 'node_modules', 'extra'), 'extra'); + + buttonStylesheet = join(projectRoot, 'src', 'app', 'button', 'button.scss'); + cardStylesheet = join(projectRoot, 'src', 'app', 'card', 'card.scss'); + dependencyStylesheet = join(dependencyRoot, 'styles.scss'); + for (const stylesheet of [buttonStylesheet, cardStylesheet]) { + await mkdir(dirname(stylesheet), { recursive: true }); + } + }); + + afterAll(async () => { + shutdownSassWorkerPool(); + await rm(temporaryRoot, { force: true, recursive: true }); + }); + + beforeEach(() => { + resetSassWorkerPoolCaches(); + resolveRequests = []; + }); + + it('should not use the package resolution of a dependency for the application', async () => { + const dependency = await compile(dependencyStylesheet); + const application = await compile(buttonStylesheet); + + expect(dependency).toContain('content: "dependency";'); + expect(application).toContain('content: "project";'); + }); + + it('should not use the package resolution of the application for a dependency', async () => { + const application = await compile(buttonStylesheet); + const dependency = await compile(dependencyStylesheet); + + expect(application).toContain('content: "project";'); + expect(dependency).toContain('content: "dependency";'); + }); + + it('should not reuse a failed package resolution of the application for a dependency', async () => { + const source = "@use 'extra';"; + const application = await compile(buttonStylesheet, source); + const dependency = await compile(dependencyStylesheet, source); + + expect(application).toContain("Can't find stylesheet to import."); + expect(dependency).toContain('content: "extra";'); + }); + + it('should not use the package root of a dependency for a deep import of the application', async () => { + // A subpath that resolves to no file of its own is located through the root of the package, + // which is cached separately from the resolution of the specifier. + const source = "@use 'theme/sub/other';"; + const dependency = await compile(dependencyStylesheet, source); + const application = await compile(buttonStylesheet, source); + + expect(dependency).toContain('content: "dependency deep";'); + expect(application).toContain('content: "project deep";'); + }); + + it('should share a package resolution between the stylesheets of different components', async () => { + const button = await compile(buttonStylesheet); + const card = await compile(cardStylesheet); + + expect(button).toContain('content: "project";'); + expect(card).toContain('content: "project";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should share a package resolution between the stylesheets of different folders of a dependency', async () => { + const dependencyRoot = join(projectRoot, 'node_modules', 'dependency'); + const first = await compile(join(dependencyRoot, 'sub1', 'styles.scss')); + const second = await compile(join(dependencyRoot, 'sub2', 'styles.scss')); + + expect(first).toContain('content: "dependency";'); + expect(second).toContain('content: "dependency";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should share a package resolution between the stylesheets of different folders of a scoped package', async () => { + const packageRoot = join(projectRoot, 'node_modules', '@scope', 'pkg'); + const first = await compile(join(packageRoot, 'sub1', 'styles.scss')); + const second = await compile(join(packageRoot, 'sub2', 'styles.scss')); + + expect(first).toContain('content: "project";'); + expect(second).toContain('content: "project";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should resolve a package url of a non-file containing URL from the working directory', async () => { + // The stylesheets of a build have file URLs, but Sass does not limit a containing URL to them. + spyOn(SassCompiler.prototype, 'compileStringAsync').and.callFake(async (_, options) => { + const importer = options.importers?.[0] as { + findFileUrl( + url: string, + context: { containingUrl: URL; fromImport: boolean }, + ): Promise; + }; + const url = await importer.findFileUrl('theme', { + containingUrl: new URL('custom:styles.scss'), + fromImport: false, + }); + + return { css: url?.href ?? '', loadedUrls: [] }; + }); + + const result = await compile(buttonStylesheet); + + expect(result).toBe( + pathToFileURL(join(projectRoot, 'node_modules', 'theme', '_index.scss')).href, + ); + }); + + it('should resolve imports when globalThis.Promise is replaced (e.g., by Zone.js)', async () => { + const originalPromise = globalThis.Promise; + + // Simulate Zone.js replacing globalThis.Promise with a constructor where + // native V8 async function return values fail `instanceof globalThis.Promise`. + class ZoneLikePromise { + #inner: Promise; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + ) => void, + ) { + this.#inner = new originalPromise(executor); + } + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): ZoneLikePromise { + return new ZoneLikePromise((resolve, reject) => { + this.#inner.then(onfulfilled, onrejected).then(resolve, reject); + }); + } + + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): ZoneLikePromise { + return this.then(undefined, onrejected); + } + + finally(onfinally?: (() => void) | null): ZoneLikePromise { + return new ZoneLikePromise((resolve, reject) => { + this.#inner.finally(onfinally).then(resolve, reject); + }); + } + + static resolve(value?: U | PromiseLike): ZoneLikePromise> { + if (value instanceof ZoneLikePromise) { + return value; + } + + return new ZoneLikePromise((resolve) => resolve(value as Awaited)); + } + + static reject(reason?: unknown): ZoneLikePromise { + return new ZoneLikePromise((_, reject) => reject(reason)); + } + + static all(values: Iterable): ZoneLikePromise { + return ZoneLikePromise.resolve(originalPromise.all(values)); + } + } + + globalThis.Promise = ZoneLikePromise as unknown as PromiseConstructor; + try { + const application = await compile(buttonStylesheet); + expect(application).toContain('content: "project";'); + } finally { + globalThis.Promise = originalPromise; + } + }); + }); + + describe('error handling', () => { + const buildStub = { + initialOptions: { absWorkingDir: '/app' }, + resolve: async () => ({ path: undefined, errors: [], warnings: [] }), + } as unknown as PluginBuild; + + afterAll(() => { + shutdownSassWorkerPool(); + }); + + it('should populate structured location on Sass compilation errors', async () => { + const result = await SassStylesheetLanguage.process?.( + 'body {\n color: $undefined-var;\n}', + '/app/src/styles.scss', + 'scss', + { sourcemap: false }, + buildStub, + ); + + expect(result?.errors?.length).toBe(1); + expect(result?.errors?.[0].text).toContain('Undefined variable.'); + expect(result?.errors?.[0].location).toEqual({ + file: '/app/src/styles.scss', + line: 2, + column: 9, + lineText: jasmine.stringContaining('color: $undefined-var;'), + }); + expect(result?.watchFiles).toContain('/app/src/styles.scss'); + }); + + it('should skip stack trace lines without line:col and extract valid stack files', async () => { + spyOn(SassCompiler.prototype, 'compileStringAsync').and.rejectWith({ + message: 'Custom Sass error', + sassMessage: 'Custom Sass error', + sassStack: 'malformed stack line without location\nsrc/partials/_vars.scss 3:5 @use\n', + span: { + url: pathToFileURL('/app/src/partials/_vars.scss'), + context: '$bad: ;', + start: { line: 2, column: 4, offset: 10 }, + end: { line: 2, column: 5, offset: 11 }, + text: ';', + }, + }); + + const result = await SassStylesheetLanguage.process?.( + '@use "partials/vars";', + '/app/src/styles.scss', + 'scss', + { sourcemap: false }, + buildStub, + ); + + expect(result?.errors).toEqual([ + { + text: 'Custom Sass error', + location: { + file: '/app/src/partials/_vars.scss', + lineText: '$bad: ;', + line: 3, + column: 4, + }, + }, + ]); + expect(result?.watchFiles).toEqual([ + '/app/src/styles.scss', + join(process.cwd(), 'src/partials/_vars.scss'), + '/app/src/partials/_vars.scss', + ]); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts new file mode 100644 index 000000000000..d760a14197ec --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { calculateHash } from '../../../utils/hash'; +import type { BundleStylesheetOptions } from './bundle-options'; + +/** + * Generates a global hash based on all build options that affect stylesheet compilation. + * + * IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache + * entries. Any change in the values hashed below will invalidate all cached stylesheet outputs + * across the entire application. + * + * Included build options and their rationale: + * - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping. + * - `sourcemap`: Toggles inline/external source map generation and comment insertion. + * - `sourcesContent`: Toggles embedding original source content inside generated source maps. + * - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories. + * - `sassOptions`: Changes Sass compiler deprecations and behavior flags. + * - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild. + * - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output. + * - `outputNames`: Affects asset output filename hashing schemes. + * - `inlineFonts`: Controls whether external web font `@import` / `` directives are inlined. + * - `dataurl`: Controls whether referenced assets are inlined as base64 data URIs. + * - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages. + * - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling. + * - `postcssConfig`: Path to custom PostCSS configuration file. + * - `tailwindConfig`: Path to Tailwind CSS configuration file. + * - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates. + * + * @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build` + * that alters generated stylesheet code or asset outputs, it MUST be added to this hash object. + */ +export function calculateGlobalStylesheetConfigHash( + options: BundleStylesheetOptions, + packageVersion: string = '', +): string { + return calculateHash( + JSON.stringify({ + optimization: options.optimization, + sourcemap: options.sourcemap, + sourcesContent: options.sourcesContent, + includePaths: options.includePaths, + sassOptions: options.sass + ? { + futureDeprecations: options.sass.futureDeprecations, + fatalDeprecations: options.sass.fatalDeprecations, + silenceDeprecations: options.sass.silenceDeprecations, + } + : undefined, + target: options.target, + publicPath: options.publicPath, + outputNames: options.outputNames, + inlineFonts: options.inlineFonts, + dataurl: options.dataurl ?? false, + preserveSymlinks: options.preserveSymlinks, + externalDependencies: options.externalDependencies, + postcssConfig: options.postcssConfiguration?.configPath + ? options.postcssConfiguration.configPath + : '', + tailwindConfig: options.tailwindConfiguration?.file ? options.tailwindConfiguration.file : '', + packageVersion, + }), + ); +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts new file mode 100644 index 000000000000..49b232720fbf --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { initializeHash } from '../../../utils/hash'; +import type { BundleStylesheetOptions } from './bundle-options'; +import { calculateGlobalStylesheetConfigHash } from './stylesheet-cache-key'; + +describe('Stylesheet Global Config Hash', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const baseOptions: BundleStylesheetOptions = { + workspaceRoot: '/root', + optimization: true, + inlineFonts: false, + sourcemap: true, + outputNames: { bundles: 'styles', media: 'media' }, + target: ['chrome100'], + cacheOptions: { enabled: true, path: '/cache', basePath: '/root' }, + }; + + describe('calculateGlobalStylesheetConfigHash', () => { + it('should generate consistent hashes for identical options', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions }, '1.0.0'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes when optimization changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, optimization: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when sourcemap options change', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcemap: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when sourcesContent changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcesContent: true }, + '1.0.0', + ); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcesContent: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when target browsers change', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, target: ['firefox90'] }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when dataurl changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions, dataurl: true }, '1.0.0'); + expect(hash1).not.toBe(hash2); + }); + + it('should produce the same hash when dataurl is undefined or false', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, dataurl: false }, + '1.0.0', + ); + expect(hash1).toBe(hash2); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts new file mode 100644 index 000000000000..79337fe1652e --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts @@ -0,0 +1,444 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { OnLoadResult, Plugin, PluginBuild } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, extname } from 'node:path'; +import type { Options } from 'sass-embedded'; +import { glob } from 'tinyglobby'; +import { assertIsError } from '../../../utils/error'; +import type { PostcssConfiguration } from '../../../utils/postcss-configuration'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +/** + * Configuration options for handling Sass-specific deprecations in a stylesheet plugin. + */ +export type StylesheetPluginsass = Pick< + Options<'async'>, + 'futureDeprecations' | 'fatalDeprecations' | 'silenceDeprecations' +>; + +/** + * Convenience type for a postcss processor. + */ +type PostcssProcessor = import('postcss').Processor; + +/** + * The lazy-loaded instance of the postcss stylesheet postprocessor. + * It is only imported and initialized if postcss is needed. + */ +let postcss: (typeof import('postcss'))['default'] | undefined; + +/** + * An object containing the plugin options to use when processing stylesheets. + */ +export interface StylesheetPluginOptions { + /** + * Controls the use and creation of sourcemaps when processing the stylesheets. + * If true, sourcemap processing is enabled; if false, disabled. + */ + sourcemap: boolean; + + /** + * An optional array of paths that will be searched for stylesheets if the default + * resolution process for the stylesheet language does not succeed. + */ + includePaths?: string[]; + + /** + * Optional component data for any inline styles from Component decorator `styles` fields. + * The key is an internal angular resource URI and the value is the stylesheet content. + */ + inlineComponentData?: Record; + + /** + * Optional information used to load and configure Tailwind CSS. If present, the postcss + * will be added to the stylesheet processing with the Tailwind plugin setup as provided + * by the configuration file. + */ + tailwindConfiguration?: { file: string; package: string }; + + /** + * Optional configuration object for custom postcss usage. If present, postcss will be + * initialized and used for every stylesheet. This overrides the tailwind integration + * and any tailwind usage must be manually configured in the custom postcss usage. + */ + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + + /** + * Optional Options for configuring Sass behavior. + */ + sass?: StylesheetPluginsass; +} + +/** + * An array of keywords that indicate Tailwind CSS processing is required for a stylesheet. + * + * Based on https://tailwindcss.com/docs/functions-and-directives + */ +const TAILWIND_KEYWORDS = [ + '@tailwind', + '@layer', + '@apply', + '@config', + 'theme(', + 'screen(', + '@screen', // Undocumented in version 3, see: https://github.com/tailwindlabs/tailwindcss/discussions/7516. +]; + +export interface StylesheetLanguage { + name: string; + componentFilter: RegExp; + fileFilter: RegExp; + process?( + data: string, + file: string, + format: string, + options: StylesheetPluginOptions, + build: PluginBuild, + ): OnLoadResult | Promise; +} + +/** + * Cached postcss instances that can be re-used between various StylesheetPluginFactory instances. + */ +const postcssProcessors = new Map>(); + +export class StylesheetPluginFactory { + constructor( + private readonly options: StylesheetPluginOptions, + private readonly cache?: LoadResultCache, + ) {} + + create(language: Readonly): Plugin { + const { cache, options, setupPostcss } = this; + + // Return a noop plugin if no load actions are required + if (!language.process && !options.postcssConfiguration && !options.tailwindConfiguration) { + return { + name: 'angular-' + language.name, + setup() {}, + }; + } + + return { + name: 'angular-' + language.name, + async setup(build) { + // Setup postcss if needed + let postcssProcessor: PostcssProcessor | undefined; + build.onStart(async () => { + try { + postcssProcessor = await setupPostcss; + } catch { + return { + errors: [ + { + text: 'Unable to load the "postcss" stylesheet processor.', + location: null, + notes: [ + { + text: + 'Ensure that the "postcss" Node.js package is installed within the project. ' + + "If not present, installation via the project's package manager should resolve the error.", + }, + ], + }, + ], + }; + } + }); + + // Add a load callback to support inline Component styles + build.onLoad( + { filter: language.componentFilter, namespace: 'angular:styles/component' }, + createCachedLoad(cache, (args) => { + const data = options.inlineComponentData?.[args.path]; + assert( + typeof data === 'string', + `component style name should always be found [${args.path}]`, + ); + + const [format, , filename] = args.path.split(';', 3); + + return processStylesheet( + language, + data, + filename, + format, + options, + build, + postcssProcessor, + ); + }), + ); + + // Add a load callback to support files from disk + build.onLoad( + { filter: language.fileFilter, namespace: 'file' }, + createCachedLoad(cache, async (args) => { + const data = await readFile(args.path, 'utf-8'); + + return processStylesheet( + language, + data, + args.path, + extname(args.path).toLowerCase().slice(1), + options, + build, + postcssProcessor, + ); + }), + ); + }, + }; + } + + private setupPostcssPromise: Promise | undefined; + private get setupPostcss(): Promise { + return (this.setupPostcssPromise ??= this.initPostcss()); + } + + private initPostcssCallCount = 0; + + /** + * This method should not be called directly. + * Use {@link setupPostcss} instead. + */ + private async initPostcss(): Promise { + assert.equal(++this.initPostcssCallCount, 1, '`initPostcss` was called more than once.'); + + const { options } = this; + if (options.postcssConfiguration) { + const { config, configPath } = options.postcssConfiguration; + const postCssInstanceKey = JSON.stringify(config); + let postcssProcessor = postcssProcessors.get(postCssInstanceKey)?.deref(); + + if (!postcssProcessor) { + postcss ??= (await import('postcss')).default; + postcssProcessor = postcss(); + + const postCssPluginRequire = createRequire(dirname(configPath) + '/'); + for (const [pluginName, pluginOptions] of config.plugins) { + const pluginMod = postCssPluginRequire(pluginName); + const plugin = pluginMod.__esModule ? pluginMod['default'] : pluginMod; + if (typeof plugin !== 'function' || plugin.postcss !== true) { + throw new Error(`Attempted to load invalid Postcss plugin: "${pluginName}"`); + } + + postcssProcessor.use(plugin(pluginOptions)); + } + + postcssProcessors.set(postCssInstanceKey, new WeakRef(postcssProcessor)); + } + + return postcssProcessor; + } else if (options.tailwindConfiguration) { + const { package: tailwindPackage, file: config } = options.tailwindConfiguration; + const postCssInstanceKey = tailwindPackage + ':' + config; + let postcssProcessor = postcssProcessors.get(postCssInstanceKey)?.deref(); + + if (!postcssProcessor) { + postcss ??= (await import('postcss')).default; + const tailwind = await import(tailwindPackage); + postcssProcessor = postcss().use(tailwind.default({ config })); + postcssProcessors.set(postCssInstanceKey, new WeakRef(postcssProcessor)); + } + + return postcssProcessor; + } + } +} + +async function processStylesheet( + language: Readonly, + data: string, + filename: string, + format: string, + options: StylesheetPluginOptions, + build: PluginBuild, + postcssProcessor: PostcssProcessor | undefined, +) { + let result: OnLoadResult; + + // Process the input data if the language requires preprocessing + if (language.process) { + result = await language.process(data, filename, format, options, build); + } else { + result = { + contents: data, + loader: 'css', + watchFiles: [filename], + }; + } + + // Return early if there are no contents to further process or there are errors + if (!result.contents || result.errors?.length) { + return result; + } + + // Only use postcss if Tailwind processing is required or custom postcss is present. + if (postcssProcessor && (options.postcssConfiguration || hasTailwindKeywords(result.contents))) { + const postcssResult = await compileString( + typeof result.contents === 'string' + ? result.contents + : Buffer.from(result.contents).toString('utf-8'), + filename, + postcssProcessor, + options, + ); + + // Merge results + if (postcssResult.errors?.length) { + delete result.contents; + } + if (result.warnings && postcssResult.warnings) { + postcssResult.warnings.unshift(...result.warnings); + } + if (result.watchFiles && postcssResult.watchFiles) { + postcssResult.watchFiles.unshift(...result.watchFiles); + } + if (result.watchDirs && postcssResult.watchDirs) { + postcssResult.watchDirs.unshift(...result.watchDirs); + } + result = { + ...result, + ...postcssResult, + }; + } + + return result; +} + +/** + * Searches the provided contents for keywords that indicate Tailwind is used + * within a stylesheet. + * @param contents A string or Uint8Array containing UTF-8 text. + * @returns True, if the contents contains tailwind keywords; False, otherwise. + */ +function hasTailwindKeywords(contents: string | Uint8Array): boolean { + // TODO: use better search algorithm for keywords + if (typeof contents === 'string') { + return TAILWIND_KEYWORDS.some((keyword) => contents.includes(keyword)); + } + + // Contents is a Uint8Array + const data = contents instanceof Buffer ? contents : Buffer.from(contents); + + return TAILWIND_KEYWORDS.some((keyword) => data.includes(keyword)); +} + +/** + * Compiles the provided CSS stylesheet data using a provided postcss processor and provides an + * esbuild load result that can be used directly by an esbuild Plugin. + * @param data The stylesheet content to process. + * @param filename The name of the file that contains the data. + * @param postcssProcessor A postcss processor instance to use. + * @param options The plugin options to control the processing. + * @returns An esbuild OnLoaderResult object with the processed content, warnings, and/or errors. + */ +async function compileString( + data: string, + filename: string, + postcssProcessor: import('postcss').Processor, + options: StylesheetPluginOptions, +): Promise { + try { + const postcssResult = await postcssProcessor.process(data, { + from: filename, + to: filename, + map: options.sourcemap && { + inline: true, + sourcesContent: true, + }, + }); + + const loadResult: OnLoadResult = { + contents: postcssResult.css, + loader: 'css', + }; + + const rawWarnings = postcssResult.warnings(); + if (rawWarnings.length > 0) { + const lineMappings = new Map(); + loadResult.warnings = rawWarnings.map((warning) => { + const file = warning.node.source?.input.file; + if (file === undefined) { + return { text: warning.text }; + } + + let lines = lineMappings.get(file); + if (lines === undefined) { + lines = warning.node.source?.input.css.split(/\r?\n/); + lineMappings.set(file, lines ?? null); + } + + return { + text: warning.text, + location: { + file, + line: warning.line, + column: warning.column - 1, + lineText: lines?.[warning.line - 1], + }, + }; + }); + } + + for (const resultMessage of postcssResult.messages) { + if (resultMessage.type === 'dependency' && typeof resultMessage['file'] === 'string') { + loadResult.watchFiles ??= []; + loadResult.watchFiles.push(resultMessage['file']); + } else if ( + resultMessage.type === 'dir-dependency' && + typeof resultMessage['dir'] === 'string' && + typeof resultMessage['glob'] === 'string' + ) { + loadResult.watchFiles ??= []; + const dependencies = await glob(resultMessage['glob'], { + absolute: true, + cwd: resultMessage['dir'], + }); + loadResult.watchFiles.push(...dependencies); + } + } + + return loadResult; + } catch (error) { + postcss ??= (await import('postcss')).default; + if (error instanceof postcss.CssSyntaxError) { + const lines = error.source?.split(/\r?\n/); + + return { + errors: [ + { + text: error.reason, + location: { + file: error.file, + line: error.line, + column: error.column && error.column - 1, + lineText: error.line === undefined ? undefined : lines?.[error.line - 1], + }, + }, + ], + watchFiles: error.file && error.file !== filename ? [filename, error.file] : [filename], + }; + } else { + assertIsError(error); + + return { + errors: [ + { + text: error.message, + }, + ], + }; + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/target.ts b/packages/angular/build/src/tools/esbuild/target.ts new file mode 100644 index 000000000000..2925905276b7 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/target.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { coerce, compare, minVersion } from 'semver'; + +/** + * Compares two target version strings. + * + * This function is used to determine the lowest version for a given browser target. + * + * @param a The first version string. + * @param b The second version string. + * @returns A negative value if `a` is lower than `b`, a positive value if `a` is higher than `b`, and 0 if they are equal. + */ +function compareTargetVersions(a: string, b: string): number { + const aVersion = coerce(a); + const bVersion = coerce(b); + + if (!aVersion || !bVersion) { + return aVersion ? -1 : bVersion ? 1 : 0; + } + + return compare(aVersion, bVersion); +} + +// https://esbuild.github.io/api/#target +const ESBUILD_SUPPORTED_BROWSERS: ReadonlySet = new Set([ + 'chrome', + 'edge', + 'firefox', + 'ie', + 'ios', + 'node', + 'opera', + 'safari', +]); + +/** + * Transform browserlists result to esbuild target. + * + * Only the lowest version for each browser is returned to avoid issues with esbuild and rolldown + * when multiple versions of the same target engine are specified. + * + * @see https://esbuild.github.io/api/#target + * @see https://github.com/evanw/esbuild/issues/4509 + * @see https://github.com/rolldown/rolldown/issues/10633 + */ +export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] { + const browsers = new Map(); + + for (const browser of supportedBrowsers) { + let [browserName, version] = browser.toLowerCase().split(' '); + if (!browserName || !version) { + continue; + } + + // browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios` + if (browserName === 'ios_saf') { + browserName = 'ios'; + } + + if (!ESBUILD_SUPPORTED_BROWSERS.has(browserName)) { + continue; + } + + // browserslist uses ranges `15.2-15.3` versions but only the lowest is required + // to perform minimum supported feature checks. esbuild also expects a single version. + [version] = version.split('-'); + + if (browserName === 'safari' && version === 'tp') { + // esbuild only supports numeric versions so `TP` is converted to a high number (999) since + // a Technology Preview (TP) of Safari is assumed to support all currently known features. + version = '999'; + } else if (!version.includes('.')) { + // A lone major version is considered by esbuild to include all minor versions. However, + // browserslist does not and is also inconsistent in its `.0` version naming. For example, + // Safari 15.0 is named `safari 15` but Safari 16.0 is named `safari 16.0`. + version += '.0'; + } + + const current = browsers.get(browserName); + if (!current || compareTargetVersions(version, current) < 0) { + browsers.set(browserName, version); + } + } + + return Array.from(browsers, ([browserName, version]) => browserName + version); +} + +const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE'; + +/** + * Transform supported Node.js versions to esbuild target. + * + * Only the lowest Node.js version is returned to avoid issues with esbuild and rolldown + * when multiple versions of the same target engine are specified. + * + * @see https://esbuild.github.io/api/#target + * @see https://github.com/evanw/esbuild/issues/4509 + * @see https://github.com/rolldown/rolldown/issues/10633 + */ +export function getSupportedNodeTargets(): string[] { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') { + // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions. + return []; + } + + const parsed = minVersion(SUPPORTED_NODE_VERSIONS); + + return parsed ? ['node' + parsed.version] : []; +} diff --git a/packages/angular/build/src/tools/esbuild/target_spec.ts b/packages/angular/build/src/tools/esbuild/target_spec.ts new file mode 100644 index 000000000000..e5a375a1412b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/target_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { getSupportedNodeTargets, transformSupportedBrowsersToTargets } from './target'; + +describe('esbuild target', () => { + describe('transformSupportedBrowsersToTargets', () => { + it('should return the smallest version for each browser', () => { + const targets = transformSupportedBrowsersToTargets([ + 'chrome 122', + 'chrome 120', + 'chrome 121', + 'firefox 116', + 'firefox 115', + 'safari 17.0', + 'safari 16.4', + ]); + + expect(targets).toEqual(['chrome120.0', 'firefox115.0', 'safari16.4']); + }); + + it('should handle version ranges and pick the lowest version', () => { + const targets = transformSupportedBrowsersToTargets([ + 'ios_saf 15.4', + 'ios_saf 15.2-15.3', + 'ios_saf 16.0', + ]); + + expect(targets).toEqual(['ios15.2']); + }); + + it('should handle Safari TP (Technology Preview)', () => { + const targetsWithOlderSafari = transformSupportedBrowsersToTargets([ + 'safari TP', + 'safari 16.4', + ]); + expect(targetsWithOlderSafari).toEqual(['safari16.4']); + + const targetsWithOnlyTP = transformSupportedBrowsersToTargets(['safari TP']); + expect(targetsWithOnlyTP).toEqual(['safari999']); + }); + + it('should ignore browsers not supported by esbuild', () => { + const targets = transformSupportedBrowsersToTargets([ + 'android 4.4', + 'samsung 22', + 'kaios 2.5', + 'chrome 115', + ]); + + expect(targets).toEqual(['chrome115.0']); + }); + + it('should return empty array for empty supportedBrowsers', () => { + const targets = transformSupportedBrowsersToTargets([]); + expect(targets).toEqual([]); + }); + + it('should handle malformed or incomplete browser strings gracefully', () => { + const targets = transformSupportedBrowsersToTargets(['chrome', 'firefox ', '']); + expect(targets).toEqual([]); + }); + + it('should handle single major versions by appending .0', () => { + const targets = transformSupportedBrowsersToTargets(['chrome 120', 'edge 120']); + expect(targets).toEqual(['chrome120.0', 'edge120.0']); + }); + }); + + describe('getSupportedNodeTargets', () => { + it('should return empty array when node versions are not stamped', () => { + expect(getSupportedNodeTargets()).toEqual([]); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts new file mode 100644 index 000000000000..e93bb295b439 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { BuildOptions, Metafile, OutputFile, formatMessages } from 'esbuild'; +import { Listr } from 'listr2'; +import { basename, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { brotliCompress } from 'node:zlib'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode } from '../../builders/application/schema'; +import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; + +import { BundleStats, generateEsbuildBuildStatsTable } from '../../utils/stats-table'; +import { ExecutionResult, PrerenderedRoutesRecord } from './bundler-execution-result'; +import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files'; + +export function logBuildStats( + metafiles: Metafile[], + outputFiles: BuildOutputFile[], + initial: Map, + budgetFailures: BudgetCalculatorResult[] | undefined, + colors: boolean, + changedFiles?: Set, + estimatedTransferSizes?: Map, + ssrOutputEnabled?: boolean, + verbose?: boolean, +): string { + // Remove the i18n subpath in case the build is using i18n. + // en-US/main.js -> main.js + const normalizedChangedFiles: Set = new Set( + [...(changedFiles ?? [])].map((f) => basename(f)), + ); + const browserStats: BundleStats[] = []; + const serverStats: BundleStats[] = []; + let unchangedCount = 0; + let componentStyleChange = false; + + for (const { path: file, size, type } of outputFiles) { + // Only display JavaScript and CSS files + if (!/\.(?:css|m?js)$/.test(file)) { + continue; + } + + // Show only changed files if a changed list is provided + if (normalizedChangedFiles.size && !normalizedChangedFiles.has(file)) { + ++unchangedCount; + continue; + } + + const isPlatformServer = + type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot; + if (isPlatformServer && !ssrOutputEnabled) { + // Only log server build stats when SSR is enabled. + continue; + } + + // Skip logging external component stylesheets used for HMR + if (metafiles.some((mf) => mf.outputs[file] && 'ng-component' in mf.outputs[file])) { + componentStyleChange = true; + continue; + } + + const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafiles, file); + const stat: BundleStats = { + initial: initial.has(file), + stats: [file, name ?? '-', size, estimatedTransferSizes?.get(file) ?? '-'], + }; + + if (isPlatformServer) { + serverStats.push(stat); + } else { + browserStats.push(stat); + } + } + + if (browserStats.length > 0 || serverStats.length > 0) { + const tableText = generateEsbuildBuildStatsTable( + [browserStats, serverStats], + colors, + unchangedCount === 0, + !!estimatedTransferSizes, + budgetFailures, + verbose, + ); + + return tableText + '\n'; + } else if (changedFiles !== undefined) { + if (componentStyleChange) { + return '\nComponent stylesheet(s) changed.\n'; + } else { + return '\nNo output file changes.\n'; + } + } + if (unchangedCount > 0) { + return `Unchanged output files: ${unchangedCount}`; + } + + return ''; +} + +export function getChunkNameFromMetafile( + metafiles: Metafile[] | Metafile, + file: string, +): string | undefined { + const metafileArray = Array.isArray(metafiles) ? metafiles : [metafiles]; + for (const metafile of metafileArray) { + if (metafile.outputs[file]?.entryPoint) { + return getEntryPointName(metafile.outputs[file].entryPoint); + } + } +} + +export async function calculateEstimatedTransferSizes( + outputFiles: OutputFile[], +): Promise> { + const sizes = new Map(); + if (outputFiles.length <= 0) { + return sizes; + } + + return new Promise((resolve, reject) => { + let completeCount = 0; + for (const outputFile of outputFiles) { + // Only calculate JavaScript and CSS files + if (!outputFile.path.endsWith('.js') && !outputFile.path.endsWith('.css')) { + ++completeCount; + continue; + } + + // Skip compressing small files which may end being larger once compressed and will most likely not be + // compressed in actual transit. + if (outputFile.contents.byteLength < 1024) { + sizes.set(outputFile.path, outputFile.contents.byteLength); + ++completeCount; + continue; + } + + // Directly use the async callback function to minimize the number of Promises that need to be created. + brotliCompress(outputFile.contents, (error, result) => { + if (error) { + reject(error); + + return; + } + + sizes.set(outputFile.path, result.byteLength); + if (++completeCount >= outputFiles.length) { + resolve(sizes); + } + }); + } + + // Covers the case where no files need to be compressed + if (completeCount >= outputFiles.length) { + resolve(sizes); + } + }); +} + +export async function withSpinner(text: string, action: () => T | Promise): Promise { + let result; + const taskList = new Listr( + [ + { + title: text, + async task() { + result = await action(); + }, + }, + ], + { rendererOptions: { clearOutput: true } }, + ); + + await taskList.run(); + + return result as T; +} + +export async function withNoProgress(text: string, action: () => T | Promise): Promise { + return action(); +} + +/** + * Generates a syntax feature object map for Angular applications. + * A full set of feature names can be found here: https://esbuild.github.io/api/#supported + * @param nativeAsyncAwait Indicate whether to support native async/await. + * @returns An object that can be used with the esbuild build `supported` option. + */ +export function getFeatureSupport(nativeAsyncAwait: boolean): BuildOptions['supported'] { + return { + // Native async/await is not supported with Zone.js. Disabling support here will cause + // esbuild to downlevel async/await, async generators, and for await...of to a Zone.js supported form. + 'async-await': nativeAsyncAwait, + // Workaround for an esbuild minification bug when async-await is disabled and the target is es2019+. + // The catch binding for downleveled for-await will be incorrectly removed in this specific situation. + ...(!nativeAsyncAwait ? { 'optional-catch-binding': false } : {}), + // V8 currently has a performance defect involving object spread operations that can cause signficant + // degradation in runtime performance. By not supporting the language feature here, a downlevel form + // will be used instead which provides a workaround for the performance issue. + // For more details: https://bugs.chromium.org/p/v8/issues/detail?id=11536 + 'object-rest-spread': false, + }; +} + +interface BuildManifest { + errors: string[]; + warnings: string[]; + outputPaths: { + root: URL; + server?: URL | undefined; + browser: URL; + }; + prerenderedRoutes: PrerenderedRoutesRecord; +} + +export async function createJsonBuildManifest( + result: ExecutionResult, + normalizedOptions: NormalizedApplicationBuildOptions, +): Promise { + const { + colors: color, + outputOptions: { base, server, browser }, + ssrOptions, + outputMode, + } = normalizedOptions; + + const { warnings, errors, prerenderedRoutes } = result; + + const manifest: BuildManifest = { + errors: errors.length ? await formatMessages(errors, { kind: 'error', color }) : [], + warnings: warnings.length ? await formatMessages(warnings, { kind: 'warning', color }) : [], + outputPaths: { + root: pathToFileURL(base), + browser: pathToFileURL(join(base, browser)), + server: + outputMode !== OutputMode.Static && ssrOptions + ? pathToFileURL(join(base, server)) + : undefined, + }, + prerenderedRoutes, + }; + + return JSON.stringify(manifest, undefined, 2); +} + +export async function logMessages( + logger: BuilderContext['logger'], + executionResult: ExecutionResult, + color?: boolean, + jsonLogs?: boolean, +): Promise { + const { warnings, errors, logs } = executionResult; + + if (logs.length) { + logger.info(logs.join('\n')); + } + + if (jsonLogs) { + return; + } + + if (warnings.length) { + logger.warn((await formatMessages(warnings, { kind: 'warning', color })).join('\n')); + } + + if (errors.length) { + logger.error((await formatMessages(errors, { kind: 'error', color })).join('\n')); + } +} + +/** + * Ascertain whether the application operates without `zone.js`, we currently rely on the polyfills setting to determine its status. + * If a file with an extension is provided or if `zone.js` is included in the polyfills, the application is deemed as not zoneless. + * @param polyfills An array of polyfills + * @returns true, when the application is considered as zoneless. + */ +export function isZonelessApp(polyfills: string[] | undefined): boolean { + // TODO: Instead, we should rely on the presence of zone.js in the polyfills build metadata. + return !polyfills?.some((p) => p === 'zone.js' || /\.[mc]?[jt]s$/.test(p)); +} + +export function getEntryPointName(entryPoint: string): string { + return basename(entryPoint) + .replace(/(.*:)/, '') // global:bundle.css -> bundle.css + .replace(/\.[cm]?[jt]s$/, '') + .replace(/[\\/.]/g, '-'); +} diff --git a/packages/angular/build/src/tools/esbuild/virtual-module-plugin.ts b/packages/angular/build/src/tools/esbuild/virtual-module-plugin.ts new file mode 100644 index 000000000000..7f6b3729e3fb --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/virtual-module-plugin.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { OnLoadArgs, Plugin, PluginBuild } from 'esbuild'; +import { LoadResultCache, createCachedLoad } from './load-result-cache'; + +/** + * Options for the createVirtualModulePlugin + * @see createVirtualModulePlugin + */ +export interface VirtualModulePluginOptions { + /** Namespace. Example: `angular:polyfills`. */ + namespace: string; + + /** If the generated module should be marked as external. */ + external?: boolean; + + /** Method to transform the onResolve path. */ + transformPath?: (path: string) => string; + + /** Method to provide the module content. */ + loadContent: ( + args: OnLoadArgs, + build: PluginBuild, + ) => ReturnType[1]>; + + /** Restrict to only entry points. Defaults to `true`. */ + entryPointOnly?: boolean; + + /** Load results cache. */ + cache?: LoadResultCache; +} + +/** + * Creates an esbuild plugin that generated virtual modules. + * + * @returns An esbuild plugin. + */ +export function createVirtualModulePlugin(options: VirtualModulePluginOptions): Plugin { + const { + namespace, + external, + transformPath: pathTransformer, + loadContent, + cache, + entryPointOnly = true, + } = options; + + return { + name: namespace.replace(/[/:]/g, '-'), + setup(build): void { + build.onResolve({ filter: new RegExp('^' + namespace) }, ({ kind, path }) => { + if (entryPointOnly && kind !== 'entry-point') { + return null; + } + + return { + path: pathTransformer?.(path) ?? path, + namespace, + }; + }); + + if (external) { + build.onResolve({ filter: /./, namespace }, ({ path }) => { + return { + path, + external: true, + }; + }); + } + + build.onLoad( + { filter: /./, namespace }, + createCachedLoad(cache, (args) => loadContent(args, build)), + ); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/wasm-plugin.ts b/packages/angular/build/src/tools/esbuild/wasm-plugin.ts new file mode 100644 index 000000000000..1bc18dd747b2 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/wasm-plugin.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin, ResolveOptions } from 'esbuild'; +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { assertIsError } from '../../utils/error'; +import { LoadResultCache, createCachedLoad } from './load-result-cache'; + +/** + * Options for the Angular WASM esbuild plugin + * @see createWasmPlugin + */ +export interface WasmPluginOptions { + /** Allow generation of async (proposal compliant) WASM imports. This requires zoneless to enable async/await. */ + allowAsync?: boolean; + + /** Load results cache. */ + cache?: LoadResultCache; +} + +const WASM_INIT_NAMESPACE = 'angular:wasm:init'; +const WASM_CONTENTS_NAMESPACE = 'angular:wasm:contents'; +const WASM_RESOLVE_SYMBOL = Symbol('WASM_RESOLVE_SYMBOL'); + +// See: https://github.com/tc39/proposal-regexp-unicode-property-escapes/blob/fe6d07fad74cd0192d154966baa1e95e7cda78a1/README.md#other-examples +const ecmaIdentifierNameRegExp = /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u; + +/** + * Creates an esbuild plugin to use WASM files with import statements and expressions. + * The default behavior follows the WebAssembly/ES mode integration proposal found at + * https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration. + * This behavior requires top-level await support which is only available in zoneless + * Angular applications. + * @returns An esbuild plugin. + */ +export function createWasmPlugin(options: WasmPluginOptions): Plugin { + const { allowAsync = false, cache } = options; + + return { + name: 'angular-wasm', + setup(build): void { + build.onResolve({ filter: /\.wasm$/ }, async (args) => { + // Skip if already resolving the WASM file to avoid infinite resolution + if (args.pluginData?.[WASM_RESOLVE_SYMBOL]) { + return; + } + // Skip if not an import statement or expression + if (args.kind !== 'import-statement' && args.kind !== 'dynamic-import') { + return; + } + + // When in the initialization namespace, the content has already been resolved + // and only needs to be loaded for use with the initialization code. + if (args.namespace === WASM_INIT_NAMESPACE) { + return { + namespace: WASM_CONTENTS_NAMESPACE, + path: join(args.resolveDir, args.path), + pluginData: args.pluginData, + }; + } + + // Skip if a custom loader is defined + if (build.initialOptions.loader?.['.wasm'] || args.with['loader']) { + return; + } + + // Attempt full resolution of the WASM file + const resolveOptions: ResolveOptions & { path?: string } = { + ...args, + pluginData: { [WASM_RESOLVE_SYMBOL]: true }, + }; + // The "path" property will cause an error if used in the resolve call + delete resolveOptions.path; + + const result = await build.resolve(args.path, resolveOptions); + + // Skip if there are errors, is external, or another plugin resolves to a custom namespace + if (result.errors.length > 0 || result.external || result.namespace !== 'file') { + // Reuse already resolved result + return result; + } + + return { + ...result, + namespace: WASM_INIT_NAMESPACE, + }; + }); + + build.onLoad( + { filter: /\.wasm$/, namespace: WASM_INIT_NAMESPACE }, + createCachedLoad(cache, async (args) => { + // Ensure async mode is supported + if (!allowAsync) { + return { + errors: [ + { + text: 'WASM/ES module integration imports are not supported with Zone.js applications', + notes: [ + { + text: 'Information about zoneless Angular applications can be found here: https://angular.dev/guide/experimental/zoneless', + }, + ], + }, + ], + }; + } + + const wasmContents = await readFile(args.path); + // Inline WASM code less than 10kB + const inlineWasm = wasmContents.byteLength < 10_000; + + // Add import of WASM contents + let initContents = `import ${inlineWasm ? 'wasmData' : 'wasmPath'} from ${JSON.stringify(basename(args.path))}`; + initContents += inlineWasm ? ' with { loader: "binary" };' : ';\n\n'; + + // Read from the file system when on Node.js (SSR) and not inline + if (!inlineWasm && build.initialOptions.platform === 'node') { + initContents += 'import { readFile } from "node:fs/promises";\n'; + initContents += + 'const wasmData = await readFile(new URL(wasmPath, import.meta.url));\n'; + } + + // Create initialization function + initContents += generateInitHelper( + !inlineWasm && build.initialOptions.platform !== 'node', + wasmContents, + ); + + // Analyze WASM for imports and exports + let importModuleNames, exportNames; + try { + const wasm = await WebAssembly.compile(wasmContents); + importModuleNames = new Set( + WebAssembly.Module.imports(wasm).map((value) => value.module), + ); + exportNames = WebAssembly.Module.exports(wasm).map((value) => value.name); + } catch (error) { + assertIsError(error); + + return { + errors: [{ text: 'Unable to analyze WASM file', notes: [{ text: error.message }] }], + }; + } + + // Ensure export names are valid JavaScript identifiers + const invalidExportNames = exportNames.filter( + (name) => !ecmaIdentifierNameRegExp.test(name), + ); + if (invalidExportNames.length > 0) { + return { + errors: invalidExportNames.map((name) => ({ + text: 'WASM export names must be valid JavaScript identifiers', + notes: [ + { + text: `The export "${name}" is not valid. The WASM file should be updated to remove this error.`, + }, + ], + })), + }; + } + + // Add import statements and setup import object + initContents += 'const importObject = Object.create(null);\n'; + let importIndex = 0; + for (const moduleName of importModuleNames) { + // Add a namespace import for each module name + initContents += `import * as wasm_import_${++importIndex} from ${JSON.stringify(moduleName)};\n`; + // Add the namespace object to the import object + initContents += `importObject[${JSON.stringify(moduleName)}] = wasm_import_${importIndex};\n`; + } + + // Instantiate the module + initContents += 'const instance = await init(importObject);\n'; + + // Add exports + const exportNameList = exportNames.join(', '); + initContents += `const { ${exportNameList} } = instance.exports;\n`; + initContents += `export { ${exportNameList} }\n`; + + return { + contents: initContents, + loader: 'js', + resolveDir: dirname(args.path), + pluginData: { wasmContents }, + watchFiles: [args.path], + }; + }), + ); + + build.onLoad({ filter: /\.wasm$/, namespace: WASM_CONTENTS_NAMESPACE }, async (args) => { + const contents = args.pluginData.wasmContents ?? (await readFile(args.path)); + + let loader: 'binary' | 'file' = 'file'; + if (args.with.loader) { + assert( + args.with.loader === 'binary' || args.with.loader === 'file', + 'WASM loader type should only be binary or file.', + ); + loader = args.with.loader; + } + + return { + contents, + loader, + watchFiles: [args.path], + }; + }); + }, + }; +} + +/** + * Generates the string content of the WASM initialization helper function. + * This function supports both file fetching and inline byte data depending on + * the preferred option for the WASM file. When fetching, an integrity hash is + * also generated and used with the fetch action. + * + * @param streaming Uses fetch and WebAssembly.instantiateStreaming. + * @param wasmContents The binary contents to generate an integrity hash. + * @returns A string containing the initialization function. + */ +function generateInitHelper(streaming: boolean, wasmContents: Uint8Array) { + let resultContents; + if (streaming) { + const fetchOptions = { + integrity: 'sha256-' + createHash('sha256').update(wasmContents).digest('base64'), + }; + const fetchContents = `fetch(new URL(wasmPath, import.meta.url), ${JSON.stringify(fetchOptions)})`; + resultContents = `await WebAssembly.instantiateStreaming(${fetchContents}, imports)`; + } else { + resultContents = 'await WebAssembly.instantiate(wasmData, imports)'; + } + + const contents = ` +let mod; +async function init(imports) { + if (mod) { + return await WebAssembly.instantiate(mod, imports); + } + + const result = ${resultContents}; + mod = result.module; + + return result.instance; +} +`; + + return contents; +} diff --git a/packages/angular/build/src/tools/esbuild/wasm.d.ts b/packages/angular/build/src/tools/esbuild/wasm.d.ts new file mode 100644 index 000000000000..e3b45032eccc --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/wasm.d.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** @fileoverview + * TypeScript does not provide a separate lib for WASM types and the Node.js + * types (`@types/node`) does not contain them either. This type definition + * file provides type information for the subset of functionality required + * by the Angular build process. Ideally this can be removed when the WASM + * type situation has improved. + */ + +declare namespace WebAssembly { + class Module { + constructor(data: Uint8Array); + + static imports(mod: Module): { module: string; name: string }[]; + static exports(mode: Module): { name: string }[]; + } + function compile(data: Uint8Array): Promise; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts new file mode 100644 index 000000000000..778ca55bb814 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts @@ -0,0 +1,581 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import { + type ɵParsedTranslation, + ɵisMissingTranslationError, + ɵmakeTemplateObject, + ɵparseMessage, + ɵtranslate, +} from '@angular/localize'; +import { MagicString } from 'magic-string'; +import { deserialize } from 'node:v8'; +import { parseSync } from 'oxc-parser'; +import { traversePostOrder } from '../oxc/traversal'; +import { createSharedTranslationProxy } from './i18n-translation-reader'; +import { loadLocaleData } from './locale-data'; + +/** + * The options passed to the inliner for each code request + */ +export 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 serialized translation messages for the locale that should be used during the inlining + * process of the file. A SharedArrayBuffer or Blob is used so that the messages are shared with + * the Worker by reference instead of being copied into it for every request. + */ + translation?: Blob | SharedArrayBuffer; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; +} + +/** + * The response returned from a code request. + */ +export interface InlineCodeResult { + output: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The options passed to the inliner for a batch file request + */ +export interface InlineFileBatchRequest { + /** + * The filename that should be processed. + */ + filename: string; + + /** + * The file content as a Blob. + */ + code: Blob; + + /** + * Optional sourcemap content as a Blob. + */ + map?: Blob; + + /** + * The locale specifiers and optional translations to use during the inlining process of the file. + */ + locales: ReadonlyMap; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; + + /** + * Whether the file data should be treated as ephemeral and not cached long-term in the Worker. + * Typically true when all remaining locales for the file are processed in a single batch. + */ + ephemeral?: boolean; + + /** + * The list of active locales in the current inlining window. Any cached translation dictionaries + * not present in this list will be evicted from the Worker's memory cache. + */ + activeLocales?: string[]; + + /** + * The current inlining generation counter. When a request with a new generation is received, + * all long-term worker caches are cleared. + */ + generation?: number; +} + +/** + * The result for a single locale within a batch file request. + */ +export interface InlineLocaleResult { + locale: string; + code?: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The response returned from a batch file request. + */ +export type InlineFileBatchResult = + | { + file: string; + unmodified: true; + messages: { type: 'error' | 'warning'; message: string }[]; + } + | { + file: string; + unmodified?: false; + results: InlineLocaleResult[]; + }; + +/** + * Cached file data including code and extracted localization metadata. + */ +interface CachedFileData { + code: string; + metadata: FileLocalizeMetadata; +} + +/** + * Cache of file data promises keyed by filename. + */ +const fileDataCache = new Map>(); + +/** + * Cache of deserialized translation messages keyed by locale. + */ +const deserializedTranslations = new Map>>(); + +/** + * The current inlining generation for this worker. + */ +let currentGeneration: number | undefined; + +let remapping: typeof import('@ampproject/remapping').default | undefined; + +/** + * Retrieves the file data for a filename, loading and extracting localization metadata. + * If `cache` is true, the result is cached in `fileDataCache` across requests in this Worker. + * If `cache` is false (ephemeral), the result is not retained in `fileDataCache`, allowing it + * to be garbage-collected once the batch request finishes. + * + * @param filename The name of the file to load. + * @param codeBlob The source code file as a Blob. + * @param cache Whether to cache the loaded file data in the Worker's long-term cache. + * @returns The cached or newly extracted code and localization metadata. + */ +function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise { + const existing = fileDataCache.get(filename); + if (existing) { + if (!cache) { + fileDataCache.delete(filename); + } + + return existing; + } + + const fileDataPromise = (async () => { + const code = await codeBlob.text(); + const metadata = extractLocalizeMetadata(filename, code); + + return { code, metadata }; + })(); + + if (cache) { + fileDataPromise.catch(() => { + fileDataCache.delete(filename); + }); + fileDataCache.set(filename, fileDataPromise); + } + + return fileDataPromise; +} + +/** + * Deserializes or wraps the translation messages for a locale, reusing the result for any + * subsequent request that targets the same locale. + * @param locale The locale identifier. + * @param translation Optional serialized translation messages (SharedArrayBuffer or Blob). + * @returns The translation messages, or undefined if the locale has no translations. + */ +function loadTranslation( + locale: string, + translation?: Blob | SharedArrayBuffer, +): Promise> | undefined { + if (!translation) { + return undefined; + } + + let messagesPromise = deserializedTranslations.get(locale); + if (!messagesPromise) { + if (translation instanceof Blob) { + messagesPromise = translation + .arrayBuffer() + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) + .catch((error) => { + deserializedTranslations.delete(locale); + throw error; + }); + } else { + messagesPromise = Promise.resolve(createSharedTranslationProxy(translation)); + } + deserializedTranslations.set(locale, messagesPromise); + } + + return messagesPromise; +} + +/** + * Inlines multiple locales and translations into a JavaScript file that contains `$localize` usage. + * + * @param request An InlineFileBatchRequest object representing the options for inlining. + * @returns An object containing the inlined results for each requested locale. + */ +export async function inlineFileBatch( + request: InlineFileBatchRequest, +): Promise { + if (request.generation !== undefined && request.generation !== currentGeneration) { + currentGeneration = request.generation; + fileDataCache.clear(); + deserializedTranslations.clear(); + } + + if (request.activeLocales) { + const activeSet = new Set(request.activeLocales); + for (const locale of deserializedTranslations.keys()) { + if (!activeSet.has(locale)) { + deserializedTranslations.delete(locale); + } + } + } + + const { code, metadata } = await loadFileData(request.filename, request.code, !request.ephemeral); + + // Fast path: file has no $localize call sites or locale insert sites + if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) { + return { + file: request.filename, + unmodified: true, + messages: (metadata.diagnostics ?? []).map((message) => ({ + type: 'error' as const, + message, + })), + }; + } + + // Parse the sourcemap once for the entire batch if provided. + // It will naturally be garbage-collected after this batch action returns. + let map: SourceMapInput | undefined; + if (request.map) { + const rawMap = await request.map.text(); + map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; + } + + const results = []; + for (const [locale, translation] of request.locales) { + const result = await inlineLocalize( + code, + map, + metadata, + locale, + await loadTranslation(locale, translation), + request.filename, + request.missingTranslation, + ); + + results.push({ + locale, + code: result.code, + map: result.map, + messages: result.diagnostics, + }); + } + + return { + file: request.filename, + results, + }; +} + +/** + * 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 async function inlineCode(request: InlineCodeRequest): Promise { + const metadata = extractLocalizeMetadata(request.filename, request.code); + const result = await inlineLocalize( + request.code, + undefined, + metadata, + request.locale, + await loadTranslation(request.locale, request.translation), + request.filename, + request.missingTranslation, + ); + + return { + output: result.code ?? request.code, + messages: result.diagnostics, + }; +} + +type DiagnosticMessage = { type: 'error' | 'warning'; message: string }; + +/** + * Translates a $localize message using @angular/localize low-level runtime functions. + * Handles missing translations and errors without requiring @angular/localize/tools. + */ +function translateMessage( + diagnostics: DiagnosticMessage[], + translations: Record | undefined, + messageParts: TemplateStringsArray, + substitutions: readonly number[], + missingTranslation: 'error' | 'warning' | 'ignore', +): [TemplateStringsArray, readonly number[]] { + // Fast path: untranslated locale (e.g. source locale without a translation dictionary). + // Directly parse the message parts to strip metadata and placeholder markers without + // throwing and catching a MissingTranslationError exception. + if (translations === undefined) { + const message = ɵparseMessage(messageParts, substitutions); + + return [ɵmakeTemplateObject(message.messageParts, message.messageParts), substitutions]; + } + + try { + return ɵtranslate(translations, messageParts, substitutions) as [ + TemplateStringsArray, + readonly number[], + ]; + } catch (error) { + if (ɵisMissingTranslationError(error)) { + if (missingTranslation !== 'ignore') { + diagnostics.push({ type: missingTranslation, message: error.message }); + } + + return [ + ɵmakeTemplateObject(error.parsedMessage.messageParts, error.parsedMessage.messageParts), + substitutions, + ]; + } else { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ type: 'error', message }); + + return [messageParts, substitutions]; + } + } +} + +/** + * Metadata for a `$localize` tagged template expression extracted from the AST. + */ +interface LocalizeCallSite { + start: number; + end: number; + messageParts: TemplateStringsArray; + expressions: { start: number; end: number }[]; + expressionIndexes: number[]; +} + +/** + * Metadata extracted from a JavaScript file AST needed for localization inlining. + */ +interface FileLocalizeMetadata { + callSites: LocalizeCallSite[]; + localeInsertSites: { start: number; end: number }[]; + diagnostics?: string[]; +} + +/** + * Extracts localization call sites and locale insertion points from JavaScript code using OXC. + * + * @param filename The name of the file being processed. + * @param code The JavaScript source code. + * @returns The extracted localization metadata. + */ +function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMetadata { + const { program } = parseSync(filename, code, { + sourceType: 'unambiguous', + }); + + if (!program) { + throw new Error(`Unknown error occurred parsing file "${filename}" with OXC.`); + } + + const callSites: LocalizeCallSite[] = []; + const localeInsertSites: { start: number; end: number }[] = []; + let diagnostics: string[] | undefined; + + traversePostOrder(program, (node) => { + if (node.type === 'Literal') { + if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') { + localeInsertSites.push({ start: node.start, end: node.end }); + } + } else if (node.type === 'TaggedTemplateExpression') { + if (node.tag.type === 'Identifier' && node.tag.name === '$localize') { + const cooked: string[] = []; + const raw: string[] = []; + let hasMalformedEscape = false; + + for (const q of node.quasi.quasis) { + if (q.value.cooked === null || q.value.cooked === undefined) { + hasMalformedEscape = true; + (diagnostics ??= []).push( + `Malformed escape sequence in $localize template literal in file "${filename}".`, + ); + break; + } + cooked.push(q.value.cooked); + raw.push(q.value.raw); + } + + if (!hasMalformedEscape) { + const messageParts = Object.assign(cooked, { raw }); + const expressions = node.quasi.expressions.map((expr) => ({ + start: expr.start, + end: expr.end, + })); + const expressionIndexes = expressions.map((_, index) => index); + + callSites.push({ + start: node.start, + end: node.end, + messageParts, + expressions, + expressionIndexes, + }); + } + } + } + }); + + return { callSites, localeInsertSites, diagnostics }; +} + +/** + * Escapes a template literal string part for insertion into an ES template literal (backticks). + * Uses JSON.stringify for base escaping of control characters and backslashes, then unescapes + * double quotes and escapes backticks and `${` expression delimiters in a single pass. + */ +function escapeTemplatePart(part: string): string { + return JSON.stringify(part) + .slice(1, -1) + .replace(/\\"|`|\$\{/g, (match) => (match === '\\"' ? '"' : '\\' + match)); +} + +/** + * Inlines translations into code using previously extracted localization metadata. + * + * @param code The source code to transform. + * @param map Optional source map for the source code. + * @param metadata Extracted localization metadata. + * @param locale The target locale identifier. + * @param translation The translation messages dictionary, or undefined for untranslated locale. + * @param filename The name of the file being transformed. + * @param missingTranslation How to handle missing translations. + * @returns The transformed code, optional remapped source map, and diagnostics. + */ +async function inlineLocalize( + code: string, + map: SourceMapInput | undefined, + metadata: FileLocalizeMetadata, + locale: string, + translation: Record | undefined, + filename: string, + missingTranslation: 'error' | 'warning' | 'ignore' = 'warning', +) { + const magicString = new MagicString(code); + const diagnostics: DiagnosticMessage[] = []; + + if (metadata.diagnostics) { + for (const message of metadata.diagnostics) { + diagnostics.push({ type: 'error', message }); + } + } + + if (metadata.localeInsertSites.length > 0) { + const localeData = await loadLocaleData(locale); + if (localeData.error) { + diagnostics.push({ type: 'error', message: localeData.error }); + } else if (localeData.warning) { + diagnostics.push({ type: 'warning', message: localeData.warning }); + } + let injected = false; + for (const site of metadata.localeInsertSites) { + magicString.overwrite( + site.start, + site.end, + JSON.stringify(locale) + (localeData.code && !injected ? `;\n${localeData.code}\n;` : ''), + ); + injected = true; + } + } + + for (const callSite of metadata.callSites) { + const [translatedParts, translatedSubstitutions] = translateMessage( + diagnostics, + translation, + callSite.messageParts, + callSite.expressionIndexes, + translation === undefined ? 'ignore' : missingTranslation, + ); + + // Reconstruct the new template/string literal replacement + let replacement: string; + if (translatedSubstitutions.length === 0) { + replacement = JSON.stringify(translatedParts[0]); + } else { + replacement = '`'; + for (let i = 0; i < translatedParts.length; i++) { + replacement += escapeTemplatePart(translatedParts[i]); + + if (i < translatedSubstitutions.length) { + const originalIndex = translatedSubstitutions[i]; + const expr = callSite.expressions[originalIndex]; + const exprCode = magicString.slice(expr.start, expr.end); + replacement += '${' + exprCode + '}'; + } + } + replacement += '`'; + } + + magicString.overwrite(callSite.start, callSite.end, replacement); + } + + if (!magicString.hasChanged()) { + return { + code: undefined, + map: undefined, + diagnostics, + }; + } + + const outputCode = magicString.toString(); + let outputMap; + if (map) { + // A decoded map is generated here rather than an encoded one because remapping decodes its + // inputs. Encoding the mappings only for remapping to immediately decode them again doubles + // the peak memory of the largest structure involved in inlining a file. + const rawMap = magicString.generateDecodedMap({ + source: filename, + includeContent: true, + hires: 'boundary', + }); + remapping ??= (await import('@ampproject/remapping')).default; + outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null); + } + + return { + code: outputCode, + map: outputMap && JSON.stringify(outputMap), + diagnostics, + }; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner.ts b/packages/angular/build/src/tools/i18n/i18n-inliner.ts new file mode 100644 index 000000000000..d4a4c4cd631f --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner.ts @@ -0,0 +1,749 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { extname, join } from 'node:path'; +import { serialize } from 'node:v8'; +import { + type Cache, + type PersistentCacheStore, + createPersistentCacheStore, +} from '../../utils/cache'; +import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; +import type { + InlineCodeRequest, + InlineCodeResult, + InlineFileBatchRequest, + InlineFileBatchResult, +} from './i18n-inliner-worker'; +import { encodeTranslationToBuffer } from './i18n-translation-encoder'; + +// TODO: Convert to import.meta usage during ESM transition +const localRequire = createRequire(__filename); +const INLINER_WORKER_PATH = localRequire.resolve('./i18n-inliner-worker'); + +interface WorkerTaskMap { + inlineFileBatch: { + request: InlineFileBatchRequest; + result: InlineFileBatchResult; + }; + inlineCode: { + request: InlineCodeRequest; + result: InlineCodeResult; + }; +} + +/** + * A keyword used to indicate if a JavaScript file may require inlining of translations. + * This keyword is used to avoid processing files that would not otherwise need i18n processing. + */ +const LOCALIZE_KEYWORD = '$localize'; + +/** + * The baseline number of locales to process concurrently in a single sliding window. + * This caps peak worker memory on low-core machines while maintaining multi-locale batching throughput. + */ +const DEFAULT_LOCALE_WINDOW_SIZE = 8; + +/** + * Minimum byte size threshold for a file to be eligible for multi-batch sharding. + * Files below this threshold (< 100 KB) are processed in a single batch to minimize IPC overhead. + */ +const SMALL_FILE_FLOOR_BYTES = 100 * 1024; + +/** + * Ratio of the maximum file size in a window to consider a file "dominant". + * Files within 70% of the largest file are sharded across bounded workers for concurrency. + */ +const DOMINANT_FILE_RATIO = 0.7; + +/** + * Maximum number of worker threads to allocate concurrently across dominant files. + * Capping concurrency on dominant files (which hold large AST and sourcemap trees) + * prevents V8 heap and RSS explosion on machines with high CPU core counts. + */ +const MAX_DOMINANT_WORKERS = 4; + +/** + * Serializes the translation messages for a locale for transfer to an inliner Worker. + * + * A SharedArrayBuffer is preferred because it enables zero-copy shared memory access + * and on-demand string decoding across all worker threads. Falls back to a Blob when + * SharedArrayBuffer is unavailable. + * + * @param translation The translation messages for a locale, if the locale has any. + * @param translationIntegrity Optional content hash of the translation file for cache lookup. + * @param translationCache Optional Cache instance for binary translation buffers. + * @returns A SharedArrayBuffer or Blob containing the serialized messages, or undefined if none. + */ +async function serializeTranslation( + translation: Record | undefined, + translationIntegrity?: string, + translationCache?: Cache, +): Promise { + if (!translation) { + return undefined; + } + + if (typeof SharedArrayBuffer !== 'undefined') { + if (translationIntegrity && translationCache) { + // Look up or generate binary translation data in the persistent cache. + // A Uint8Array view is stored in the cache store to allow binary persistence. + const binaryData = await translationCache.getOrCreate(translationIntegrity, () => { + return new Uint8Array(encodeTranslationToBuffer(translation)); + }); + + // On a cache miss, getOrCreate returns the newly created Uint8Array backed by the + // original SharedArrayBuffer. Return it directly to avoid an unnecessary allocation and copy. + if ( + binaryData.buffer instanceof SharedArrayBuffer && + binaryData.byteOffset === 0 && + binaryData.byteLength === binaryData.buffer.byteLength + ) { + return binaryData.buffer; + } + + // On a warm cache hit, the restored data is backed by a standard ArrayBuffer from disk. + // Copy it into a SharedArrayBuffer so worker threads can access it via zero-copy shared memory. + const buffer = new SharedArrayBuffer(binaryData.byteLength); + new Uint8Array(buffer).set(binaryData); + + return buffer; + } + + // When persistent caching is not configured, encode directly into a SharedArrayBuffer. + return encodeTranslationToBuffer(translation); + } + + return new Blob([serialize(translation)]); +} + +/** + * Inlining options that should apply to all transformed code. + */ +export interface I18nInlinerOptions { + missingTranslation: 'error' | 'warning' | 'ignore'; + + /** + * The maximum number of concurrent translation inlining operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; + persistentCachePath?: string; + localizeVersion?: string; +} + +/** + * Options for inlining a specific locale. + */ +export interface LocaleInlineOptions { + /** + * The locale specifier string. + */ + locale: string; + + /** + * The translation messages for the locale, or undefined for the source/untranslated locale. + */ + translation?: Record; + + /** + * An optional content integrity hash of the translation file(s) for fast cache key calculation. + */ + translationIntegrity?: string; +} + +/** + * Result of inlining for a specific locale. + */ +export interface LocaleInlineResult { + outputFiles: BuildOutputFile[]; + errors: string[]; + warnings: string[]; +} + +/** + * Transformation result for a single file and locale combination. + */ +interface TransformedFileResult { + file: string; + code?: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * An uncached transformation request entry for a file within a specific locale. + */ +interface UncachedLocaleEntry { + locale: string; + cacheKey?: string; + translation?: Blob | SharedArrayBuffer; +} + +/** + * A class that performs i18n translation inlining of JavaScript code. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Inlining is only performed on code that contains the + * localize function (`$localize`). + */ +export class I18nInliner { + #cacheInitFailed = false; + readonly #workerPool: WorkerPool; + readonly #ownsWorkerPool: boolean; + #cacheStore: PersistentCacheStore | undefined; + #transformedFileCache: Cache | undefined; + #translationCache: Cache | undefined; + #generation = 0; + + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool.maxThreads || 1); + } + + constructor( + private readonly options: I18nInlinerOptions, + workerPool?: WorkerPool, + ) { + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + + this.#ownsWorkerPool = !workerPool; + // Piscina uses object spread against default options internally. Only define + // maxThreads when specified to avoid overwriting Piscina's default thread count + // with undefined. + this.#workerPool = + workerPool ?? + new WorkerPool({ + ...(options.maxConcurrency !== undefined && { maxThreads: options.maxConcurrency }), + }); + } + + #partitionFiles(files: Iterable): { + filenames: string[]; + localizeFiles: Map; + localizeMaps: Map; + unmodifiedFiles: BuildOutputFile[]; + } { + const filenames: string[] = []; + const localizeFiles = new Map(); + const localizeMaps = new Map(); + const unmodifiedFiles: BuildOutputFile[] = []; + + const pendingMaps: BuildOutputFile[] = []; + for (const file of files) { + if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.ServerRoot) { + // Skip also the server entry-point. + // Skip stats and similar files. + continue; + } + + const fileExtension = extname(file.path); + if (fileExtension === '.js' || fileExtension === '.mjs') { + // Check if localizations are present + const contentBuffer = Buffer.isBuffer(file.contents) + ? file.contents + : Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength); + const hasLocalize = contentBuffer.includes(LOCALIZE_KEYWORD); + + if (hasLocalize) { + localizeFiles.set(file.path, file); + filenames.push(file.path); + + continue; + } + } else if (fileExtension === '.map') { + // The related JS file may not have been checked yet. To ensure that map files are not + // missed, store any pending map files and check them after all output files. + pendingMaps.push(file); + continue; + } + + unmodifiedFiles.push(file); + } + + // Check if any pending map files should be processed by checking if the parent JS file is present + for (const file of pendingMaps) { + const jsPath = file.path.slice(0, -4); + if (localizeFiles.has(jsPath)) { + localizeMaps.set(jsPath, file); + } else { + unmodifiedFiles.push(file); + } + } + + return { filenames, localizeFiles, localizeMaps, unmodifiedFiles }; + } + + /** + * Performs inlining of translations across multiple locales in parallel. + * + * An adaptive 2D task-partitioning algorithm distributes (files x locales) work units + * across all worker threads while caching AST metadata and sourcemaps in worker memory. + * + * @param files The build output files to transform. + * @param locales The locales and translations to inline. + * @returns A map of locale names to their inlined output files and diagnostics. + */ + async inlineAll( + files: Iterable, + locales: Iterable, + ): Promise> { + await this.initCache(); + + const generation = ++this.#generation; + const { missingTranslation, localizeVersion } = this.options; + const localeList = Array.from(locales); + + if (localeList.length === 0) { + return new Map(); + } + + const { filenames, localizeFiles, localizeMaps, unmodifiedFiles } = this.#partitionFiles(files); + + const fileResultsByLocale = new Map>(); + for (const { locale } of localeList) { + assert(!fileResultsByLocale.has(locale), 'Duplicate locale provided to inliner: ' + locale); + fileResultsByLocale.set(locale, new Map()); + } + + // Process locales in sliding windows to cap peak worker memory. + // Ensure the window has at least enough locales to saturate all available workers on high-core machines. + const windowSize = Math.max(DEFAULT_LOCALE_WINDOW_SIZE, this.#maxConcurrency); + for (let i = 0; i < localeList.length; i += windowSize) { + const windowLocales = localeList.slice(i, i + windowSize); + const activeLocales = windowLocales.map((item) => item.locale); + const isLastWindow = i + windowSize >= localeList.length; + + // Pre-calculate cache key bases and serialized Blobs for each locale in this window + const localeCacheBases = new Map(); + const localeBlobs = new Map(); + + await Promise.all( + windowLocales.map(async ({ locale, translation, translationIntegrity }) => { + const serialized = await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ); + localeBlobs.set(locale, serialized); + + if (this.#cacheStore) { + localeCacheBases.set( + locale, + calculateHash( + JSON.stringify({ + locale, + translation: translationIntegrity || translation, + missingTranslation, + localizeVersion, + }), + ), + ); + } + }), + ); + + const uncachedByFile = new Map(); + + if (this.#transformedFileCache) { + const cache = this.#transformedFileCache; + const cacheChecks: Promise[] = []; + + for (const filename of filenames) { + const file = localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); + + const fileEntriesPromises = windowLocales.map( + async ({ locale }): Promise => { + const fileCacheKeyBase = localeCacheBases.get(locale); + assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + const cacheKey = hasher.digest(); + + try { + const result = await cache.get(cacheKey); + if (result) { + fileResultsByLocale.get(locale)?.set(filename, result); + + return; + } + } catch {} + + return { + locale, + cacheKey, + translation: localeBlobs.get(locale), + }; + }, + ); + + cacheChecks.push( + Promise.all(fileEntriesPromises).then((entries) => { + const filtered = entries.filter((e): e is UncachedLocaleEntry => e !== undefined); + if (filtered.length > 0) { + uncachedByFile.set(filename, filtered); + } + }), + ); + } + + await Promise.all(cacheChecks); + } else { + for (const filename of filenames) { + uncachedByFile.set( + filename, + windowLocales.map(({ locale }) => ({ + locale, + translation: localeBlobs.get(locale), + })), + ); + } + } + + // Adaptive 2D Sharding for uncached tasks in this window + if (uncachedByFile.size > 0) { + await this.#processUncachedBatches( + localizeFiles, + localizeMaps, + uncachedByFile, + fileResultsByLocale, + activeLocales, + isLastWindow, + generation, + ); + } + } + + // Assemble final results in deterministic order per locale + const resultsByLocale = new Map(); + + for (const { locale } of localeList) { + const fileResults = fileResultsByLocale.get(locale); + const outputFiles: BuildOutputFile[] = []; + const errors: string[] = []; + const warnings: string[] = []; + + if (fileResults) { + for (const filename of filenames) { + const originalFile = localizeFiles.get(filename); + assert(originalFile !== undefined, 'Localize file must exist: ' + filename); + + const fileResult = fileResults.get(filename); + if (!fileResult) { + continue; + } + + const type = originalFile.type; + if (fileResult.code != undefined) { + outputFiles.push(createOutputFile(filename, fileResult.code, type)); + } else { + outputFiles.push(originalFile.clone()); + } + + const originalMap = localizeMaps.get(filename); + if (fileResult.map !== undefined) { + outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type)); + } else if (originalMap !== undefined) { + outputFiles.push(originalMap.clone()); + } + + for (const message of fileResult.messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } + } + } + } + + // Include cloned unmodified files for every locale + outputFiles.push(...unmodifiedFiles.map((file) => file.clone())); + + resultsByLocale.set(locale, { + outputFiles, + errors, + warnings, + }); + } + + return resultsByLocale; + } + + async #processUncachedBatches( + localizeFiles: Map, + localizeMaps: Map, + uncachedByFile: Map, + fileResultsByLocale: Map>, + activeLocales?: string[], + isLastWindow = true, + generation?: number, + ): Promise { + const workerCount = this.#maxConcurrency; + + // Extract file data and identify the heaviest file size in a single pass + let maxFileSize = 0; + const sortedFiles = Array.from(uncachedByFile, ([filename, entries]) => { + const codeFile = localizeFiles.get(filename); + assert(codeFile !== undefined, 'Localize file must exist: ' + filename); + const fileSize = codeFile.contents.byteLength; + if (fileSize > maxFileSize) { + maxFileSize = fileSize; + } + + return { filename, entries, codeFile, fileSize }; + }); + + // Sort files descending by byte size (Longest Processing Time First / LPT). + // Heavy files (e.g. main.js) are queued first to saturate all worker threads immediately, + // while small files act as gap fillers near the window barrier to prevent tail stragglers. + sortedFiles.sort((a, b) => b.fileSize - a.fileSize); + + const dominantThreshold = Math.max(SMALL_FILE_FLOOR_BYTES, maxFileSize * DOMINANT_FILE_RATIO); + let dominantCount = 0; + for (const { fileSize } of sortedFiles) { + if (fileSize >= dominantThreshold) { + dominantCount++; + } else { + break; + } + } + const dominantTargetWorkers = + dominantCount > 0 + ? Math.max(1, Math.min(workerCount - 1, Math.floor(MAX_DOMINANT_WORKERS / dominantCount))) + : 1; + + const workerTasks: Promise[] = []; + + for (const { filename, entries, codeFile, fileSize } of sortedFiles) { + const mapFile = localizeMaps.get(filename); + const codeBlob = new Blob([codeFile.contents]); + const mapBlob = mapFile ? new Blob([mapFile.contents]) : undefined; + + let localesPerBatch: number; + if (uncachedByFile.size === 1) { + // Single file in window: shard across bounded workers to balance throughput and peak memory + const targetWorkers = Math.min(workerCount, MAX_DOMINANT_WORKERS); + localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers)); + } else if (fileSize < SMALL_FILE_FLOOR_BYTES) { + // Small chunks (< 100 KB): process all locales in 1 batch to eliminate IPC overhead + localesPerBatch = entries.length; + } else if (fileSize >= dominantThreshold) { + // Dominant file(s) with multiple files: shard across bounded workers while always reserving + // at least one worker to process smaller chunks in parallel without starvation. + localesPerBatch = Math.max(1, Math.ceil(entries.length / dominantTargetWorkers)); + } else if (uncachedByFile.size >= workerCount) { + // Worker pool is already saturated by file-level concurrency; + // process all locales in a single batch to avoid duplicate parsing and IPC overhead. + localesPerBatch = entries.length; + } else { + // Intermediate files with idle workers: moderate sharding + localesPerBatch = Math.max(1, Math.ceil(entries.length / 2)); + } + + const ephemeral = isLastWindow && entries.length <= localesPerBatch; + for (let i = 0; i < entries.length; i += localesPerBatch) { + const batchEntries = entries.slice(i, i + localesPerBatch); + const task = (async () => { + const batchResult = await this.#runWorkerTask('inlineFileBatch', { + filename, + code: codeBlob, + map: mapBlob, + locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), + missingTranslation: this.options.missingTranslation, + ephemeral, + activeLocales, + generation, + }); + + if (batchResult.unmodified) { + const unmodifiedResult: TransformedFileResult = { + file: filename, + messages: batchResult.messages, + }; + + const cachePromises: Promise[] = []; + for (const { locale, cacheKey } of batchEntries) { + fileResultsByLocale.get(locale)?.set(filename, unmodifiedResult); + + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, unmodifiedResult)); + } + } + await Promise.allSettled(cachePromises); + } else { + const cachePromises: Promise[] = []; + for (const res of batchResult.results) { + const matchingEntry = batchEntries.find((e) => e.locale === res.locale); + const cacheKey = matchingEntry?.cacheKey; + const fileResult: TransformedFileResult = { + file: filename, + code: res.code, + map: res.map, + messages: res.messages, + }; + + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, fileResult)); + } + + fileResultsByLocale.get(res.locale)?.set(filename, fileResult); + } + await Promise.allSettled(cachePromises); + } + })(); + + workerTasks.push(task); + } + } + + await Promise.all(workerTasks); + } + + #runWorkerTask( + name: T, + request: WorkerTaskMap[T]['request'], + ): Promise { + return this.#workerPool.run(request, { + filename: INLINER_WORKER_PATH, + name, + }) as Promise; + } + + /** + * Performs inlining of translations for the provided locale and translations. + * + * @param files The build output files to transform. + * @param locale The string representing the locale to inline. + * @param translation The translation messages to use when inlining. + * @param translationIntegrity An optional integrity value for the translation messages to use for caching. + * @returns A promise that resolves to an array of OutputFiles representing a translated result. + */ + async inlineForLocale( + files: Iterable, + locale: string, + translation: Record | undefined, + translationIntegrity?: string, + ): Promise { + const results = await this.inlineAll(files, [{ locale, translation, translationIntegrity }]); + const result = results.get(locale); + assert(result !== undefined, `Result for locale '${locale}' should be present.`); + + return result; + } + + async inlineTemplateUpdate( + locale: string, + translation: Record | undefined, + templateCode: string, + templateId: string, + translationIntegrity?: string, + ): Promise<{ code: string; errors: string[]; warnings: string[] }> { + const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD); + + if (!hasLocalize) { + return { + code: templateCode, + errors: [], + warnings: [], + }; + } + + const { output, messages } = await this.#runWorkerTask('inlineCode', { + code: templateCode, + filename: templateId, + locale, + missingTranslation: this.options.missingTranslation, + translation: await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ), + }); + + const errors: string[] = []; + const warnings: string[] = []; + 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. + */ + async close(): Promise { + await Promise.allSettled([ + this.#cacheStore?.close(), + this.#ownsWorkerPool ? this.#workerPool.destroy() : undefined, + ]); + } + + /** + * Initializes the cache for storing translated bundles. + * If the cache is already initialized, it does nothing. + * + * @returns A promise that resolves once the cache initialization process is complete. + */ + private async initCache(): Promise { + if (this.#cacheStore || this.#cacheInitFailed) { + return; + } + + const { persistentCachePath } = this.options; + // Webcontainers currently do not support this persistent cache store. + if (!persistentCachePath || process.versions.webcontainer) { + return; + } + + // Initialize a persistent cache for i18n transformations. + try { + const [, cacheStore] = await Promise.all([ + initializeHash(), + createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')), + ]); + this.#cacheStore = cacheStore; + this.#transformedFileCache = cacheStore.createCache('transforms'); + this.#translationCache = cacheStore.createCache('translations'); + } catch { + this.#cacheInitFailed = true; + + // eslint-disable-next-line no-console + console.warn( + 'Unable to initialize JavaScript cache storage.\n' + + 'This will not affect the build output content but may result in slower builds.', + ); + } + } +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts new file mode 100644 index 000000000000..2fbb6e02ce31 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts @@ -0,0 +1,1187 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import { transform } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createPersistentCacheStore } from '../../utils/cache'; +import { initializeHash } from '../../utils/hash'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; +import { I18nInliner, type I18nInlinerOptions } from './i18n-inliner'; + +/** + * A module that uses a `$localize` message with an explicit message identifier so that the + * translations for a test can be keyed by a known name. + */ +const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n'; + +/** + * Creates the parsed translation form that `@angular/localize` expects. + */ +function parsedTranslation( + parts: string[], + placeholderNames: string[] = [], + text?: string, +): ɵParsedTranslation { + return { + messageParts: Object.assign([...parts], { raw: [...parts] }), + placeholderNames, + text: text ?? parts.join(''), + }; +} + +function translationFor(message: string): ɵParsedTranslation { + return parsedTranslation([message], [], message); +} + +function browserFile(path: string, contents: string): BuildOutputFile { + return createOutputFile(path, contents, BuildOutputFileType.Browser); +} + +function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile { + const file = outputFiles.find((output) => output.path === path); + if (!file) { + throw new Error(`Expected output files to contain '${path}'.`); + } + + return file; +} + +describe('I18nInliner', () => { + let inliner: I18nInliner | undefined; + + beforeAll(async () => { + await initializeHash(); + }); + + // A single thread is used throughout so that every file of every locale is inlined by the same + // Worker. Any translation state that a Worker retains between requests is then observable. + function createInliner(options?: Partial, maxConcurrency = 1): I18nInliner { + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency, ...options }); + + return inliner; + } + + afterEach(async () => { + await inliner?.close(); + inliner = undefined; + }); + + it('inlines the translations of a locale', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of each locale when several are inlined in sequence', async () => { + const files = [browserFile('main.js', GREETING_SOURCE)]; + const localeInliner = createInliner(); + + const french = await localeInliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + const german = await localeInliner.inlineForLocale(files, 'de', { + greeting: translationFor('Hallo'), + }); + // Repeats the first locale to cover a locale being inlined again after another has been. + const frenchAgain = await localeInliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + + expect(findFile(french.outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(german.outputFiles, 'main.js').text).toContain('"Hallo"'); + expect(findFile(frenchAgain.outputFiles, 'main.js').text).toContain('"Bonjour"'); + }); + + it('inlines the translations of a locale into every file that uses them', async () => { + const { outputFiles } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE), browserFile('chunk.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'chunk.js').text).toContain('"Bonjour"'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'en-US', + undefined, + ); + + // A locale without translations is the source locale, so its messages are not missing. + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('retains the original messages with placeholders for a locale without translations', async () => { + const source = 'export const greeting = $localize`:@@greeting:Hello ${name}:NAME:!`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'en-US', + undefined, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('`Hello ${name}!`'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('warns and retains the original message when a locale is missing a translation', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { unrelated: translationFor('Sans rapport') }, + ); + + expect(errors).toEqual([]); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('greeting'); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('errors and retains the original message when missingTranslation is "error"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'error', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('greeting'); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('ignores missing translations when missingTranslation is "ignore"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'ignore', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('replaces the locale placeholder with the locale being inlined', async () => { + // The placeholder is only inlined for files that use `$localize`, which is where the build + // inserts it, so the message is present alongside it here. + const { outputFiles } = await createInliner().inlineForLocale( + [ + browserFile( + 'main.js', + `export const locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`, + ), + ], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'main.js').text).toContain('"fr"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('___NG_LOCALE_INSERT___'); + }); + + it('remaps the source map of a file it modifies', async () => { + // esbuild provides a map from the emitted code back to an original file, matching what the + // inliner receives during a build. + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const { outputFiles } = await createInliner().inlineForLocale( + [browserFile('main.js', code), browserFile('main.js.map', map)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + const outputMap = JSON.parse(findFile(outputFiles, 'main.js.map').text) as { + version: number; + sources: string[]; + mappings: string; + }; + + expect(outputMap.version).toBe(3); + // The map must still resolve to the original file rather than to the inliner's input. + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + }); + + describe('inlineTemplateUpdate', () => { + it('inlines the translations of a locale into a template update', async () => { + const { code, errors, warnings } = await createInliner().inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + expect(code).not.toContain('$localize'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { code, errors, warnings } = await createInliner().inlineTemplateUpdate( + 'en-US', + undefined, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Hello"'); + }); + + it('returns the code untouched when it has no localize calls', async () => { + const source = 'export const answer = 42;\n'; + const { code } = await createInliner().inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + source, + 'template-id', + ); + + expect(code).toBe(source); + }); + + it('inlines translations with translationIntegrity and persistent caching', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-template-cache-test-')); + + try { + const inliner = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-template-integrity-123'; + const { code, errors, warnings } = await inliner.inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + translationIntegrity, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + await inliner.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + }); + + it('safely inlines translations containing special characters, quotes, and newlines', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { + greeting: translationFor('Bonjour "mon ami" \\ \' \n '), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const greeting = "Bonjour \\"mon ami\\" \\\\ \' \\n ";\n', + ); + }); + + it('inlines translations containing placeholders', async () => { + const source = 'export const welcome = (name) => $localize`:@@welcome:Hello ${name}!`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + welcome: parsedTranslation(['Bonjour ', ' !'], ['PH'], 'Bonjour {$PH} !'), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const welcome = (name) => `Bonjour ${name} !`;\n', + ); + }); + + it('inlines multiple localize calls within the same file', async () => { + const source = + 'export const a = $localize`:@@greeting:Hello`;\nexport const b = $localize`:@@farewell:Goodbye`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const a = "Bonjour";\nexport const b = "Au revoir";\n', + ); + }); + + it('inlines translations across multiple files using multiple worker threads in parallel', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk1.js', GREETING_SOURCE), + browserFile('chunk2.js', GREETING_SOURCE), + browserFile('chunk3.js', GREETING_SOURCE), + ]; + inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 4, + }); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + for (const name of ['main.js', 'chunk1.js', 'chunk2.js', 'chunk3.js']) { + expect(findFile(outputFiles, name).text).toBe('export const greeting = "Bonjour";\n'); + } + }); + + it('leaves files without localize calls unmodified', async () => { + const { outputFiles } = await createInliner().inlineForLocale( + [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n'); + }); + + it('inlines nested $localize calls in post-order', async () => { + const source = + 'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + inner: translationFor('Pomme'), + outer: parsedTranslation( + ['Vous avez sélectionné ', ' pour la livraison.'], + ['PH'], + 'Vous avez sélectionné {$PH} pour la livraison.', + ), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n', + ); + }); + + it('reports an error diagnostic when a $localize template has a malformed escape sequence', async () => { + const source = 'export const msg = $localize`:@@id:\\unicode:`;\n'; + const { errors } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + {}, + ); + + expect(errors).toEqual([ + 'Malformed escape sequence in $localize template literal in file "main.js".', + ]); + }); + + it('inlines the translations of a locale when translationIntegrity is provided', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of a locale when localizeVersion is configured in options', async () => { + inliner = new I18nInliner({ + missingTranslation: 'warning', + localizeVersion: '20.2.0', + maxConcurrency: 1, + }); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines multiple locales in parallel via inlineAll', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'es', translation: { greeting: translationFor('Hola') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(4); + + const fr = results.get('fr'); + expect(fr).toBeDefined(); + expect(fr?.errors).toEqual([]); + expect(fr?.warnings).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'chunk.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de).toBeDefined(); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es).toBeDefined(); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + const en = results.get('en-US'); + expect(en).toBeDefined(); + expect(en?.errors).toEqual([]); + expect(findFile(en?.outputFiles ?? [], 'main.js').text).toContain('"Hello"'); + }); + + it('inlines multiple locales with sourcemaps in parallel via inlineAll', async () => { + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const files = [ + browserFile('main.js', code), + browserFile('main.js.map', map), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(3); + + for (const [locale, greeting] of [ + ['fr', 'Bonjour'], + ['de', 'Hallo'], + ['en-US', 'Hello'], + ] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toContain(`"${greeting}"`); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + const outputMap = JSON.parse(mainMap.text) as { + version: number; + sources: string[]; + mappings: string; + }; + expect(outputMap.version).toBe(3); + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + + const otherJs = findFile(localeResult?.outputFiles ?? [], 'other.js'); + expect(otherJs.text).toBe('export const answer = 42;\n'); + } + }); + + it('inlines multiple locales with partial cache hits and misses via inlineAll', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-cache-test-')); + + try { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const initialInliner = new I18nInliner({ + missingTranslation: 'warning', + persistentCachePath: cacheDir, + maxConcurrency: 2, + }); + + // Pre-populate cache for 'fr' + await initialInliner.inlineForLocale( + files, + 'fr', + { greeting: translationFor('Bonjour') }, + 'integrity-fr-1', + ); + await initialInliner.close(); + + // Create new inliner with same cache path, inlining cached 'fr' alongside uncached 'de' and 'es' + inliner = new I18nInliner({ + missingTranslation: 'warning', + persistentCachePath: cacheDir, + maxConcurrency: 2, + }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'integrity-fr-1', + }, + { + locale: 'de', + translation: { greeting: translationFor('Hallo') }, + translationIntegrity: 'integrity-de-1', + }, + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + translationIntegrity: 'integrity-es-1', + }, + ]); + + expect(results.size).toBe(3); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + // Verify deterministic file order matching input order + for (const localeResult of results.values()) { + expect(localeResult.outputFiles.map((f) => f.path)).toEqual(['main.js', 'other.js']); + } + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('inlines across sliding windows when locale count exceeds window size', async () => { + const locales = Array.from({ length: 20 }, (_, i) => ({ + locale: `locale-${i}`, + translation: { greeting: translationFor(`Hello ${i}`) }, + })); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 2, + }); + + const results = await inliner.inlineAll(files, locales); + + expect(results.size).toBe(20); + for (let i = 0; i < 20; i++) { + const localeResult = results.get(`locale-${i}`); + expect(localeResult?.errors).toEqual([]); + expect(findFile(localeResult?.outputFiles ?? [], 'main.js').text).toContain(`"Hello ${i}"`); + } + }); + + it('injects locale data alongside ___NG_LOCALE_INSERT___ for non-English locales', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'en-US', translation: undefined }, + ]); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + const frPolyfills = findFile(fr?.outputFiles ?? [], 'polyfills.js').text; + expect(frPolyfills).toContain('(globalThis.$localize ??= {}).locale = "fr";'); + expect(frPolyfills).toContain('ng.common.locales'); + + const en = results.get('en-US'); + expect(en?.errors).toEqual([]); + const enPolyfills = findFile(en?.outputFiles ?? [], 'polyfills.js').text; + expect(enPolyfills).toContain('(globalThis.$localize ??= {}).locale = "en-US";'); + expect(enPolyfills).not.toContain('ng.common.locales'); + }); + + it('warns and uses parent locale data when a subtag locale is not directly available', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr-ZZ', translation: { greeting: translationFor('Bonjour') } }, + ]); + + const frZZ = results.get('fr-ZZ'); + expect(frZZ?.errors).toEqual([]); + expect(frZZ?.warnings).toEqual([ + "Locale data for 'fr-ZZ' cannot be found. Using locale data for 'fr'.", + ]); + const polyfills = findFile(frZZ?.outputFiles ?? [], 'polyfills.js').text; + expect(polyfills).toContain('(globalThis.$localize ??= {}).locale = "fr-ZZ";'); + expect(polyfills).toContain('ng.common.locales'); + }); + + it('warns and includes no locale data when locale data cannot be found', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'xx-YY', translation: { greeting: translationFor('Test') } }, + ]); + + const xxYY = results.get('xx-YY'); + expect(xxYY?.errors).toEqual([]); + expect(xxYY?.warnings).toEqual([ + "Locale data for 'xx-YY' cannot be found. No locale data will be included for this locale.", + ]); + const polyfills = findFile(xxYY?.outputFiles ?? [], 'polyfills.js').text; + expect(polyfills).toContain('(globalThis.$localize ??= {}).locale = "xx-YY";'); + expect(polyfills).not.toContain('ng.common.locales'); + }); + + it('reports an error diagnostic when an invalid or unsupported locale is provided', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'invalid locale tag', translation: { greeting: translationFor('Test') } }, + ]); + + const invalid = results.get('invalid locale tag'); + expect(invalid?.errors).toEqual([ + 'Invalid or unsupported locale provided in configuration: "invalid locale tag"', + ]); + }); + + it('injects locale data script only once when multiple ___NG_LOCALE_INSERT___ sites are present', async () => { + const source = + `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n` + + `const secondary = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + const frPolyfills = findFile(fr?.outputFiles ?? [], 'polyfills.js').text; + expect(frPolyfills).toContain('(globalThis.$localize ??= {}).locale = "fr";'); + expect(frPolyfills).toContain('const secondary = "fr";'); + + const iifeMatches = frPolyfills.match(/function\s*\(global/g); + expect(iifeMatches?.length).toBe(1); + }); + + it('preserves code and sourcemaps when a file contains the $localize keyword only in comments', async () => { + const source = '// $localize keyword in comment\nexport const message = "hello world";\n'; + const { code, map } = await transform(source, { + sourcefile: 'comment-only.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const files = [browserFile('main.js', code), browserFile('main.js.map', map)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + + expect(results.size).toBe(2); + + for (const locale of ['fr', 'de'] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toBe(code); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + expect(mainMap.text).toBe(map); + } + }); + + it('correctly handles persistent caching for unmodified files', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-unmodified-cache-test-')); + const source = '// $localize comment only\nexport const value = 123;\n'; + + try { + const files = [browserFile('main.js', source)]; + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results1 = await inliner1.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'fr-1', + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source); + await inliner1.close(); + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'fr-1', + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('caches binary translation buffer in persistent cache store when translationIntegrity is provided', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-inliner-trans-cache-test-')); + + try { + const files = [browserFile('main.js', GREETING_SOURCE)]; + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-test-integrity-12345'; + const results1 = await inliner1.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner1.close(); + + // Verify directly that the binary translation buffer was persisted on disk + const store = await createPersistentCacheStore(path.join(cacheDir, 'angular-i18n')); + try { + const translationCache = store.createCache('translations'); + const cachedData = await translationCache.get(translationIntegrity); + expect(cachedData).toBeInstanceOf(Uint8Array); + expect(cachedData?.byteLength).toBeGreaterThan(0); + } finally { + await store.close(); + } + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('throws an error when duplicate locales are provided to inlineAll', async () => { + const localeInliner = createInliner(); + + await expectAsync( + localeInliner.inlineAll( + [browserFile('main.js', GREETING_SOURCE)], + [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + ], + ), + ).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/); + }); + + it('correctly transforms files across multiple inlineAll runs on the same inliner instance', async () => { + const localeInliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 2, + }); + + try { + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + // First generation + const results1 = await localeInliner.inlineAll(files1, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second generation (e.g. watch mode rebuild with updated translation) + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await localeInliner.inlineAll(files2, [ + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Salut"'); + expect(findFile(results2.get('de')?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + } finally { + await localeInliner.close(); + } + }); + + it('correctly escapes backticks, double quotes, and expression delimiters in translated template literals', async () => { + const source = 'export const msg = $localize`:@@msg:Hello ${name}:name:!`;\n'; + const inliner = createInliner(); + + const results = await inliner.inlineAll( + [browserFile('main.js', source)], + [ + { + locale: 'fr', + translation: { + msg: parsedTranslation(['Bonjour "', '` with ${injected} and \\backslash!'], ['name']), + }, + }, + ], + ); + + const outputText = findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text; + expect(outputText).toContain('`Bonjour "${name}\\` with \\${injected} and \\\\backslash!`'); + }); + + it('correctly inlines when files have varying sizes across multiple workers', async () => { + // Create a large dominant bundle (> 120 KB) and a small chunk (< 10 KB) + const largePadding = '/* padding */ console.log(1);\n'.repeat(4000); + const largeSource = `${GREETING_SOURCE}\n${largePadding}`; + const smallSource = 'console.log($localize`:@@farewell:Goodbye`);'; + + const largeFile = browserFile('main.js', largeSource); + const smallFile = browserFile('chunk.js', smallSource); + const files = [largeFile, smallFile]; + + inliner = new I18nInliner({ missingTranslation: 'error', maxConcurrency: 2 }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + }, + { + locale: 'de', + translation: { + greeting: translationFor('Guten Tag'), + farewell: translationFor('Auf Wiedersehen'), + }, + }, + { + locale: 'es', + translation: { + greeting: translationFor('Hola'), + farewell: translationFor('Adios'), + }, + }, + ]); + + expect(results.size).toBe(3); + + const frFiles = results.get('fr')?.outputFiles ?? []; + expect(findFile(frFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(frFiles, 'chunk.js').text).toContain('"Au revoir"'); + + const deFiles = results.get('de')?.outputFiles ?? []; + expect(findFile(deFiles, 'main.js').text).toContain('"Guten Tag"'); + expect(findFile(deFiles, 'chunk.js').text).toContain('"Auf Wiedersehen"'); + + const esFiles = results.get('es')?.outputFiles ?? []; + expect(findFile(esFiles, 'main.js').text).toContain('"Hola"'); + expect(findFile(esFiles, 'chunk.js').text).toContain('"Adios"'); + }); + + it('respects maxConcurrency when processing multiple locales and files', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', 'console.log($localize`:@@farewell:Goodbye`);'), + ]; + + inliner = new I18nInliner({ + missingTranslation: 'error', + maxConcurrency: 1, + }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + }, + { + locale: 'de', + translation: { + greeting: translationFor('Guten Tag'), + farewell: translationFor('Auf Wiedersehen'), + }, + }, + ]); + + expect(results.size).toBe(2); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(results.get('de')?.outputFiles ?? [], 'chunk.js').text).toContain( + '"Auf Wiedersehen"', + ); + }); + + describe('maxConcurrency validation', () => { + it('throws when maxConcurrency is less than 1', () => { + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 0, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: -1, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('throws when maxConcurrency is not an integer', () => { + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 1.5, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: NaN, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: Infinity, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('defaults to worker pool concurrency when maxConcurrency is omitted', async () => { + const maxThreadsSpy = spyOnProperty( + WorkerPool.prototype, + 'maxThreads', + 'get', + ).and.callThrough(); + + inliner = new I18nInliner({ + missingTranslation: 'warning', + }); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(maxThreadsSpy).toHaveBeenCalled(); + expect(maxThreadsSpy.calls.mostRecent().returnValue).toBeGreaterThanOrEqual(1); + }); + }); + + describe('supplied workerPool', () => { + let externalPool: WorkerPool | undefined; + + afterEach(async () => { + await externalPool?.destroy(); + externalPool = undefined; + }); + + it('uses the supplied workerPool to execute transformation tasks', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const runSpy = spyOn(externalPool, 'run').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(runSpy).toHaveBeenCalled(); + }); + + it('does not destroy the supplied workerPool on close()', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const destroySpy = spyOn(externalPool, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + await inliner.close(); + + expect(destroySpy).not.toHaveBeenCalled(); + }); + + it('destroys internally created workerPool on close()', async () => { + const destroySpy = spyOn(WorkerPool.prototype, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency: 1 }); + await inliner.close(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('supports multiple sequential inlining passes with the same supplied workerPool', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + const results1 = await inliner.inlineAll(files1, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second inlining pass on the same pool + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await inliner.inlineAll(files2, [ + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + }, + ]); + expect(findFile(results2.get('es')?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts new file mode 100644 index 000000000000..cc8f1ae50f31 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; + +/** + * Magic header identifier for i18n SharedArrayBuffer translation tables ('I18N'). + */ +export const I18N_MAGIC_ID = 0x4931384e; + +/** + * Encodes a JavaScript translation dictionary into a contiguous SharedArrayBuffer. + * The buffer contains a header, a sorted index table (by key for fast O(log N) binary search), + * and a UTF-8 string pool. + * + * @param translation The translation dictionary object. + * @returns A SharedArrayBuffer containing the binary encoded translation catalog. + */ +export function encodeTranslationToBuffer( + translation: Record, +): SharedArrayBuffer { + const encoder = new TextEncoder(); + const entries = Object.entries(translation); + const entryCount = entries.length; + + // Pre-encode string keys and JSON-serialized string values + const encodedEntries: { + keyBytes: Uint8Array; + valBytes: Uint8Array; + }[] = new Array(entryCount); + + let stringPoolByteSize = 0; + + for (let i = 0; i < entryCount; i++) { + const [key, val] = entries[i]; + const keyBytes = encoder.encode(key); + const valBytes = encoder.encode(JSON.stringify(val)); + + encodedEntries[i] = { keyBytes, valBytes }; + stringPoolByteSize += keyBytes.byteLength + valBytes.byteLength; + } + + // Sort entries lexicographically by UTF-8 byte comparison. + // Lexicographical byte comparison matches the binary search reader's byte comparison, + // ensuring deterministic and zero-allocation lookups across all runtimes regardless of UTF-16 surrogate pairs. + encodedEntries.sort((a, b) => { + const aBytes = a.keyBytes; + const bBytes = b.keyBytes; + const minLen = Math.min(aBytes.length, bBytes.length); + + for (let i = 0; i < minLen; i++) { + const diff = aBytes[i] - bBytes[i]; + if (diff !== 0) { + return diff; + } + } + + return aBytes.length - bBytes.length; + }); + + const indexByteSize = entryCount * 16; + const headerByteSize = 16; + const stringPoolOffset = headerByteSize + indexByteSize; + + const totalByteSize = stringPoolOffset + stringPoolByteSize; + const buffer = new SharedArrayBuffer(totalByteSize); + + // Set up views over the SharedArrayBuffer + const uint32Header = new Uint32Array(buffer, 0, 4); + const uint32Index = new Uint32Array(buffer, headerByteSize, entryCount * 4); + const uint8Pool = new Uint8Array(buffer, stringPoolOffset); + + // Write Header: Magic, EntryCount, IndexOffset, StringPoolOffset + uint32Header[0] = I18N_MAGIC_ID; + uint32Header[1] = entryCount; + uint32Header[2] = headerByteSize; + uint32Header[3] = stringPoolOffset; + + // Write Index Table and String Pool entries + let currentPoolOffset = 0; + + for (let i = 0; i < entryCount; i++) { + const { keyBytes, valBytes } = encodedEntries[i]; + + const keyOffset = currentPoolOffset; + const keyLen = keyBytes.byteLength; + uint8Pool.set(keyBytes, keyOffset); + currentPoolOffset += keyLen; + + const valOffset = currentPoolOffset; + const valLen = valBytes.byteLength; + uint8Pool.set(valBytes, valOffset); + currentPoolOffset += valLen; + + // Index entry format (4 uint32 words / 16 bytes): + // [0]: keyOffset (uint32) + // [1]: keyLength (uint32) + // [2]: valOffset (uint32) + // [3]: valLength (uint32) + const idx = i * 4; + uint32Index[idx] = keyOffset; + uint32Index[idx + 1] = keyLen; + uint32Index[idx + 2] = valOffset; + uint32Index[idx + 3] = valLen; + } + + return buffer; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts new file mode 100644 index 000000000000..dd660f9a4059 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { encodeTranslationToBuffer } from './i18n-translation-encoder'; +import { + SharedTranslationDictionary, + createSharedTranslationProxy, +} from './i18n-translation-reader'; + +describe('SharedArrayBuffer Translation Encoder & Reader', () => { + it('encodes and reads simple key-value translations', () => { + const translation = { + greeting: 'Hello', + farewell: 'Goodbye', + numberKey: 42, + arrayKey: ['part1', 'part2'], + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('greeting')).toEqual('Hello'); + expect(dictionary.get('farewell')).toEqual('Goodbye'); + expect(dictionary.get('numberKey')).toEqual(42); + expect(dictionary.get('arrayKey')).toEqual(['part1', 'part2']); + expect(dictionary.get('nonexistent')).toBeUndefined(); + }); + + it('works with Proxy wrapper and "in" operator', () => { + const translation = { + msg1: 'Message 1', + msg2: 'Message 2', + }; + + const buffer = encodeTranslationToBuffer(translation); + const proxy = createSharedTranslationProxy(buffer); + + expect(proxy['msg1']).toEqual('Message 1'); + expect(proxy['msg2']).toEqual('Message 2'); + expect(proxy['unknown']).toBeUndefined(); + expect('msg1' in proxy).toBeTrue(); + expect('msg2' in proxy).toBeTrue(); + expect('unknown' in proxy).toBeFalse(); + }); + + it('supports Object.prototype.hasOwnProperty and Object.getOwnPropertyDescriptor on Proxy', () => { + const translation = { + msg1: 'Message 1', + }; + + const buffer = encodeTranslationToBuffer(translation); + const proxy = createSharedTranslationProxy(buffer); + + expect(Object.prototype.hasOwnProperty.call(proxy, 'msg1')).toBeTrue(); + expect(Object.prototype.hasOwnProperty.call(proxy, 'unknown')).toBeFalse(); + + const desc = Object.getOwnPropertyDescriptor(proxy, 'msg1'); + expect(desc).toBeDefined(); + expect(desc?.value).toEqual('Message 1'); + expect(desc?.enumerable).toBeTrue(); + + const missingDesc = Object.getOwnPropertyDescriptor(proxy, 'unknown'); + expect(missingDesc).toBeUndefined(); + + // Verify standard Object.prototype methods work and don't throw + expect(typeof proxy.toString).toBe('function'); + expect(proxy.toString()).toEqual('[object Object]'); + expect(typeof proxy.valueOf).toBe('function'); + expect('toString' in proxy).toBeTrue(); + expect(String(proxy)).toEqual('[object Object]'); + }); + + it('handles non-BMP Unicode surrogate pairs and high BMP keys correctly', () => { + const translation = { + '😀': 'emoji message', + '\uE000': 'uE000 message', + 'abc': 'abc message', + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('😀')).toEqual('emoji message'); + expect(dictionary.get('\uE000')).toEqual('uE000 message'); + expect(dictionary.get('abc')).toEqual('abc message'); + }); + + it('handles empty translation dictionaries', () => { + const buffer = encodeTranslationToBuffer({}); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('anyKey')).toBeUndefined(); + }); + + it('caches missing translation lookups efficiently', () => { + const translation = { + msg1: 'Message 1', + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + // First lookup for missing key + expect(dictionary.get('missingKey')).toBeUndefined(); + // Second lookup for missing key (should hit lazyCache NOT_FOUND sentinel) + expect(dictionary.get('missingKey')).toBeUndefined(); + }); + + it('validates header size and offsets on initialization', () => { + // Buffer too small + const smallSab = new SharedArrayBuffer(8); + expect(() => new SharedTranslationDictionary(smallSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header: buffer too small.', + ); + + // Invalid magic header + const badMagicSab = new SharedArrayBuffer(16); + expect(() => new SharedTranslationDictionary(badMagicSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header magic identifier.', + ); + + // Out-of-bounds offset values + const invalidOffsetsSab = new SharedArrayBuffer(16); + const view = new Uint32Array(invalidOffsetsSab); + view[0] = 0x4931384e; // I18N_MAGIC_ID + view[1] = 100; // entryCount = 100 entries requiring 1600 bytes + view[2] = 16; + view[3] = 16; + expect(() => new SharedTranslationDictionary(invalidOffsetsSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header offsets.', + ); + + // Unaligned header size + const unalignedHeaderSab = new SharedArrayBuffer(32); + const unalignedView = new Uint32Array(unalignedHeaderSab); + unalignedView[0] = 0x4931384e; + unalignedView[1] = 0; + unalignedView[2] = 17; // unaligned header byte size + unalignedView[3] = 17; + expect(() => new SharedTranslationDictionary(unalignedHeaderSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header offsets.', + ); + }); + + it('safely handles corrupted index offsets within a valid header', () => { + // 1 entry: header (16 bytes) + index (16 bytes) + pool (10 bytes) = 42 bytes + const buffer = new SharedArrayBuffer(48); + const uint32 = new Uint32Array(buffer); + uint32[0] = 0x4931384e; // Magic + uint32[1] = 1; // 1 entry + uint32[2] = 16; // header size + uint32[3] = 32; // string pool offset + + // Corrupted entry: keyOffset points past pool length + uint32[4] = 100; // keyOffset = 100 + uint32[5] = 5; // keyLen = 5 + uint32[6] = 0; // valOffset = 0 + uint32[7] = 5; // valLen = 5 + + const dictionary = new SharedTranslationDictionary(buffer); + expect(dictionary.get('testKey')).toBeUndefined(); + }); +}); diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts new file mode 100644 index 000000000000..c332b47f1941 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import { I18N_MAGIC_ID } from './i18n-translation-encoder'; + +/** + * Sentinel value used to cache non-existent message key lookups in lazyCache. + */ +const NOT_FOUND = Symbol('NOT_FOUND'); + +/** + * Compares a target key's UTF-8 byte array against a byte sequence in the string pool. + * Lexicographical byte comparison of UTF-8 sequences matches UTF-16 code-unit string comparison + * used when encoding the sorted index table. + */ +function compareBytes( + target: Uint8Array, + pool: Uint8Array, + poolOffset: number, + keyLen: number, +): number { + if (poolOffset < 0 || poolOffset + keyLen > pool.length) { + return 1; + } + + const minLen = Math.min(target.length, keyLen); + for (let i = 0; i < minLen; i++) { + const diff = target[i] - pool[poolOffset + i]; + if (diff !== 0) { + return diff; + } + } + + return target.length - keyLen; +} + +/** + * A zero-copy reader that queries translation messages directly from a SharedArrayBuffer + * using binary search over a sorted key index. + */ +export class SharedTranslationDictionary { + private readonly entryCount: number; + private readonly uint32Index: Uint32Array; + private readonly uint8Pool: Uint8Array; + private readonly decoder = new TextDecoder(); + private readonly encoder = new TextEncoder(); + private readonly lazyCache = new Map(); + + constructor(buffer: SharedArrayBuffer) { + if (buffer.byteLength < 16) { + throw new Error('Invalid SharedArrayBuffer translation header: buffer too small.'); + } + + const uint32Header = new Uint32Array(buffer, 0, 4); + if (uint32Header[0] !== I18N_MAGIC_ID) { + throw new Error('Invalid SharedArrayBuffer translation header magic identifier.'); + } + + this.entryCount = uint32Header[1]; + const headerByteSize = uint32Header[2]; + const stringPoolOffset = uint32Header[3]; + + if ( + headerByteSize < 16 || + headerByteSize % 4 !== 0 || + headerByteSize + this.entryCount * 16 > buffer.byteLength || + stringPoolOffset < headerByteSize + this.entryCount * 16 || + stringPoolOffset > buffer.byteLength + ) { + throw new Error('Invalid SharedArrayBuffer translation header offsets.'); + } + + this.uint32Index = new Uint32Array(buffer, headerByteSize, this.entryCount * 4); + this.uint8Pool = new Uint8Array(buffer, stringPoolOffset); + } + + /** + * Looks up a translation message by key. + * Performs a binary search over the index table if not previously cached in the lazy cache. + * + * @param targetKey The message key ID to search for. + * @returns The parsed translation message, or undefined if not found. + */ + get(targetKey: string): T | undefined { + const cached = this.lazyCache.get(targetKey); + if (cached !== undefined) { + return cached === NOT_FOUND ? undefined : cached; + } + + const targetBytes = this.encoder.encode(targetKey); + let low = 0; + let high = this.entryCount - 1; + + while (low <= high) { + const mid = (low + high) >>> 1; + const idx = mid * 4; + + const keyOffset = this.uint32Index[idx]; + const keyLen = this.uint32Index[idx + 1]; + + const cmp = compareBytes(targetBytes, this.uint8Pool, keyOffset, keyLen); + if (cmp === 0) { + const valOffset = this.uint32Index[idx + 2]; + const valLen = this.uint32Index[idx + 3]; + if (valOffset < 0 || valOffset + valLen > this.uint8Pool.length) { + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } + + const valBytes = this.uint8Pool.subarray(valOffset, valOffset + valLen); + const valJson = this.decoder.decode(valBytes); + + try { + const val = JSON.parse(valJson) as T; + this.lazyCache.set(targetKey, val); + + return val; + } catch { + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } + } else if (cmp < 0) { + high = mid - 1; + } else { + low = mid + 1; + } + } + + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } +} + +/** + * Creates a JavaScript object Proxy wrapping a SharedTranslationDictionary so it can be passed + * directly to `@angular/localize` inliner functions as a standard translation Record. + * + * Traps `get`, `has`, and `getOwnPropertyDescriptor` to fulfill all `@angular/localize` `translate()` + * lookup requirements (`translations[id]`, `translations[legacyId]`, `Object.hasOwn(translations, id)`). + * The `ownKeys` trap is intentionally omitted so keys are not eagerly decoded upfront upon reflection. + * + * @param buffer The SharedArrayBuffer containing binary encoded translation catalog. + * @returns A Proxy object that intercepts property reads and queries the SharedTranslationDictionary. + */ +export function createSharedTranslationProxy( + buffer: SharedArrayBuffer, +): Record { + const dictionary = new SharedTranslationDictionary(buffer); + + return new Proxy( + {}, + { + get(target, prop: string | symbol) { + if (typeof prop === 'string') { + const value = dictionary.get(prop); + if (value !== undefined) { + return value; + } + } + + return Reflect.get(target, prop); + }, + has(target, prop: string | symbol) { + if (typeof prop === 'string' && dictionary.get(prop) !== undefined) { + return true; + } + + return Reflect.has(target, prop); + }, + getOwnPropertyDescriptor(target, prop: string | symbol) { + if (typeof prop === 'string') { + const val = dictionary.get(prop); + if (val !== undefined) { + return { + enumerable: true, + configurable: true, + writable: false, + value: val, + }; + } + } + + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + }, + ); +} diff --git a/packages/angular/build/src/tools/i18n/index.ts b/packages/angular/build/src/tools/i18n/index.ts new file mode 100644 index 000000000000..a2f8cdd2d8f3 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from './i18n-inliner'; +export * from './i18n-translation-encoder'; +export * from './i18n-translation-reader'; +export * from './locale-data'; diff --git a/packages/angular/build/src/tools/i18n/locale-data.ts b/packages/angular/build/src/tools/i18n/locale-data.ts new file mode 100644 index 000000000000..0418830b69a8 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/locale-data.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { createProjectResolver } from '../../utils/resolve-project'; + +/** + * The internal namespace used by generated locale import statements and Angular locale data plugin. + */ +export const LOCALE_DATA_NAMESPACE = 'angular:locale/data'; + +/** + * The base module location used to search for locale specific data. + */ +export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +/** + * Result of resolving locale data for a given locale tag. + */ +export interface LocaleDataResolution { + path?: string; + warning?: string; + error?: string; +} + +/** + * Result of loading locale data for a given locale tag. + */ +export interface LoadedLocaleData { + code?: string; + warning?: string; + error?: string; +} + +const localeDataCache = new Map>(); + +/** + * Resolves the path to the Angular locale data file for a given locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectResolve A function that attempts to resolve a path string to an absolute file path. + * @returns Resolution result with file path, or warning/error diagnostics if applicable. + */ +export function resolveLocaleDataPath( + rawLocaleTag: string, + projectResolve: (potentialPath: string) => string | undefined, +): LocaleDataResolution { + let partialLocaleTag: string; + try { + const locale = new Intl.Locale(rawLocaleTag); + partialLocaleTag = locale.baseName; + } catch { + return { + error: `Invalid or unsupported locale provided in configuration: "${rawLocaleTag}"`, + }; + } + + let exact = true; + while (partialLocaleTag) { + // Angular embeds the `en`/`en-US` locale into the framework and it does not need to be included again here. + if (partialLocaleTag === 'en' || partialLocaleTag === 'en-US') { + return {}; + } + + const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocaleTag}`; + try { + const resolvedPath = projectResolve(potentialPath); + if (resolvedPath) { + return { + path: resolvedPath, + warning: exact + ? undefined + : `Locale data for '${rawLocaleTag}' cannot be found. Using locale data for '${partialLocaleTag}'.`, + }; + } + } catch {} + + // Remove the last subtag and try again with a less specific locale. + const parts = partialLocaleTag.split('-'); + partialLocaleTag = parts.slice(0, -1).join('-'); + exact = false; + } + + return { + warning: `Locale data for '${rawLocaleTag}' cannot be found. No locale data will be included for this locale.`, + }; +} + +/** + * Loads the Angular global locale data script for a specified locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectRoot Optional project root for module resolution. + * @returns A promise resolving to the loaded locale data script code and any diagnostic warnings. + */ +export function loadLocaleData( + rawLocaleTag: string, + projectRoot?: string, +): Promise { + let cached = localeDataCache.get(rawLocaleTag); + if (!cached) { + cached = (async () => { + const projectResolve = createProjectResolver(projectRoot ?? process.cwd()); + const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { + try { + return projectResolve(potentialPath); + } catch { + return undefined; + } + }); + + if (resolution.error) { + return { error: resolution.error }; + } + + if (resolution.path) { + try { + const code = await readFile(resolution.path, 'utf8'); + + return { code, warning: resolution.warning }; + } catch (e) { + return { error: `Failed to read locale data file: ${(e as Error).message}` }; + } + } + + return { warning: resolution.warning }; + })(); + + localeDataCache.set(rawLocaleTag, cached); + } + + return cached; +} diff --git a/packages/angular/build/src/tools/javascript-transformer/index.ts b/packages/angular/build/src/tools/javascript-transformer/index.ts new file mode 100644 index 000000000000..399eea87e644 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from './javascript-transformer'; diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts new file mode 100644 index 000000000000..87df19ee81eb --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { DecodedSourceMap, EncodedSourceMap } from '@ampproject/remapping'; +import type { PluginItem } from '@babel/core'; +import { createRequire } from 'node:module'; +import { workerData } from 'node:worker_threads'; +import Piscina from 'piscina'; +import { useBabelLinker } from '../../utils/environment-options.js'; +import { + findTrailingSourceMapComment, + isTrailingSourceMapComment, + loadInputSourceMap, + loadInputSourceMapFromUrl, + removeSourceMappingURL, +} from '../../utils/source-map'; +import { transform as transformWithOxc } from '../oxc/oxc-transform.js'; +import type { JavaScriptTransformerOptions } from './javascript-transformer'; + +interface JavaScriptTransformRequest { + filename: string; + data: string | Uint8Array; + skipLinker?: boolean; + sideEffects?: boolean; + instrumentForCoverage?: boolean; +} + +interface TransformOptions extends Omit { + inputSourceMap?: EncodedSourceMap; + isAlreadyStripped?: boolean; +} + +const { + sourcemap = false, + thirdPartySourcemaps = false, + advancedOptimizations = false, + jit = false, +} = (workerData || {}) as Partial; + +let babelLinkerDeps: + | Promise< + [ + typeof import('@angular/compiler-cli/linker/babel'), + typeof import('@angular/compiler-cli'), + typeof import('@babel/core'), + ] + > + | undefined; + +let remapping: typeof import('@ampproject/remapping').default | undefined; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +async function instrumentCoverage( + filename: string, + data: string, + useInputSourcemap: boolean, +): Promise<{ code: string; map?: EncodedSourceMap }> { + try { + let resolvedPath = 'istanbul-lib-instrument'; + try { + const requireFn = createRequire(filename); + resolvedPath = requireFn.resolve('istanbul-lib-instrument'); + } catch { + // Fallback to pool worker import traversal + } + + const { createInstrumenter } = (await import( + resolvedPath + )) as typeof import('istanbul-lib-instrument'); + const instrumenter = createInstrumenter({ + produceSourceMap: useInputSourcemap, + esModules: true, + }); + + const inputSourceMap = useInputSourcemap ? loadInputSourceMap(filename, data) : undefined; + const instrumentedCode = instrumenter.instrumentSync( + data, + filename, + inputSourceMap as Parameters[2], + ); + const lastMap = useInputSourcemap + ? (instrumenter.lastSourceMap() as EncodedSourceMap) + : undefined; + + return { + code: instrumentedCode, + map: lastMap ?? undefined, + }; + } catch (error) { + throw new Error( + `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`, + { cause: error }, + ); + } +} + +export default async function transformJavaScript( + request: JavaScriptTransformRequest, +): Promise { + const { filename, data, ...options } = request; + + const useInputSourcemap = + sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + let textData: string; + let inputSourceMap: EncodedSourceMap | undefined; + let isAlreadyStripped = false; + + if (typeof data !== 'string') { + const trailing = findTrailingSourceMapComment(data); + if (trailing === null) { + // 0 comments: fast path, no sourcemap to load or strip + textData = textDecoder.decode(data); + isAlreadyStripped = true; + } else if (trailing !== undefined) { + if (useInputSourcemap) { + inputSourceMap = loadInputSourceMapFromUrl(filename, trailing.urlLine); + if (inputSourceMap !== undefined) { + // Valid trailing sourcemap comment confirmed: safe to slice code buffer for transformation passes. + // Note: If no passes modify the code, the untouched original `data` buffer is returned below. + textData = textDecoder.decode(trailing.code); + isAlreadyStripped = true; + } else { + // Not a valid trailing sourcemap (e.g. inside template literal): fallback to full decode + textData = textDecoder.decode(data); + } + } else if (isTrailingSourceMapComment(trailing.urlLine)) { + // Valid trailing sourcemap comment confirmed: safe to slice code buffer + textData = textDecoder.decode(trailing.code); + isAlreadyStripped = true; + } else { + // Fallback to full decode and state-machine stripping + textData = textDecoder.decode(data); + } + } else { + // Multiple comments or comment not at line start: fall back to full decode and string parser + textData = textDecoder.decode(data); + } + } else { + textData = data; + } + + const transformedData = await transformJavaScriptImpl(filename, textData, { + ...options, + inputSourceMap, + isAlreadyStripped, + }); + + // If no transformations modified the code, return the original untouched data buffer via `move`. + // This preserves any original trailing sourcemap comment and avoids re-encoding. + if (transformedData === textData && typeof data !== 'string') { + return Piscina.move(data); + } + + return Piscina.move(textEncoder.encode(transformedData)); +} + +async function transformJavaScriptImpl( + filename: string, + data: string, + options: TransformOptions, +): Promise { + const shouldLink = !options.skipLinker; + const useInputSourcemap = + sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + let code = data; + const maps: (DecodedSourceMap | EncodedSourceMap)[] = []; + let coverageMap: EncodedSourceMap | undefined; + + if (options.instrumentForCoverage) { + const result = await instrumentCoverage(filename, code, useInputSourcemap); + code = result.code; + coverageMap = result.map; + } + + if (shouldLink && useBabelLinker) { + babelLinkerDeps ??= Promise.all([ + import('@angular/compiler-cli/linker/babel'), + import('@angular/compiler-cli'), + import('@babel/core'), + ]); + const [{ createEs2015LinkerPlugin }, { ConsoleLogger, LogLevel }, { transformAsync }] = + await babelLinkerDeps; + + const result = await transformAsync(code, { + filename, + inputSourceMap: false, + sourceMaps: !!useInputSourcemap, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [ + createEs2015LinkerPlugin({ + fileSystem: { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, + } as never, + logger: new ConsoleLogger(LogLevel.info), + linkerJitMode: jit, + // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. + sourceMapping: false, + }) as PluginItem, + ], + }); + + code = result?.code ?? code; + if (result?.map) { + maps.push(result.map as EncodedSourceMap); + } + } + + // Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass + const oxcLink = shouldLink && !useBabelLinker; + if (oxcLink || advancedOptimizations) { + const sideEffectFree = options.sideEffects === false; + const safeAngularPackage = + sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); + const topLevelSafeMode = !safeAngularPackage; + + const result = transformWithOxc(filename, code, { + link: oxcLink, + jit, + advancedOptimizations, + sourcemap: useInputSourcemap, + sideEffects: options.sideEffects, + topLevelSafeMode, + }); + code = result.code; + if (result.map) { + maps.push(result.map); + } + } + + if (useInputSourcemap) { + const baseMap = coverageMap ?? options.inputSourceMap ?? loadInputSourceMap(filename, data); + if (maps.length > 0 || coverageMap) { + if (!options.isAlreadyStripped) { + code = removeSourceMappingURL(code); + } + const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse(); + if (baseMap) { + remappingChain.push(baseMap); + } + + if (remappingChain.length > 0) { + remapping ??= (await import('@ampproject/remapping')).default; + const finalMap = remapping(remappingChain, () => null).toString(); + const base64Map = Buffer.from(finalMap).toString('base64'); + code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + } + } + + return code; + } + + // Strip sourcemaps if they should not be used + return options.isAlreadyStripped ? code : removeSourceMappingURL(code); +} diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts new file mode 100644 index 000000000000..56a7b4eb6ed9 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { Cache } from '../../utils/cache'; +import { createContentHash } from '../../utils/hash'; +import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; +import { removeSourceMappingURL } from '../../utils/source-map'; +import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; + +const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare'; +const LINKER_DECLARATION_PREFIX_BYTES = Buffer.from(LINKER_DECLARATION_PREFIX, 'utf-8'); + +const ADVANCED_OPTIMIZATION_TOKENS = [ + 'ɵ', + 'InjectionToken', + 'INJECTOR_KEY', + 'ctorParameters', + 'decorators', + 'propDecorators', +] as const; + +const ADVANCED_OPTIMIZATION_TOKEN_BYTES = ADVANCED_OPTIMIZATION_TOKENS.map((token) => + Buffer.from(token, 'utf-8'), +); + +const DECORATOR_TOKENS = ['__decorate', '__esDecorate'] as const; +const DECORATOR_TOKEN_BYTES = DECORATOR_TOKENS.map((token) => Buffer.from(token, 'utf-8')); + +const ADVANCED_OPTIMIZATION_REGEX = new RegExp(ADVANCED_OPTIMIZATION_TOKENS.join('|')); +const DECORATOR_REGEX = new RegExp(DECORATOR_TOKENS.join('|')); + +/** + * Determines whether JavaScript code contains potential candidate constructs for advanced optimizations. + * When false, advanced optimizations can be bypassed without worker dispatch or AST parsing. + * + * @param filename The full path to the file. + * @param data The data (string or Buffer) of the file. + * @param sideEffects An optional lazy resolver callback that returns whether the file is considered side-effect free. + * @returns True if the code may contain constructs that advanced optimizations can mutate. + */ +async function hasAdvancedOptimizationCandidates( + filename: string, + data: string | Uint8Array, + sideEffects?: () => Promise, +): Promise { + // Side-effect-free @angular/ packages undergo top-level pure function annotations + if (/[\\/]node_modules[\\/]@angular[\\/]/.test(filename) && (await sideEffects?.()) === false) { + return true; + } + + if (typeof data === 'string') { + const hasDecorators = DECORATOR_REGEX.test(data); + if (hasDecorators && (await sideEffects?.()) === false) { + return true; + } + + return ADVANCED_OPTIMIZATION_REGEX.test(data); + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + for (const tokenBytes of DECORATOR_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + if ((await sideEffects?.()) === false) { + return true; + } + break; + } + } + + for (const tokenBytes of ADVANCED_OPTIMIZATION_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + return true; + } + } + + return false; +} + +/** + * Determines whether JavaScript code requires Angular linker processing. + * + * @param path The full path to the file. + * @param data The data (string or Buffer) of the file. + * @returns True if the code contains an Angular partial declaration; otherwise false. + */ +function requiresLinking(path: string, data: string | Uint8Array): boolean { + // @angular/core and @angular/compiler will cause false positives + // Also, TypeScript files do not require linking + if (/[\\/]@angular[\\/](?:compiler|core)[\\/]|\.[cm]?tsx?$/.test(path)) { + return false; + } + + if (typeof data === 'string') { + return data.includes(LINKER_DECLARATION_PREFIX); + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + return dataBuffer.includes(LINKER_DECLARATION_PREFIX_BYTES); +} + +/** + * Transformation options that should apply to all transformed files and data. + */ +export interface JavaScriptTransformerOptions { + sourcemap: boolean; + thirdPartySourcemaps?: boolean; + advancedOptimizations?: boolean; + jit?: boolean; + + /** + * The maximum number of concurrent transformation operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; +} + +/** + * Transformation options for an individual file or data transform request. + */ +export interface TransformOptions { + /** If true, bypass all Angular linker processing; if false, attempt linking. */ + skipLinker?: boolean; + + /** + * An optional lazy resolver callback that returns whether the file has side-effects. + * If it resolves to false, top-level pure function annotations and decorator wrapping may be applied. + */ + sideEffects?: () => Promise; + + /** If true, instrument the code for test coverage. */ + instrumentForCoverage?: boolean; +} + +/** + * A class that performs transformation of JavaScript files and raw data. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Transformation behavior is based on the filename and + * data. Transformations may include: async downleveling, Angular linking, + * and advanced optimizations. + */ +export class JavaScriptTransformer { + #workerPool: WorkerPool | undefined; + #commonOptions: Required>; + #fileCacheKeyBase: Uint8Array; + + /** Queue of pending transformation tasks waiting for an active concurrency slot. */ + #pendingTasks: { resolve: () => void; reject: (reason: Error) => void }[] = []; + + /** Current count of actively executing transformation tasks. */ + #activeTasks = 0; + + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool?.maxThreads || 1); + } + + constructor( + private readonly options: JavaScriptTransformerOptions, + private readonly cache?: Cache, + ) { + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + + // Extract options to ensure only the named options are serialized and sent to the worker + const { + sourcemap, + thirdPartySourcemaps = false, + advancedOptimizations = false, + jit = false, + } = options; + this.#commonOptions = { + sourcemap, + thirdPartySourcemaps, + advancedOptimizations, + jit, + }; + this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8'); + this.#workerPool = this.#ensureWorkerPool(); + } + + /** + * Executes a transformation action using a semaphore-based backpressure throttle. + * Prevents libuv thread pool saturation and excessive V8 heap accumulation. + * @param action A callback that produces a promise for the transformation result. + * @returns A promise resolving to the transformation result. + */ + async #runWithThrottle(action: () => Promise): Promise { + if (this.#activeTasks >= this.#maxConcurrency) { + await new Promise((resolve, reject) => { + this.#pendingTasks.push({ resolve, reject }); + }); + } else { + this.#activeTasks++; + } + + try { + return await action(); + } finally { + const next = this.#pendingTasks.shift(); + if (next) { + next.resolve(); + } else { + this.#activeTasks--; + } + } + } + + #ensureWorkerPool(): WorkerPool { + if (this.#workerPool) { + return this.#workerPool; + } + + const workerPoolOptions: WorkerPoolOptions = { + filename: require.resolve('./javascript-transformer-worker'), + workerData: this.#commonOptions, + ...(this.options.maxConcurrency !== undefined && { + minThreads: this.options.maxConcurrency, + maxThreads: this.options.maxConcurrency, + }), + }; + + // Prevent passing SSR `--import` (loader-hooks) from parent to child worker. + const filteredExecArgv = process.execArgv.filter((v) => v !== IMPORT_EXEC_ARGV); + if (process.execArgv.length !== filteredExecArgv.length) { + workerPoolOptions.execArgv = filteredExecArgv; + } + + this.#workerPool = new WorkerPool(workerPoolOptions); + + return this.#workerPool; + } + + /** + * Performs JavaScript transformations on a file from the filesystem. + * If no transformations are required, the data for the original file will be returned. + * @param filename The full path to the file. + * @param options Transformation options specific to this file. + * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. + */ + async transformFile(filename: string, options?: TransformOptions): Promise { + return this.#runWithThrottle(async () => { + const data = await readFile(filename); + + return this.#transform(filename, data, options); + }); + } + + /** + * Performs JavaScript transformations on the provided data of a file. The file does not need + * to exist on the filesystem. + * @param filename The full path of the file represented by the data. + * @param data The data of the file that should be transformed. + * @param options Transformation options specific to this file data. + * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. + */ + async transformData( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, + ): Promise { + return this.#runWithThrottle(() => this.#transform(filename, data, options)); + } + + async #transform( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, + ): Promise { + let resolvedSideEffects: boolean | undefined; + let sideEffectsQueried = false; + + const sideEffectsGetter = options?.sideEffects + ? async () => { + if (!sideEffectsQueried) { + sideEffectsQueried = true; + resolvedSideEffects = await options.sideEffects?.(); + } + + return resolvedSideEffects; + } + : undefined; + + const shouldLink = !options?.skipLinker && requiresLinking(filename, data); + const shouldOptimize = + this.#commonOptions.advancedOptimizations && + (await hasAdvancedOptimizationCandidates(filename, data, sideEffectsGetter)); + + // Perform a quick test to determine if the data needs any transformations. + // This allows directly returning the data without the worker communication overhead. + if (!shouldLink && !shouldOptimize && !options?.instrumentForCoverage) { + const keepSourcemap = + this.#commonOptions.sourcemap && + (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + if (typeof data === 'string') { + return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8'); + } + + return keepSourcemap ? data : removeSourceMappingURL(data); + } + + let cacheKey: string | undefined; + if (this.cache) { + // Create a cache key from the file data and options that affect the output. + // NOTE: If additional options are added, this may need to be updated. + const hasher = createContentHash(); + hasher.update( + `${!options?.skipLinker}--${resolvedSideEffects === false}--${!!options?.instrumentForCoverage}`, + ); + hasher.update(data); + hasher.update(this.#fileCacheKeyBase); + cacheKey = hasher.digest(); + + try { + const cached = await this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + } catch { + // Failure to get the value should not fail the transform + } + } + + // Only standalone (non-pooled) ArrayBuffers can be transferred across worker threads. + // Node.js shares an internal 8KB ArrayBuffer pool for small buffers, and transferring + // a pooled buffer will throw a DataCloneError because detaching it invalidates other slices. + // In addition, SharedArrayBuffers cannot be transferred, and Yarn PnP has deserialization issues. + const isTransferable = + typeof data !== 'string' && + data.buffer instanceof ArrayBuffer && + data.byteOffset === 0 && + data.byteLength === data.buffer.byteLength && + !process.versions.pnp; + + const result = (await this.#ensureWorkerPool().run( + { + filename, + data, + skipLinker: !shouldLink, + sideEffects: resolvedSideEffects, + instrumentForCoverage: options?.instrumentForCoverage, + }, + { + transferList: isTransferable ? [data.buffer] : undefined, + }, + )) as Uint8Array; + + if (this.cache && cacheKey) { + try { + await this.cache.put(cacheKey, result); + } catch { + // Failure to store the value in the cache should not fail the transform + } + } + + return result; + } + + /** + * Stops all active transformation tasks and shuts down all workers. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + const pending = this.#pendingTasks; + this.#pendingTasks = []; + for (const task of pending) { + task.reject(new Error('JavaScriptTransformer closed.')); + } + + if (this.#workerPool) { + try { + await this.#workerPool.destroy(); + } finally { + this.#workerPool = undefined; + } + } + } +} diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts new file mode 100644 index 000000000000..94d671ef2a72 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts @@ -0,0 +1,662 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { JavaScriptTransformer } from './javascript-transformer'; + +describe('JavaScriptTransformer sourcemaps', () => { + let transformer: JavaScriptTransformer; + + afterEach(async () => { + await transformer?.close(); + }); + + function extractSourcemap(code: string): Record | null { + const match = code.match( + /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.+)/, + ); + if (!match) { + return null; + } + + return JSON.parse(Buffer.from(match[1], 'base64').toString('utf-8')); + } + + it('should remap correctly when only advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['export class MyClass { static ɵprov = 42; }'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `export class MyClass { static ɵprov = 42; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when only linking is applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/directive.ts'], + sourcesContent: ['export class MyDirective {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData('node_modules/my-lib/directive.js', input, { + skipLinker: false, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/directive.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/component.ts'], + sourcesContent: ['export class MyComponent {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + selector: "my-cmp", + template: "
" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData('node_modules/my-lib/component.js', input, { + skipLinker: false, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/component.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should produce a valid sourcemap when no input sourcemap is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const input = 'export class MyClass { static ɵprov = 42; }'; + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/counter.ts'], + sourcesContent: ['export function add(a: number, b: number) { return a + b; }'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `export function add(a, b) { return a + b; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/counter.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/counter.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['const x = new SomeClass();'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/app.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should accept a Uint8Array input in transformData', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export class MyClass { static ɵprov = 42; }', 'utf-8'); + const result = await transformer.transformData('src/app.js', inputBuffer, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + }); + + it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + 'console.log("hello");\n//# sourceMappingURL=app.js.map', + 'utf-8', + ); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toBe('console.log("hello");\n'); + }); + + it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8'); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: false, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); + const result = await transformer.transformData( + 'node_modules/@angular/core/fesm2022/core.mjs', + inputBuffer, + { skipLinker: false }, + ); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); + + for (const ext of ['.ts', '.tsx', '.mts', '.cts']) { + const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, { + skipLinker: false, + }); + + expect(result).toBe(inputBuffer); + } + }); + + it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = await transformer.transformData( + 'node_modules/@angular/compiler-cli/test.js', + input, + { skipLinker: false }, + ); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).not.toContain('i0.ɵɵngDeclareDirective'); + }); + + describe('advanced optimizations fast-path pre-filter', () => { + it('should bypass worker and return input buffer directly when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + 'function add(a, b) { return a + b; }\nconst result = add(1, 2);', + 'utf-8', + ); + const result = await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for standard classes without static properties', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + `export class UserService { + constructor(http) { this.http = http; } + getUser(id) { return this.http.get('/users/' + id); } + }`, + 'utf-8', + ); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for default exports without static properties or Angular metadata', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + `export default class UserService { + constructor(http) { this.http = http; } + getUser(id) { return this.http.get('/users/' + id); } + }`, + 'utf-8', + ); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for classes with static members when no Angular metadata is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export class MyComponent { static prop = 42; }', 'utf-8'); + const result = await transformer.transformData('src/component.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should dispatch to worker when Angular tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const input = 'export class MyService { static ɵprov = true; }'; + const result = await transformer.transformData('src/service.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + }); + + it('should dispatch to worker when decorator tokens are present and sideEffects is false', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => false, + }); + + expect(result).not.toBe(inputBuffer); + }); + + it('should bypass worker and return converted buffer when no candidate tokens are present in string input', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputString = 'function multiply(a, b) { return a * b; }'; + const result = await transformer.transformData('src/math.js', inputString, { + skipLinker: true, + }); + + expect(Buffer.from(result).toString('utf-8')).toBe(inputString); + }); + + it('should dispatch to worker when candidate tokens are present in string input', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputString = 'export class MyService { static ɵprov = true; }'; + const result = await transformer.transformData('src/service.js', inputString, { + skipLinker: true, + }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + }); + + it('should not query sideEffects when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queried = false; + const inputBuffer = Buffer.from('function add(a, b) { return a + b; }', 'utf-8'); + await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should not query sideEffects when primary tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queried = false; + const input = 'export class MyService { static ɵprov = true; }'; + await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should query sideEffects for @angular/ packages and dispatch to worker when sideEffects is false', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from('export const foo = someCall();', 'utf-8'); + const result = await transformer.transformData( + '/node_modules/@angular/core/fesm2022/index.mjs', + inputBuffer, + { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }, + ); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects for decorator tokens and evaluate at most once', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects and wrap decorators when both primary and decorator tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const input = ` + let MyService = class MyService { static ɵprov = true; }; + MyService = __decorate([], MyService); + `; + const result = await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + + it('should query sideEffects when both primary and decorator tokens are present in Buffer', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from( + 'let MyService = class MyService { static ɵprov = true; };\nMyService = __decorate([], MyService);', + 'utf-8', + ); + const result = await transformer.transformData('src/service.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + }); + + describe('maxConcurrency', () => { + it('should throw RangeError if maxConcurrency is less than 1', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 0 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: -1 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should throw RangeError if maxConcurrency is not an integer', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 1.5 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should allow omitting maxConcurrency', async () => { + transformer = new JavaScriptTransformer({ sourcemap: false }); + const result = await transformer.transformData('src/app.js', 'const x = 1;', { + skipLinker: true, + }); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts new file mode 100644 index 000000000000..7fe8c68030fc --- /dev/null +++ b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts @@ -0,0 +1,1103 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { transform } from './oxc-transform'; + +const NO_CHANGE = Symbol('NO_CHANGE'); + +function cleanCode(code: string): string { + return code.replace(/\s+/g, '').replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, + options, +}: { + input: string; + expected: string | typeof NO_CHANGE; + options?: { wrapDecorators?: boolean }; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + sideEffects: options?.wrapDecorators ? false : true, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected === NO_CHANGE ? input : expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +describe('adjust-static-class-members oxc-transform implementation', () => { + it( + 'elides empty ctorParameters function expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = function () { return []; }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty ctorParameters function expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = function () { return [{type: Injector}]; }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides empty ctorParameters arrow expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = () => []; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty ctorParameters arrow expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = () => [{type: Injector}]; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps ctorParameters static field without arrow/function expression', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.ctorParameters = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'elides empty decorators static field with array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = []; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty decorators static field with array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = [{ type: Injectable }]; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps decorators static field without array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.decorators = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'elides empty propDecorators static field with object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = {}; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty propDecorators static field with object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = { 'ngIf': [{ type: Input }] }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps propDecorators static field without object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.propDecorators = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'does not wrap default exported class with no connected siblings', + testCase({ + // NOTE: This could technically have no changes but the default export splitting detection + // does not perform class property analysis currently. + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + export { CustomComponentEffects as default }; + `, + }), + ); + + it( + 'does wrap not default exported class with only side effect fields', + testCase({ + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only side effect fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only side effect native fields', + testCase({ + input: ` + class CustomComponentEffects { + static someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only instance native fields', + testCase({ + input: ` + class CustomComponentEffects { + someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (#__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect native fields (#__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (@__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (@pureOrBreakMyCode)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /**@pureOrBreakMyCode*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = + /**@pureOrBreakMyCode*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with closure pure annotated side effect fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /* @pureOrBreakMyCode */ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = + /* @pureOrBreakMyCode */ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps exported class with a pure static field', + testCase({ + input: ` + export class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + export let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps exported class with a pure native static field', + testCase({ + input: ` + export class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + export let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with a basic literal static field', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with a pure static field', + testCase({ + input: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + `, + expected: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + let TemplateRef = /*#__PURE__*/ (() => { + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + return TemplateRef; + })(); + `, + }), + ); + + it( + 'wraps class with multiple pure static field', + testCase({ + input: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + TemplateRef.someField = 42; + `, + expected: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + let TemplateRef = /*#__PURE__*/ (() => { + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + TemplateRef.someField = 42; + return TemplateRef; + })(); + `, + }), + ); + + it( + 'does not wrap class with only some pure static fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only pure native static fields and some side effect static fields', + testCase({ + input: ` + class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only some pure native static fields', + testCase({ + input: ` + class CustomComponentEffects { + static someField = 42; + static someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with class decorators when wrapDecorators is false', + testCase({ + input: ` + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + Dec() + ], SomeClass); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with Angular ɵfac static field (esbuild)', + testCase({ + input: ` + var Comp2Component = class _Comp2Component { + static { + this.ɵfac = function Comp2Component_Factory(t) { + return new (t || _Comp2Component)(); + }; + } + }; + `, + expected: ` + var Comp2Component = /*#__PURE__*/ (() => { + let Comp2Component = class _Comp2Component { + static { + this.ɵfac = function Comp2Component_Factory(t) { + return new (t || _Comp2Component)(); + }; + } + }; + return Comp2Component; + })(); + `, + }), + ); + + it( + 'wraps class with class decorators when wrapDecorators is true (esbuild output)', + testCase({ + input: ` + var ExampleClass = class { + method() { + } + }; + __decorate([ + SomeDecorator() + ], ExampleClass.prototype, "method", null); + `, + expected: ` + var ExampleClass = /*#__PURE__*/ (() => { + let ExampleClass = class { + method() {} + }; + __decorate([SomeDecorator()], ExampleClass.prototype, "method", null); + return ExampleClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'wraps class with class decorators when wrapDecorators is true', + testCase({ + input: ` + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + SomeDecorator() + ], SomeClass); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + SomeDecorator() + ], SomeClass); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'does not wrap class with constructor decorators when wrapDecorators is false', + testCase({ + input: ` + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with constructor decorators when wrapDecorators is true', + testCase({ + input: ` + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'does not wrap class with field decorators when wrapDecorators is false', + testCase({ + input: ` + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with field decorators when wrapDecorators is true', + testCase({ + input: ` + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'wraps class with Angular ɵfac static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with Angular ɵfac static block (ES2022 + useDefineForClassFields: false)', + testCase({ + input: ` + class CommonModule { + static { this.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; } + static { this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); } + } + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + static { + this.ɵfac = function CommonModule_Factory(t) { + return new (t || CommonModule)(); + }; + } + static { + this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ + type: CommonModule, + }); + } + } + return CommonModule; + })(); + `, + }), + ); + + it( + 'does not wrap class with side effect full static block (ES2022 + useDefineForClassFields: false)', + testCase({ + input: ` + class CommonModule { + static { globalThis.bar = 1 } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps class with Angular ɵmod static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with Angular ɵinj static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization + }, + ] }); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with multiple Angular static fields', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization + }, + ]}); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with multiple Angular native static fields', + testCase({ + input: ` + class CommonModule { + static ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + static ɵinj = ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + } + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + static ɵfac = function CommonModule_Factory(t) { + return new (t || CommonModule)(); + }; + static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ + type: CommonModule, + }); + static ɵinj = ɵngcc0.ɵɵdefineInjector({ + providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization, + }, + ], + }); + } + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps default exported class with pure static fields', + testCase({ + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + export { CustomComponentEffects as default }; + `, + }), + ); + it( + 'supports class with empty static block and wraps pure static properties', + testCase({ + input: ` + class MyClass { + static {} + static ɵprov = 42; + } + `, + expected: ` + let MyClass = /*#__PURE__*/ (() => { + class MyClass { + static {} + static ɵprov = 42; + } + return MyClass; + })(); + `, + }), + ); + + it( + 'does not wrap class with non-empty static block', + testCase({ + input: ` + class MyClass { + static { console.log(1); } + static ɵprov = 42; + } + `, + expected: ` + class MyClass { + static { console.log(1); } + static ɵprov = 42; + } + `, + }), + ); + + it( + 'wraps adjacent class declarations without interleaving', + testCase({ + input: 'class A{static s=[1]}class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent exported class declarations without interleaving', + testCase({ + input: 'export class A{static s=[1]}export class B{static s=[2]}', + expected: ` + export let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + export let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent default export and class declarations without interleaving', + testCase({ + input: 'export default class A{static s=[1]}class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + export { A as default }; + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'splits default export without interleaving with adjacent class declaration', + testCase({ + input: 'export default class A{}class B{static s=[2]}', + expected: ` + class A {} + export { A as default }; + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent variable class declarations without interleaving', + testCase({ + input: 'let A=class A{static s=[1]};class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + let A = class A { + static s = [1] + }; + return A; + })(); + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts new file mode 100644 index 000000000000..82877cb3b2e3 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts @@ -0,0 +1,382 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { format } from 'prettier'; +import { transform } from './oxc-transform'; + +const NO_CHANGE = Symbol('NO_CHANGE'); + +function testCase({ + input, + expected, +}: { + input: string; + expected: string | typeof NO_CHANGE; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + expect(await format(result.code, { parser: 'babel' })).toEqual( + await format(expected === NO_CHANGE ? input : expected, { parser: 'babel' }), + ); + } + }; +} + +describe('adjust-typescript-enums oxc-transform implementation', () => { + it( + 'wraps unexported TypeScript enums', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps exported TypeScript enums', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'does not wrap exported TypeScript enums from CommonJS (<5.1)', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy = exports.ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = {})); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps exported TypeScript enums from CommonJS (5.1+)', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {})); + `, + }), + ); + + it( + 'wraps TypeScript enums with custom numbering', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 5] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 8] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 5)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 8)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps string-based TypeScript enums', + testCase({ + input: ` + var NotificationKind; + (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + })(NotificationKind || (NotificationKind = {})); + `, + expected: ` + var NotificationKind = /*#__PURE__*/ (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + return NotificationKind; + })(NotificationKind || {}); + `, + }), + ); + + it( + 'wraps enums that were renamed due to scope hoisting', + testCase({ + input: ` + var NotificationKind$1; + (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + })(NotificationKind$1 || (NotificationKind$1 = {})); + `, + expected: ` + var NotificationKind$1 = /*#__PURE__*/ (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + return NotificationKind; + })(NotificationKind$1 || {}); + `, + }), + ); + + it( + 'maintains multi-line comments', + testCase({ + input: ` + /** + * Supported http methods. + * @deprecated use @angular/common/http instead + */ + var RequestMethod; + (function (RequestMethod) { + RequestMethod[RequestMethod["Get"] = 0] = "Get"; + RequestMethod[RequestMethod["Post"] = 1] = "Post"; + RequestMethod[RequestMethod["Put"] = 2] = "Put"; + RequestMethod[RequestMethod["Delete"] = 3] = "Delete"; + RequestMethod[RequestMethod["Options"] = 4] = "Options"; + RequestMethod[RequestMethod["Head"] = 5] = "Head"; + RequestMethod[RequestMethod["Patch"] = 6] = "Patch"; + })(RequestMethod || (RequestMethod = {})); + `, + expected: ` + /** + * Supported http methods. + * @deprecated use @angular/common/http instead + */ + var RequestMethod = /*#__PURE__*/ (function (RequestMethod) { + RequestMethod[(RequestMethod["Get"] = 0)] = "Get"; + RequestMethod[(RequestMethod["Post"] = 1)] = "Post"; + RequestMethod[(RequestMethod["Put"] = 2)] = "Put"; + RequestMethod[(RequestMethod["Delete"] = 3)] = "Delete"; + RequestMethod[(RequestMethod["Options"] = 4)] = "Options"; + RequestMethod[(RequestMethod["Head"] = 5)] = "Head"; + RequestMethod[(RequestMethod["Patch"] = 6)] = "Patch"; + return RequestMethod; + })(RequestMethod || {}); + `, + }), + ); + + it( + 'does not wrap TypeScript enums with side effect values', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = console.log('foo'); + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap object literals similar to TypeScript enums', + testCase({ + input: ` + const RendererStyleFlags3 = { + Important: 1, + DashCase: 2, + }; + if (typeof RendererStyleFlags3 === 'object') { + RendererStyleFlags3[RendererStyleFlags3.Important] = 'DashCase'; + } + RendererStyleFlags3[RendererStyleFlags3.Important] = 'Important'; + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps TypeScript enums', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'should wrap TypeScript enums if the declaration identifier has been renamed to avoid collisions', + testCase({ + input: ` + var ChangeDetectionStrategy$1; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy$1 || (ChangeDetectionStrategy$1 = {})); + `, + expected: ` + var ChangeDetectionStrategy$1 = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy$1 || {}); + `, + }), + ); + + it( + 'handles TypeScript enums with chained exports assignment (angular-split / shared-docs pattern)', + testCase({ + input: ` + var Area; + (function (a1) { + a1[a1["areaAfter"] = 0] = "areaAfter"; + a1[a1["preserveOtherCategoryOrder"] = 1] = "preserveOtherCategoryOrder"; + })(Area || (Area = exports.Area = {})); + `, + expected: ` + var Area = /*#__PURE__*/ (function (a1) { + a1[(a1["areaAfter"] = 0)] = "areaAfter"; + a1[(a1["preserveOtherCategoryOrder"] = 1)] = "preserveOtherCategoryOrder"; + return a1; + })(Area || (exports.Area = {})); + `, + }), + ); + + it( + 'handles TypeScript enums wrapped in parentheses', + testCase({ + input: ` + var ChangeDetectionStrategy; + ((function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}))); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps Crockford-style TypeScript enum IIFE without leaving dangling parentheses', + testCase({ + input: ` + var HDirection; + (function (HDirection) { + HDirection[HDirection['Backwards'] = -1] = 'Backwards'; + HDirection[HDirection['Forwards'] = 1] = 'Forwards'; + }(HDirection || (HDirection = {}))); + const nextStatement = true; + `, + expected: ` + var HDirection = /*#__PURE__*/ (function (HDirection) { + HDirection[(HDirection['Backwards'] = -1)] = 'Backwards'; + HDirection[(HDirection['Forwards'] = 1)] = 'Forwards'; + return HDirection; + }(HDirection || {})); + + const nextStatement = true; + `, + }), + ); + + it( + 'wraps TypeScript enum IIFE with multiple nested parentheses', + testCase({ + input: ` + var Foo; + (((function (Foo) { + Foo[Foo['A'] = 0] = 'A'; + }(Foo || (Foo = {}))))); + `, + expected: ` + var Foo = /*#__PURE__*/ (function (Foo) { + Foo[(Foo['A'] = 0)] = 'A'; + return Foo; + }(Foo || {})); + `, + }), + ); + + it( + 'wraps Crockford-style TypeScript enum IIFE with chained export assignments', + testCase({ + input: ` + var Foo; + (function (Foo) { + Foo[Foo['A'] = 0] = 'A'; + }(Foo || (Foo = exports.Foo = {}))); + `, + expected: ` + var Foo = /*#__PURE__*/ (function (Foo) { + Foo[(Foo['A'] = 0)] = 'A'; + return Foo; + }(Foo || (exports.Foo = {}))); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts new file mode 100644 index 000000000000..f4d6c09b6c19 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { transform } from './oxc-transform'; + +function cleanCode(code: string): string { + return code + .replace(/\s+/g, '') + .replace(/"/g, "'") + .replace(/;}/g, '}') + .replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, +}: { + input: string; + expected: string; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +describe('elide-angular-metadata oxc-transform implementation', () => { + it( + 'elides pure annotated ɵsetClassMetadata', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { i0.ɵsetClassMetadata(Clazz, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { void 0 })(); + `, + }), + ); + + it( + 'elides JIT mode protected ɵsetClassMetadata', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })();`, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })();`, + }), + ); + + it( + 'elides ɵsetClassMetadata inside an arrow-function-based IIFE', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { i0.ɵsetClassMetadata(Clazz, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { void 0 })(); + `, + }), + ); + + it( + 'elides pure annotated ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { + i0.ɵsetClassMetadataAsync(SomeClass, + function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; }, + function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { void 0 })(); + `, + }), + ); + + it( + 'elides JIT mode protected ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { + (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadataAsync(SomeClass, + function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; }, + function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })(); + `, + }), + ); + + it( + 'elides arrow-function-based ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { + i0.ɵsetClassMetadataAsync(SomeClass, + () => [import("./cmp-a").then(m => m.CmpA)], + (CmpA) => { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { void 0 })(); + `, + }), + ); + + it( + 'elides ɵsetClassDebugInfo', + testCase({ + input: ` + import { Component } from '@angular/core'; + class SomeClass {} + (() => { + (typeof ngDevMode === 'undefined' || ngDevMode) && + i0.ɵsetClassDebugInfo(SomeClass, { className: 'SomeClass' }); + })(); + `, + expected: ` + import { Component } from "@angular/core"; + class SomeClass {} + (() => { + (typeof ngDevMode === "undefined" || ngDevMode) && void 0; + })(); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts new file mode 100644 index 000000000000..e0d6ba26a252 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -0,0 +1,748 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { DecodedSourceMap } from '@ampproject/remapping'; +import { needsLinking } from '@angular/compiler-cli/linker'; +import type { BindingIdentifier, Class, Node } from '@oxc-project/types'; +import { MagicString } from 'magic-string'; +import { parseSync } from 'oxc-parser'; +import { OxcLinker } from '../angular/linker/oxc-linker'; +import { traversePostOrder } from './traversal'; + +export interface OxcTransformOptions { + sourcemap?: boolean; + sideEffects?: boolean; + topLevelSafeMode?: boolean; + pureAnnotate?: boolean; + link?: boolean; + jit?: boolean; + advancedOptimizations?: boolean; +} + +/** + * A set of constructor names that are considered to be side-effect free. + */ +const sideEffectFreeConstructors = new Set(['InjectionToken']); + +/** + * A set of TypeScript helper function names used by the helper name matcher utility function. + */ +const tslibHelpers = new Set([ + '__extends', + '__assign', + '__rest', + '__decorate', + '__param', + '__esDecorate', + '__runInitializers', + '__propKey', + '__setFunctionName', + '__metadata', + '__awaiter', + '__generator', + '__exportStar', + '__values', + '__read', + '__privateGet', + '__privateSet', + '__privateMethod', + '__addDisposableResource', + '__disposeResources', +]); + +/** + * Determines whether an identifier name matches one of the TypeScript helper function names. + * + * @param name The identifier name to check. + * @returns True if the name matches a TypeScript helper name; otherwise, false. + */ +function isTslibHelperName(name: string): boolean { + const nameParts = name.split('$'); + const originalName = nameParts[0]; + + if (nameParts.length > 2 || (nameParts.length === 2 && !/^\d+$/.test(nameParts[1]))) { + return false; + } + + return tslibHelpers.has(originalName); +} + +/** + * A set of Babel helper function names that are intended to cause side effects. + */ +const babelHelpers = new Set(['_defineProperty']); + +/** + * Determines whether an identifier name matches one of the Babel helper function names. + * + * @param name The identifier name to check. + * @returns True if the name matches a Babel helper name; otherwise, false. + */ +function isBabelHelperName(name: string): boolean { + return babelHelpers.has(name); +} + +/** + * A set of Angular static properties that should be wrapped in pure IIFE statements. + */ +const angularStaticsToWrap = new Set([ + 'ɵcmp', + 'ɵdir', + 'ɵfac', + 'ɵinj', + 'ɵmod', + 'ɵpipe', + 'ɵprov', + 'INJECTOR_KEY', +]); + +/** + * A set of Angular metadata decorator functions that can be elided. + */ +const angularMetadataFunctions = new Set([ + 'ɵsetClassMetadata', + 'ɵsetClassMetadataAsync', + 'ɵsetClassDebugInfo', +]); + +/** + * A map of static properties and their matcher predicate functions to check if they + * can be safely elided from class declarations. + */ +const angularStaticsToElide: Record boolean> = { + 'ctorParameters'(node) { + return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression'; + }, + 'decorators'(node) { + return node.type === 'ArrayExpression'; + }, + 'propDecorators'(node) { + return node.type === 'ObjectExpression'; + }, +}; + +/** + * Determines whether an AST node is considered safe for pure evaluation (side-effect free). + * + * @param node The AST node to check. + * @returns True if the node is side-effect free; otherwise, false. + */ +function isPure(node: Node): boolean { + switch (node.type) { + case 'Identifier': + case 'Literal': + return true; + case 'BinaryExpression': + case 'LogicalExpression': + return isPure(node.left) && isPure(node.right); + case 'UnaryExpression': + return isPure(node.argument); + case 'MemberExpression': + return isPure(node.object) && (!node.computed || isPure(node.property)); + case 'ObjectExpression': + return node.properties.every((p) => p.type === 'Property' && isPure(p.value)); + case 'ArrayExpression': + return node.elements.every((e) => !e || isPure(e)); + case 'ParenthesizedExpression': + return isPure(node.expression); + default: + return false; + } +} + +/** + * Recursively unwraps any ParenthesizedExpression wrapper nodes to get the inner expression. + * + * @param node The potentially parenthesized AST node. + * @returns The inner non-parenthesized AST node. + */ +function unwrapParentheses(node: Node): Node { + while (node && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + +/** + * Determines whether a class static property assignment is safe to be wrapped in a pure IIFE. + * + * @param propertyName The name of the property. + * @param assignmentValue The AST node representing the value assigned. + * @param code The source code of the file. + * @returns True if the property can be wrapped safely; otherwise, false. + */ +function canWrapProperty(propertyName: string, assignmentValue: Node, code: string): boolean { + if (angularStaticsToWrap.has(propertyName)) { + return true; + } + + const prefix = code.substring(Math.max(0, assignmentValue.start - 100), assignmentValue.start); + if (/(\/\*[\s\S]*?(?:@__PURE__|#__PURE__|@pureOrBreakMyCode)[\s\S]*?\*\/)\s*$/.test(prefix)) { + return true; + } + + return isPure(assignmentValue); +} + +/** + * Analyzes static properties inside a class body to determine if the class has any + * side-effecting static property initializers. + * + * @param classNode The Class AST node. + * @param code The source code of the file. + * @returns True if the class static properties are pure and can be wrapped; otherwise, false. + */ +function analyzeClassStaticProperties(classNode: Node, code: string): boolean { + let shouldWrap = false; + const body = (classNode as Class).body.body; + for (const element of body) { + if (element.type === 'PropertyDefinition') { + if (!element.static) { + continue; + } + + const key = element.key; + const value = element.value; + if (key.type === 'Identifier' && (!value || canWrapProperty(key.name, value, code))) { + shouldWrap = true; + } else { + shouldWrap = false; + break; + } + } else if (element.type === 'StaticBlock') { + const blockBody = element.body; + if (blockBody.length === 0) { + continue; + } + if (blockBody.length > 1) { + shouldWrap = false; + break; + } + const expressionStatement = blockBody[0]; + if (expressionStatement && expressionStatement.type === 'ExpressionStatement') { + const assignment = expressionStatement.expression; + if ( + assignment && + assignment.type === 'AssignmentExpression' && + assignment.left.type === 'MemberExpression' + ) { + const left = assignment.left; + if (left.object.type === 'ThisExpression' && left.property.type === 'Identifier') { + if (canWrapProperty(left.property.name, assignment.right, code)) { + shouldWrap = true; + continue; + } + } + } + } + shouldWrap = false; + break; + } + } + + return shouldWrap; +} + +/** + * Executes a single-pass optimized transformation using oxc-parser and magic-string. + * Performs typescript enum wrapping, static class members elision/wrapping, angular metadata elision, + * and top-level pure function annotations. + * + * @param filename The absolute path of the file being transformed. + * @param code The string source content of the file. + * @param options Configuration options specifying which optimization steps to run. + * @returns The transformed code string and an optional source map. + */ +// eslint-disable-next-line max-lines-per-function +export function transform(filename: string, code: string, options: OxcTransformOptions) { + const { program } = parseSync(filename, code, { range: true }); + const source = new MagicString(code); + + const shouldLink = options.link && needsLinking(filename, code); + const linker = shouldLink ? new OxcLinker(filename, code, options.jit) : undefined; + + const advancedOptimizations = options.advancedOptimizations ?? true; + const sideEffectFree = options.sideEffects === false; + const topLevelSafeMode = options.topLevelSafeMode ?? false; + const wrapDecorators = sideEffectFree; + const pureAnnotate = options.pureAnnotate ?? true; + + /** + * Scans backwards from the specified start index to check if a pure comment (e.g. `/*@__PURE__*\/`) + * already precedes the node. + * + * @param start The index where the node starts. + * @returns True if a pure comment is already present; otherwise, false. + */ + function hasPureComment(start: number): boolean { + let i = start - 1; + while (i >= 0 && /\s/.test(code[i])) { + i--; + } + if (i < 1 || code[i] !== '/' || code[i - 1] !== '*') { + return false; + } + const commentEnd = i + 1; + const commentStart = code.lastIndexOf('/*', commentEnd); + if (commentStart === -1) { + return false; + } + const commentContent = code.substring(commentStart + 2, commentEnd - 2); + + return commentContent.includes('@__PURE__') || commentContent.includes('#__PURE__'); + } + + const editedRanges: { start: number; end: number }[] = []; + + /** + * Records a range in the source code that has been modified, preventing subsequent nested mutations. + * + * @param start The start index of the modified range. + * @param end The end index of the modified range. + */ + function markEdited(start: number, end: number) { + editedRanges.push({ start, end }); + } + + /** + * Checks if the specified range falls inside an already modified section of code. + * + * @param start The start index of the range. + * @param end The end index of the range. + * @returns True if the range is already edited; otherwise, false. + */ + function isAlreadyEdited(start: number, end: number): boolean { + return editedRanges.some((r) => start >= r.start && end <= r.end); + } + + /** + * Scans and rewrites TypeScript emitted enum declarations in the statement block. + * Wraps enum statements inside a pure IIFE assignable directly to the enum variable. + * + * @param body The array of statement AST nodes to process. + */ + function adjustTypeScriptEnumsInStatements(body: Node[]) { + for (let i = 0; i < body.length - 1; i++) { + const statement = body[i]; + let declStatement = statement; + if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'VariableDeclaration' + ) { + declStatement = statement.declaration; + } + + if ( + declStatement.type !== 'VariableDeclaration' || + declStatement.kind !== 'var' || + declStatement.declarations.length !== 1 + ) { + continue; + } + const decl = declStatement.declarations[0]; + if (decl.init || decl.id.type !== 'Identifier') { + continue; + } + + const nextStatement = body[i + 1]; + if (nextStatement.type !== 'ExpressionStatement') { + continue; + } + + const nextExpr = unwrapParentheses(nextStatement.expression); + if (nextExpr.type !== 'CallExpression' || nextExpr.arguments.length !== 1) { + continue; + } + + const arg = unwrapParentheses(nextExpr.arguments[0]); + if (arg.type !== 'LogicalExpression' || arg.operator !== '||') { + continue; + } + + const argLeft = unwrapParentheses(arg.left); + if (argLeft.type !== 'Identifier' || argLeft.name !== decl.id.name) { + continue; + } + + const rightCallArgument = unwrapParentheses(arg.right); + if (rightCallArgument.type !== 'AssignmentExpression') { + continue; + } + + const callee = unwrapParentheses(nextExpr.callee); + if ( + callee.type !== 'FunctionExpression' || + callee.params.length !== 1 || + !callee.body || + callee.body.type !== 'BlockStatement' + ) { + continue; + } + + const param = callee.params[0]; + if (param.type !== 'Identifier') { + continue; + } + const paramName = (param as BindingIdentifier).name; + + // Check if all statements in body are pure assignments + let hasElements = false; + let allPure = true; + for (const enumStatement of callee.body.body) { + if (enumStatement.type !== 'ExpressionStatement') { + allPure = false; + break; + } + + const enumValueAssignment = unwrapParentheses(enumStatement.expression); + if ( + enumValueAssignment.type !== 'AssignmentExpression' || + !isPure(enumValueAssignment.right) + ) { + allPure = false; + break; + } + + hasElements = true; + } + + if (!allPure || !hasElements) { + continue; + } + + // 1. Remove leading/trailing characters/parentheses of the expression statement + source.remove(nextStatement.start, nextExpr.start); + source.remove(nextExpr.end, nextStatement.end); + markEdited(nextStatement.start, nextStatement.end); + + // 2. Add return statement inside IIFE body + source.appendRight(callee.body.end - 1, `; return ${paramName};`); + + // 3. Remove `Name = ` assignment in arguments if it's a simple identifier + if (rightCallArgument.left.type === 'Identifier') { + let replacement = code.substring( + rightCallArgument.right.start, + rightCallArgument.right.end, + ); + if (unwrapParentheses(rightCallArgument.right).type === 'AssignmentExpression') { + replacement = `(${replacement})`; + } + source.overwrite(arg.right.start, arg.right.end, replacement); + markEdited(arg.right.start, arg.right.end); + } + + // 4. Move IIFE to the var initializer + source.move(nextExpr.start, nextExpr.end, decl.id.end); + source.appendLeft(decl.id.end, ' = /*#__PURE__*/ '); + } + } + + /** + * Scans and rewrites static class member initializers in the statement block. + * Groups externalized class static assignments into pure wrappers or elides them when safe. + * + * @param body The array of statement AST nodes to process. + */ + function adjustStaticMembersInStatements(body: Node[]) { + for (let i = 0; i < body.length; i++) { + const statement = body[i]; + let classNode: Node | null = null; + let isExportDefault = false; + let isExportNamed = false; + let isVariableClass = false; + let classIdName = ''; + if (statement.type === 'ClassDeclaration') { + classNode = statement; + classIdName = classNode.id?.name || ''; + } else if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'ClassDeclaration' + ) { + classNode = statement.declaration; + classIdName = classNode.id?.name || ''; + isExportNamed = true; + } else if ( + statement.type === 'ExportDefaultDeclaration' && + statement.declaration?.type === 'ClassDeclaration' + ) { + classNode = statement.declaration; + classIdName = classNode.id?.name || ''; + isExportDefault = true; + } else if (statement.type === 'VariableDeclaration' && statement.declarations.length === 1) { + const decl = statement.declarations[0]; + if (decl.init && decl.init.type === 'ClassExpression' && decl.id.type === 'Identifier') { + classNode = decl.init; + classIdName = decl.id.name; + isVariableClass = true; + } + } + + if (!classNode || !classIdName) { + continue; + } + + const wrapStatementPaths: { statement: Node; type: 'wrap' | 'decorate' | 'elide' }[] = []; + let hasPotentialSideEffects = false; + + for (let j = i + 1; j < body.length; j++) { + const nextStatement = body[j]; + if (nextStatement.type !== 'ExpressionStatement') { + break; + } + + const nextExpression = nextStatement.expression; + + // Case 1: __decorate(...) + if (nextExpression.type === 'CallExpression') { + if ( + nextExpression.callee.type !== 'Identifier' || + nextExpression.callee.name !== '__decorate' + ) { + break; + } + + if (wrapDecorators) { + wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' }); + } else { + hasPotentialSideEffects = true; + } + continue; + } + + // Case 2: AssignmentExpression + if (nextExpression.type !== 'AssignmentExpression') { + break; + } + + const left = nextExpression.left; + + if (left.type === 'Identifier') { + if ( + left.name !== classIdName || + nextExpression.right.type !== 'CallExpression' || + nextExpression.right.callee.type !== 'Identifier' || + nextExpression.right.callee.name !== '__decorate' + ) { + break; + } + + if (wrapDecorators) { + wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' }); + } else { + hasPotentialSideEffects = true; + } + continue; + } + + if ( + left.type !== 'MemberExpression' || + left.object.type !== 'Identifier' || + left.object.name !== classIdName || + left.property.type !== 'Identifier' + ) { + break; + } + + const propertyName = left.property.name; + const assignmentValue = nextExpression.right; + + if (angularStaticsToElide[propertyName]?.(assignmentValue)) { + wrapStatementPaths.push({ statement: nextStatement, type: 'elide' }); + } else if (canWrapProperty(propertyName, assignmentValue, code)) { + wrapStatementPaths.push({ statement: nextStatement, type: 'wrap' }); + } else { + hasPotentialSideEffects = true; + } + } + + // Check class body static properties + const shouldWrapClassStaticProperties = analyzeClassStaticProperties(classNode, code); + + // Perform elisions immediately + for (const item of wrapStatementPaths) { + if (item.type === 'elide') { + source.remove(item.statement.start, item.statement.end); + markEdited(item.statement.start, item.statement.end); + } + } + + const activeWrapPaths = wrapStatementPaths.filter( + (p) => p.type === 'wrap' || p.type === 'decorate', + ); + + if ( + !hasPotentialSideEffects && + (activeWrapPaths.length > 0 || shouldWrapClassStaticProperties) + ) { + const lastStatement = + activeWrapPaths.length > 0 + ? activeWrapPaths[activeWrapPaths.length - 1].statement + : classNode; + + if (isExportDefault) { + // 1. Remove `export default ` + source.overwrite(statement.start, classNode.start, ''); + // 2. Wrap in IIFE + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft( + lastStatement.end, + `\nreturn ${classIdName};\n})();\nexport { ${classIdName} as default };`, + ); + } else if (isExportNamed) { + // 1. Export is kept, turn `class` into `let ClassName = IIFE` + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + } else if (isVariableClass) { + // 2. Wrap class inside init: `(() => { let ClassName = class ClassName {}; return ClassName; })()` + source.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `); + const terminator = activeWrapPaths.length === 0 ? ';' : ''; + const iifeClosing = activeWrapPaths.length === 0 ? '})()' : '})();'; + source.appendLeft( + lastStatement.end, + `${terminator}\nreturn ${classIdName};\n${iifeClosing}`, + ); + } else { + // Standard ClassDeclaration + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + } + + markEdited(statement.start, lastStatement.end); + + // Fast-forward outer loop index to skip the statements we wrapped + i += wrapStatementPaths.length; + } else if (isExportDefault && !hasPotentialSideEffects) { + // Splitting default export even when not wrapped + source.overwrite(statement.start, classNode.start, ''); + source.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`); + markEdited(statement.start, classNode.end); + } + } + } + + traversePostOrder(program, (node, { functionDepth, classDepth, parentFunc }) => { + switch (node.type) { + case 'Program': + case 'BlockStatement': + if (advancedOptimizations) { + adjustTypeScriptEnumsInStatements(node.body); + adjustStaticMembersInStatements(node.body); + } + break; + + case 'CallExpression': { + if (isAlreadyEdited(node.start, node.end)) { + return; + } + + if (linker) { + const linkedCode = linker.linkCallExpression(node); + if (linkedCode !== undefined) { + source.overwrite(node.start, node.end, linkedCode); + markEdited(node.start, node.end); + + return; + } + } + + if (!advancedOptimizations) { + return; + } + + // 1. Elide Angular Metadata check + let calleeName: string | undefined; + if (node.callee.type === 'Identifier') { + calleeName = node.callee.name; + } else if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' + ) { + calleeName = node.callee.property.name; + } + + if (calleeName && angularMetadataFunctions.has(calleeName)) { + if ( + parentFunc && + (parentFunc.type === 'FunctionExpression' || + parentFunc.type === 'ArrowFunctionExpression') + ) { + source.overwrite(node.start, node.end, 'void 0'); + markEdited(node.start, node.end); + + return; + } + } + + // 2. Mark Top-Level Pure Functions check + if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) { + return; + } + + const callee = unwrapParentheses(node.callee); + if ( + (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') && + node.arguments.length !== 0 + ) { + return; + } + + if ( + callee.type === 'Identifier' && + (isTslibHelperName(callee.name) || isBabelHelperName(callee.name)) + ) { + return; + } + + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + break; + } + + case 'NewExpression': { + if ( + !advancedOptimizations || + !pureAnnotate || + functionDepth > 0 || + classDepth > 0 || + isAlreadyEdited(node.start, node.end) + ) { + return; + } + + if (!topLevelSafeMode) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + + return; + } + + const newCallee = node.callee; + if (newCallee.type === 'Identifier' && sideEffectFreeConstructors.has(newCallee.name)) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + } + break; + } + } + }); + + let map: DecodedSourceMap | undefined; + if (options.sourcemap) { + const rawMap = source.generateDecodedMap({ hires: 'boundary', source: filename }); + map = { ...rawMap, version: 3 }; + } + + return { + code: source.toString(), + map, + }; +} diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts new file mode 100644 index 000000000000..d1b02f6a1bdc --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { transform } from './oxc-transform'; + +describe('oxc-transform', () => { + describe('sourcemaps', () => { + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { + const input = 'var result = new SomeClass();'; + const result = transform('test.js', input, { sourcemap: true }); + + expect(result.map).toBeDefined(); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + expect(result.map?.mappings.length).toBeGreaterThan(0); + }); + }); + + describe('linking and unified passes', () => { + const componentInput = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + it('should link partial component declarations when link option is enabled', () => { + const result = transform('test.js', componentInput, { link: true }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should not link partial component declarations when link option is disabled', () => { + const result = transform('test.js', componentInput, { link: false }); + expect(result.code).not.toContain('i0.ɵɵdefineComponent'); + expect(result.code).toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should perform linking and advanced optimizations simultaneously in a single pass', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent { + static create() { + return new MyComponent(); + } + } + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + const result = transform('test.js', input, { + link: true, + advancedOptimizations: true, + topLevelSafeMode: true, + }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + expect(result.code).toContain('let MyComponent = /*#__PURE__*/ (() => {'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts new file mode 100644 index 000000000000..0b91cc98aa45 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { transform } from './oxc-transform'; + +function cleanCode(code: string): string { + return code + .replace(/\s+/g, '') + .replace(/"/g, "'") + .replace(/;}/g, '}') + .replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, + options, +}: { + input: string; + expected: string; + options?: { topLevelSafeMode: boolean }; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + topLevelSafeMode: options?.topLevelSafeMode, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +function testCaseNoChange(input: string): jasmine.ImplementationCallback { + return testCase({ input, expected: input }); +} + +describe('pure-toplevel-functions oxc-transform implementation', () => { + it( + 'annotates top-level new expressions', + testCase({ + input: 'var result = new SomeClass();', + expected: 'var result = /*#__PURE__*/ new SomeClass();', + }), + ); + + it( + 'annotates top-level function calls', + testCase({ + input: 'var result = someCall();', + expected: 'var result = /*#__PURE__*/ someCall();', + }), + ); + + it( + 'annotates top-level IIFE assignments with no arguments', + testCase({ + input: 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })();', + expected: + 'var SomeClass = /*#__PURE__*/(function () { function SomeClass() { } return SomeClass; })();', + }), + ); + + it( + 'annotates top-level arrow-function-based IIFE assignments with no arguments', + testCase({ + input: 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })();', + expected: + 'var SomeClass = /*#__PURE__*/(() => { function SomeClass() { } return SomeClass; })();', + }), + ); + + it( + 'does not annotate top-level IIFE assignments with arguments', + testCaseNoChange( + 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })(abc);', + ), + ); + + it( + 'does not annotate top-level arrow-function-based IIFE assignments with arguments', + testCaseNoChange( + 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })(abc);', + ), + ); + + it( + 'does not annotate call expressions inside function declarations', + testCaseNoChange('function funcDecl() { const result = someFunction(); }'), + ); + + it( + 'does not annotate call expressions inside function expressions', + testCaseNoChange('const foo = function funcDecl() { const result = someFunction(); }'), + ); + + it( + 'does not annotate call expressions inside arrow functions', + testCaseNoChange('const foo = () => { const result = someFunction(); }'), + ); + + it( + 'does not annotate new expressions inside function declarations', + testCaseNoChange('function funcDecl() { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate new expressions inside function expressions', + testCaseNoChange('const foo = function funcDecl() { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate new expressions inside arrow functions', + testCaseNoChange('const foo = () => { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate TypeScript helper functions (tslib)', + testCaseNoChange(` + class LanguageState {} + __decorate([ + __metadata("design:type", Function), + __metadata("design:paramtypes", [Object]), + __metadata("design:returntype", void 0) + ], LanguageState.prototype, "checkLanguage", null); + `), + ); + + it( + 'does not annotate _defineProperty function', + testCaseNoChange(` + class LanguageState {} + _defineProperty( + LanguageState, + 'property', + 'value' + ); + `), + ); + + it( + 'does not annotate object literal methods', + testCaseNoChange(` + const literal = { + method() { + var newClazz = new Clazz(); + } + }; + `), + ); + + it( + 'annotates helper functions with non-numeric suffixes', + testCase({ + input: 'var result = __decorate$foo();', + expected: 'var result = /*#__PURE__*/ __decorate$foo();', + }), + ); + + it( + 'does not annotate helper functions with numeric suffixes', + testCaseNoChange('var result = __decorate$1();'), + ); + + describe('topLevelSafeMode: true', () => { + it( + 'annotates top-level `new InjectionToken` expressions', + testCase({ + input: `const result = new InjectionToken('abc');`, + expected: `const result = /*#__PURE__*/ new InjectionToken('abc');`, + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate other top-level `new` expressions', + testCase({ + input: 'const result = new SomeClass();', + expected: 'const result = new SomeClass();', + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate top-level function calls', + testCase({ + input: 'const result = someCall();', + expected: 'const result = someCall();', + options: { topLevelSafeMode: true }, + }), + ); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/traversal.ts b/packages/angular/build/src/tools/oxc/traversal.ts new file mode 100644 index 000000000000..f25f4bc78664 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/traversal.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Node } from '@oxc-project/types'; +import { visitorKeys } from 'oxc-parser'; + +/** + * Contextual scope information provided to AST visitor callbacks during traversal. + */ +export interface TraversalContext { + /** + * The current function nesting depth. Top-level code has depth 0. + */ + functionDepth: number; + + /** + * The current class nesting depth. Top-level code has depth 0. + */ + classDepth: number; + + /** + * The immediate enclosing function AST node, if currently inside a function. + */ + parentFunc?: Node; +} + +/** + * A set of TypeScript AST node types that contain executable code or runtime declarations. + */ +const executableTypeScriptNodes = new Set([ + 'TSEnumDeclaration', + 'TSEnumBody', + 'TSEnumMember', + 'TSModuleDeclaration', + 'TSModuleBlock', + 'TSParameterProperty', + 'TSImportEqualsDeclaration', + 'TSExternalModuleReference', +]); + +/** + * Determines whether the given AST node type represents a function declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a function; otherwise, false. + */ +function isFunction(type: Node['type']): boolean { + return ( + type === 'FunctionDeclaration' || + type === 'FunctionExpression' || + type === 'ArrowFunctionExpression' + ); +} + +/** + * Determines whether the given AST node type represents a class declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a class; otherwise, false. + */ +function isClass(type: Node['type']): boolean { + return type === 'ClassDeclaration' || type === 'ClassExpression'; +} + +/** + * Determines whether the given AST node type represents a non-executable TypeScript type-only node. + * Executable TypeScript nodes (e.g. enums, namespaces, parameter properties) return false. + * + * @param type The AST node type to check. + * @returns True if the type is a TypeScript type construct that can be safely pruned; otherwise, false. + */ +function isTypeOnlyTypeScriptNode(type: Node['type']): boolean { + return type.startsWith('TS') && !executableTypeScriptNodes.has(type); +} + +/** + * Pushes an array of AST nodes onto the traversal stack in reverse order. + * + * @param nodes The array of child AST nodes to push. + * @param stack The traversal stack. + */ +function pushNodesReverse( + nodes: readonly (Node | null | undefined)[] | undefined, + stack: (Node | null)[], +): void { + if (nodes) { + for (let i = nodes.length - 1; i >= 0; i--) { + const item = nodes[i]; + if (item) { + stack.push(item); + } + } + } +} + +/** + * Pushes child AST nodes of the specified node onto the traversal stack in reverse order + * so they are visited in source order. + * + * Explicit cases are provided for the most common AST node types (~95%+ of AST nodes) to: + * 1. Fast-path leaf nodes and frequent expressions/statements by avoiding dynamic `visitorKeys` + * dictionary lookups, string indexing, and dynamic array iteration. + * 2. Prune non-executable TypeScript type subtrees (e.g. `typeAnnotation`, `typeParameters`, + * `returnType`, `implements`) so traversal only processes executable JavaScript expressions. + * + * All other less frequent node types fall back to the `visitorKeys` lookup in `default:`. + * + * @param node The parent AST node whose children to push. + * @param stack The traversal stack. + */ +function pushChildNodes(node: Node, stack: (Node | null)[]): void { + switch (node.type) { + // High-frequency leaf nodes (~50% of AST) + case 'Identifier': + case 'PrivateIdentifier': + case 'Literal': + case 'ThisExpression': + break; + + case 'Program': + case 'BlockStatement': + case 'ClassBody': + case 'StaticBlock': + pushNodesReverse(node.body, stack); + break; + + case 'ExpressionStatement': + case 'ParenthesizedExpression': + case 'ChainExpression': + case 'TSNonNullExpression': + case 'TSAsExpression': + case 'TSTypeAssertion': + case 'TSSatisfiesExpression': + case 'TSInstantiationExpression': + case 'TSExportAssignment': + if (node.expression) { + stack.push(node.expression); + } + break; + + case 'CallExpression': + case 'NewExpression': + pushNodesReverse(node.arguments, stack); + if (node.callee) { + stack.push(node.callee); + } + break; + + case 'MemberExpression': + if (node.property) { + stack.push(node.property); + } + if (node.object) { + stack.push(node.object); + } + break; + + case 'BinaryExpression': + case 'LogicalExpression': + case 'AssignmentExpression': + if (node.right) { + stack.push(node.right); + } + if (node.left) { + stack.push(node.left); + } + break; + + case 'UnaryExpression': + case 'UpdateExpression': + case 'AwaitExpression': + case 'YieldExpression': + case 'ReturnStatement': + case 'ThrowStatement': + if (node.argument) { + stack.push(node.argument); + } + break; + + case 'IfStatement': + case 'ConditionalExpression': + if (node.alternate) { + stack.push(node.alternate); + } + if (node.consequent) { + stack.push(node.consequent); + } + if (node.test) { + stack.push(node.test); + } + break; + + case 'VariableDeclaration': + pushNodesReverse(node.declarations, stack); + break; + + case 'VariableDeclarator': + if (node.init) { + stack.push(node.init); + } + if (node.id) { + stack.push(node.id); + } + break; + + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + if (node.body) { + stack.push(node.body); + } + pushNodesReverse(node.params, stack); + if (node.id) { + stack.push(node.id); + } + break; + + case 'ClassDeclaration': + case 'ClassExpression': + if (node.body) { + stack.push(node.body); + } + if (node.superClass) { + stack.push(node.superClass); + } + if (node.id) { + stack.push(node.id); + } + pushNodesReverse(node.decorators, stack); + break; + + case 'Property': + case 'PropertyDefinition': + case 'MethodDefinition': + if (node.value) { + stack.push(node.value); + } + if (node.key) { + stack.push(node.key); + } + if (node.type !== 'Property') { + pushNodesReverse(node.decorators, stack); + } + break; + + case 'ObjectExpression': + pushNodesReverse(node.properties, stack); + break; + + case 'ArrayExpression': + pushNodesReverse(node.elements, stack); + break; + + case 'TemplateLiteral': + pushNodesReverse(node.expressions, stack); + break; + + case 'TaggedTemplateExpression': + if (node.quasi) { + stack.push(node.quasi); + } + if (node.tag) { + stack.push(node.tag); + } + break; + + default: { + // Prune non-executable TypeScript type-only AST nodes (e.g. TSTypeAnnotation, TSTypeReference, etc.) + if (isTypeOnlyTypeScriptNode(node.type)) { + break; + } + + const keys = visitorKeys[node.type]; + if (!keys) { + break; + } + + for (let i = keys.length - 1; i >= 0; i--) { + const child = ( + node as unknown as Record + )[keys[i]]; + if (!child) { + continue; + } + + if (Array.isArray(child)) { + pushNodesReverse(child, stack); + } else { + stack.push(child); + } + } + break; + } + } +} + +/** + * Traverses ESTree AST nodes in post-order (bottom-up) without recursion. + * + * @param root The root AST node to traverse. + * @param visit Callback invoked on each AST node in post-order. + */ +export function traversePostOrder( + root: Node, + visit: (node: Node, context: TraversalContext) => void, +): void { + const stack: (Node | null)[] = [root]; + let functionDepth = 0; + let classDepth = 0; + const functionStack: Node[] = []; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + break; + } + if (current === null) { + const node = stack.pop(); + if (!node) { + continue; + } + const type = node.type; + if (isFunction(type)) { + functionDepth--; + functionStack.pop(); + } else if (isClass(type)) { + classDepth--; + } + visit(node, { + functionDepth, + classDepth, + parentFunc: functionStack[functionStack.length - 1], + }); + continue; + } + + stack.push(current); + stack.push(null); + const type = current.type; + if (isFunction(type)) { + functionDepth++; + functionStack.push(current); + } else if (isClass(type)) { + classDepth++; + } + + pushChildNodes(current, stack); + } +} diff --git a/packages/angular/build/src/tools/oxc/types.d.ts b/packages/angular/build/src/tools/oxc/types.d.ts new file mode 100644 index 000000000000..aa1f40580491 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/types.d.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +declare module 'istanbul-lib-instrument' { + export interface Instrumenter { + instrumentSync(code: string, filename: string, inputSourceMap?: object): string; + lastSourceMap(): object | undefined; + } + + export function createInstrumenter(options?: { + produceSourceMap?: boolean; + esModules?: boolean; + coverageVariable?: string; + }): Instrumenter; +} diff --git a/packages/angular/build/src/tools/sass/lexer.ts b/packages/angular/build/src/tools/sass/lexer.ts new file mode 100644 index 000000000000..0fc4f7d1e543 --- /dev/null +++ b/packages/angular/build/src/tools/sass/lexer.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +// TODO: Combine everything into a single pass lexer + +/** + * Determines if a unicode code point is a CSS whitespace character. + * @param code The unicode code point to test. + * @returns true, if the code point is CSS whitespace; false, otherwise. + */ +function isWhitespace(code: number): boolean { + // Based on https://www.w3.org/TR/css-syntax-3/#whitespace + switch (code) { + case 0x0009: // tab + case 0x0020: // space + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + return true; + default: + return false; + } +} + +/** + * Scans a CSS or Sass file and locates all valid url function values as defined by the + * syntax specification. + * @param contents A string containing a CSS or Sass file to scan. + * @returns An iterable that yields each CSS url function value found. + */ +export function* findUrls( + contents: string, +): Iterable<{ start: number; end: number; value: string }> { + let pos = 0; + let width = 1; + let current = -1; + const next = () => { + pos += width; + current = contents.codePointAt(pos) ?? -1; + width = current > 0xffff ? 2 : 1; + + return current; + }; + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token + while ((pos = contents.indexOf('url(', pos)) !== -1) { + width = 1; + + // Ensure whitespace, comma, or colon before `url(` + if (pos > 0) { + pos -= 2; + next(); + if (!isWhitespace(current) && current !== 0x002c && current !== 0x003a) { + // Skip - not a url token + pos += 3; + continue; + } + pos += 1; + } + + // Set to position of the ( + pos += 3; + + // Consume all leading whitespace + while (isWhitespace(next())) { + /* empty */ + } + + // Initialize URL state + const url = { start: pos, end: -1, value: '' }; + let complete = false; + + // If " or ', then consume the value as a string + if (current === 0x0022 || current === 0x0027) { + const ending = current; + // Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token + while (!complete) { + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Skip when inside a string + break; + default: + // TODO: Handle hex escape codes + url.value += String.fromCodePoint(current); + break; + } + break; + case ending: + // Full string position should include the quotes for replacement + url.end = pos + 1; + complete = true; + yield url; + break; + default: + url.value += String.fromCodePoint(current); + break; + } + } + + next(); + continue; + } + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-url-token + while (!complete) { + switch (current) { + case -1: // EOF + return; + case 0x0022: // " + case 0x0027: // ' + case 0x0028: // ( + // Invalid + complete = true; + break; + case 0x0029: // ) + // URL is valid and complete + url.end = pos; + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + default: + // TODO: Handle hex escape codes + url.value += String.fromCodePoint(current); + break; + } + break; + default: + if (isWhitespace(current)) { + while (isWhitespace(next())) { + /* empty */ + } + // Unescaped whitespace is only valid before the closing ) + if (current === 0x0029) { + // URL is valid + url.end = pos; + } + complete = true; + } else { + // Add the character to the url value + url.value += String.fromCodePoint(current); + } + break; + } + next(); + } + + // An end position indicates a URL was found + if (url.end !== -1) { + yield url; + } + } +} diff --git a/packages/angular/build/src/tools/sass/lexer_spec.ts b/packages/angular/build/src/tools/sass/lexer_spec.ts new file mode 100644 index 000000000000..151077af4c7f --- /dev/null +++ b/packages/angular/build/src/tools/sass/lexer_spec.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { findUrls } from './lexer'; + +describe('Sass URL lexer', () => { + it('should find url immediately after a colon, comma, or whitespace', () => { + const input = `.c{property:url("first"),url("second"), url("third");}`; + + const result = [...findUrls(input)]; + + expect(result).toEqual([ + { start: 16, end: 23, value: 'first' }, + { start: 29, end: 37, value: 'second' }, + { start: 44, end: 51, value: 'third' }, + ]); + }); + + it('should find full url with string containing url function', () => { + const input = `.c{property:url("url('abc')");}`; + + const result = [...findUrls(input)]; + + expect(result).toEqual([{ start: 16, end: 28, value: `url('abc')` }]); + }); +}); diff --git a/packages/angular/build/src/tools/sass/rebasing-importer.ts b/packages/angular/build/src/tools/sass/rebasing-importer.ts new file mode 100644 index 000000000000..ccc5fa3c4664 --- /dev/null +++ b/packages/angular/build/src/tools/sass/rebasing-importer.ts @@ -0,0 +1,396 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { DecodedSourceMap } from '@ampproject/remapping'; +import { MagicString } from 'magic-string'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { basename, dirname, extname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { CanonicalizeContext, Importer, ImporterResult, Syntax } from 'sass-embedded'; +import { assertIsError } from '../../utils/error'; +import { toPosixPath } from '../../utils/path'; +import { findUrls } from './lexer'; + +/** + * A preprocessed cache entry for the files and directories within a previously searched + * directory when performing Sass import resolution. + */ +export interface DirectoryEntry { + files: Set; + directories: Set; +} + +/** + * A Sass Importer base class that provides the load logic to rebase all `url()` functions + * within a stylesheet. The rebasing will ensure that the URLs in the output of the Sass compiler + * reflect the final filesystem location of the output CSS file. + * + * This class provides the core of the rebasing functionality. To ensure that each file is processed + * by this importer's load implementation, the Sass compiler requires the importer's canonicalize + * function to return a non-null value with the resolved location of the requested stylesheet. + * Concrete implementations of this class must provide this canonicalize functionality for rebasing + * to be effective. + */ +abstract class UrlRebasingImporter implements Importer<'sync'> { + /** + * @param entryDirectory The directory of the entry stylesheet that was passed to the Sass compiler. + * @param rebaseSourceMaps When provided, rebased files will have an intermediate sourcemap added to the Map + * which can be used to generate a final sourcemap that contains original sources. + */ + constructor( + private entryDirectory: string, + private rebaseSourceMaps?: Map, + ) { + this.load = this.load.bind(this); + } + + abstract canonicalize(url: string, options: { fromImport: boolean }): URL | null; + + load(canonicalUrl: URL): ImporterResult | null { + const stylesheetPath = fileURLToPath(canonicalUrl); + const stylesheetDirectory = dirname(stylesheetPath); + let contents = readFileSync(stylesheetPath, 'utf-8'); + + // Rebase any URLs that are found + let updatedContents; + for (const { start, end, value } of findUrls(contents)) { + // Skip if value is empty or Webpack-specific prefix + if (value.length === 0 || value[0] === '~' || value[0] === '^') { + continue; + } + + // Skip if root-relative, absolute or protocol relative url + if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) { + continue; + } + + // Skip if a fragment identifier but not a Sass interpolation + if (value[0] === '#' && value[1] !== '{') { + continue; + } + + // Skip if value is value contains a function call + if (/#\{.+\(.+\)\}/.test(value)) { + continue; + } + + // Sass variable usage either starts with a `$` or contains a namespace and a `.$` + const valueNormalized = + value[0] === '$' || /^\w[\w_-]*\.\$/.test(value) ? `#{${value}}` : value; + const rebasedPath = relative(this.entryDirectory, stylesheetDirectory); + + // Normalize path separators and escape characters + // https://developer.mozilla.org/en-US/docs/Web/CSS/url#syntax + const rebasedUrl = toPosixPath(rebasedPath).replace(/[()\s'"]/g, '\\$&'); + + updatedContents ??= new MagicString(contents); + // Always quote the URL to avoid potential downstream parsing problems + updatedContents.update(start, end, `"${rebasedUrl}||file:${valueNormalized}"`); + } + + if (updatedContents) { + contents = updatedContents.toString(); + if (this.rebaseSourceMaps) { + // Generate an intermediate source map for the rebasing changes + const map = updatedContents.generateDecodedMap({ + hires: 'boundary', + includeContent: true, + source: canonicalUrl.href, + }); + this.rebaseSourceMaps.set(canonicalUrl.href, { + ...map, + version: 3, + } satisfies DecodedSourceMap); + } + } + + let syntax: Syntax | undefined; + switch (extname(stylesheetPath).toLowerCase()) { + case '.css': + syntax = 'css'; + break; + case '.sass': + syntax = 'indented'; + break; + default: + syntax = 'scss'; + break; + } + + return { + contents, + syntax, + sourceMapUrl: canonicalUrl, + }; + } +} + +/** + * Provides the Sass importer logic to resolve relative stylesheet imports via both import and use rules + * and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that + * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. + */ +export class RelativeUrlRebasingImporter extends UrlRebasingImporter { + constructor( + entryDirectory: string, + private directoryCache = new Map(), + rebaseSourceMaps?: Map, + ) { + super(entryDirectory, rebaseSourceMaps); + this.canonicalize = this.canonicalize.bind(this); + } + + canonicalize(url: string, options: { fromImport: boolean }): URL | null { + return this.resolveImport(url, options.fromImport, true); + } + + /** + * Attempts to resolve a provided URL to a stylesheet file using the Sass compiler's resolution algorithm. + * Based on https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart + * @param url The file protocol URL to resolve. + * @param fromImport If true, URL was from an import rule; otherwise from a use rule. + * @param checkDirectory If true, try checking for a directory with the base name containing an index file. + * @returns A full resolved URL of the stylesheet file or `null` if not found. + */ + private resolveImport(url: string, fromImport: boolean, checkDirectory: boolean): URL | null { + let stylesheetPath; + try { + stylesheetPath = fileURLToPath(url); + } catch { + // Only file protocol URLs are supported by this importer + return null; + } + + const directory = dirname(stylesheetPath); + const extension = extname(stylesheetPath); + const hasStyleExtension = + extension === '.scss' || extension === '.sass' || extension === '.css'; + // Remove the style extension if present to allow adding the `.import` suffix + const filename = basename(stylesheetPath, hasStyleExtension ? extension : undefined); + + const importPotentials = new Set(); + const defaultPotentials = new Set(); + + if (hasStyleExtension) { + if (fromImport) { + importPotentials.add(filename + '.import' + extension); + importPotentials.add('_' + filename + '.import' + extension); + } + defaultPotentials.add(filename + extension); + defaultPotentials.add('_' + filename + extension); + } else { + if (fromImport) { + importPotentials.add(filename + '.import.scss'); + importPotentials.add(filename + '.import.sass'); + importPotentials.add(filename + '.import.css'); + importPotentials.add('_' + filename + '.import.scss'); + importPotentials.add('_' + filename + '.import.sass'); + importPotentials.add('_' + filename + '.import.css'); + } + defaultPotentials.add(filename + '.scss'); + defaultPotentials.add(filename + '.sass'); + defaultPotentials.add(filename + '.css'); + defaultPotentials.add('_' + filename + '.scss'); + defaultPotentials.add('_' + filename + '.sass'); + defaultPotentials.add('_' + filename + '.css'); + } + + let foundDefaults; + let foundImports; + let hasPotentialIndex = false; + + let cachedEntries = this.directoryCache.get(directory); + if (cachedEntries) { + // If there is a preprocessed cache of the directory, perform an intersection of the potentials + // and the directory files. + const { files, directories } = cachedEntries; + foundDefaults = [...defaultPotentials.intersection(files)]; + foundImports = [...importPotentials.intersection(files)]; + hasPotentialIndex = checkDirectory && !hasStyleExtension && directories.has(filename); + } else { + // If no preprocessed cache exists, get the entries from the file system and, while searching, + // generate the cache for later requests. + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch (error) { + assertIsError(error); + // If the containing directory does not exist return null to indicate it cannot be resolved + if (error.code === 'ENOENT') { + return null; + } + + throw new Error(`Error reading directory ["${directory}"] while resolving Sass import`, { + cause: error, + }); + } + + foundDefaults = []; + foundImports = []; + cachedEntries = { files: new Set(), directories: new Set() }; + for (const entry of entries) { + let isDirectory: boolean; + let isFile: boolean; + + if (entry.isSymbolicLink()) { + const stats = statSync(join(directory, entry.name)); + isDirectory = stats.isDirectory(); + isFile = stats.isFile(); + } else { + isDirectory = entry.isDirectory(); + isFile = entry.isFile(); + } + + if (isDirectory) { + cachedEntries.directories.add(entry.name); + + // Record if the name should be checked as a directory with an index file + if (checkDirectory && !hasStyleExtension && entry.name === filename) { + hasPotentialIndex = true; + } + } + + if (!isFile) { + continue; + } + + cachedEntries.files.add(entry.name); + + if (importPotentials.has(entry.name)) { + foundImports.push(entry.name); + } + + if (defaultPotentials.has(entry.name)) { + foundDefaults.push(entry.name); + } + } + + this.directoryCache.set(directory, cachedEntries); + } + + // `foundImports` will only contain elements if `options.fromImport` is true + const result = this.checkFound(foundImports) ?? this.checkFound(foundDefaults); + if (result !== null) { + return pathToFileURL(join(directory, result)); + } + + if (hasPotentialIndex) { + // Check for index files using filename as a directory + return this.resolveImport(url + '/index', fromImport, false); + } + + return null; + } + + /** + * Checks an array of potential stylesheet files to determine if there is a valid + * stylesheet file. More than one discovered file may indicate an error. + * @param found An array of discovered stylesheet files. + * @returns A fully resolved path for a stylesheet file or `null` if not found. + * @throws If there are ambiguous files discovered. + */ + private checkFound(found: string[]): string | null { + if (found.length === 0) { + // Not found + return null; + } + + // More than one found file may be an error + if (found.length > 1) { + // Presence of CSS files alongside a Sass file does not cause an error + const foundWithoutCss = found.filter((element) => extname(element) !== '.css'); + // If the length is zero then there are two or more css files + // If the length is more than one than there are two or more sass/scss files + if (foundWithoutCss.length !== 1) { + throw new Error('Ambiguous import detected.'); + } + + // Return the non-CSS file (sass/scss files have priority) + // https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart#L44-L47 + return foundWithoutCss[0]; + } + + return found[0]; + } +} + +/** + * Provides the Sass importer logic to resolve module (npm package) stylesheet imports asynchronously + * and also rebase any `url()` function usage within those stylesheets. + */ +export class AsyncModuleUrlRebasingImporter implements Importer<'async'> { + private relativeImporter: RelativeUrlRebasingImporter; + + constructor( + entryDirectory: string, + directoryCache: Map, + rebaseSourceMaps: Map | undefined, + private finder: ( + specifier: string, + options: CanonicalizeContext, + ) => Promise | URL | null, + ) { + this.relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + this.canonicalize = this.canonicalize.bind(this); + this.load = this.load.bind(this); + } + + canonicalize(url: string, options: CanonicalizeContext): Promise | URL | null { + if (url.startsWith('file://')) { + return this.relativeImporter.canonicalize(url, options); + } + + // Use `Promise.resolve` instead of an `async` method so the returned promise is an + // instance of the current `globalThis.Promise` (e.g., `ZoneAwarePromise` when Zone.js + // is loaded during SSR). `sass-embedded` checks `result instanceof Promise`, which fails + // for native `async` function return values when `globalThis.Promise` has been replaced. + return Promise.resolve(this.finder(url, options)).then((result) => + result ? this.relativeImporter.canonicalize(result.href, options) : null, + ); + } + + load(canonicalUrl: URL): ImporterResult | null { + return this.relativeImporter.load(canonicalUrl); + } +} + +/** + * Provides the Sass importer logic to resolve load paths located stylesheet imports via both import and + * use rules and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that + * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. + */ +export class LoadPathsUrlRebasingImporter extends RelativeUrlRebasingImporter { + constructor( + entryDirectory: string, + directoryCache: Map, + rebaseSourceMaps: Map | undefined, + private loadPaths: Iterable, + ) { + super(entryDirectory, directoryCache, rebaseSourceMaps); + } + + override canonicalize(url: string, options: { fromImport: boolean }): URL | null { + if (url.startsWith('file://')) { + return super.canonicalize(url, options); + } + + let result = null; + for (const loadPath of this.loadPaths) { + result = super.canonicalize(pathToFileURL(join(loadPath, url)).href, options); + if (result !== null) { + break; + } + } + + return result; + } +} diff --git a/packages/angular/build/src/tools/sass/sass-service.ts b/packages/angular/build/src/tools/sass/sass-service.ts new file mode 100644 index 000000000000..8ce65dd7e2f2 --- /dev/null +++ b/packages/angular/build/src/tools/sass/sass-service.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + AsyncCompiler, + CanonicalizeContext, + CompileResult, + FileImporter, + Importer, + NodePackageImporter, + StringOptions, +} from 'sass-embedded'; +import { useSassEmbedded } from '../../utils/environment-options'; +import { + AsyncModuleUrlRebasingImporter, + DirectoryEntry, + LoadPathsUrlRebasingImporter, + RelativeUrlRebasingImporter, +} from './rebasing-importer'; + +type Importers = + | Importer<'sync'> + | Importer<'async'> + | FileImporter<'sync'> + | FileImporter<'async'> + | NodePackageImporter; + +function isFileImporter(value: Importers): value is FileImporter { + return 'findFileUrl' in value; +} + +/** + * A Sass renderer implementation that uses the persistent Dart Sass embedded compiler + * daemon (`sass-embedded`) communicating over standard input/output with protocol buffers, + * or falls back to the pure-JS Dart Sass async compiler (`sass.initAsyncCompiler()`). + */ +export class SassCompiler { + #asyncCompiler: AsyncCompiler | undefined; + #asyncCompilerPromise: Promise | undefined; + readonly #directoryCache = new Map(); + + constructor(private readonly rebase = false) {} + + async #createAsyncCompiler(): Promise { + if (useSassEmbedded) { + const { initAsyncCompiler } = await import('sass-embedded'); + + return initAsyncCompiler(); + } + + const { initAsyncCompiler } = await import('sass'); + + return initAsyncCompiler() as unknown as Promise; + } + + async #ensureAsyncCompiler(): Promise { + if (this.#asyncCompiler) { + return this.#asyncCompiler; + } + + this.#asyncCompilerPromise ??= this.#createAsyncCompiler(); + + try { + this.#asyncCompiler = await this.#asyncCompilerPromise; + } finally { + this.#asyncCompilerPromise = undefined; + } + + return this.#asyncCompiler; + } + + /** + * Provides information about the Sass implementation. + * This mimics enough of the `sass-embedded` or `sass` value to be used with the `sass-loader`. + */ + get info(): string { + return useSassEmbedded ? 'sass-embedded\tasync-compiler' : 'dart-sass\tasync-compiler'; + } + + /** + * The synchronous render function is not used by the `sass-loader`. + */ + compileString(): never { + throw new Error('Sass compileString is not supported.'); + } + + /** + * Asynchronously request a Sass stylesheet to be rendered using the native embedded compiler + * or the pure-JS async compiler fallback. + * + * @param source The contents to compile. + * @param options The `sass` / `sass-embedded` options to use when rendering the stylesheet. + */ + async compileStringAsync( + source: string, + options: StringOptions<'async'>, + ): Promise { + // The CLI's configuration does not use or expose the ability to define custom Sass functions + if (options.functions && Object.keys(options.functions).length > 0) { + throw new Error('Sass custom functions are not supported.'); + } + + const compiler = await this.#ensureAsyncCompiler(); + + if (!this.rebase) { + return compiler.compileStringAsync(source, options); + } + + const { functions, importers, importer, url, logger, ...serializableOptions } = options; + + let finalImporters: + (Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined; + let loadPaths = options.loadPaths; + const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd(); + const directoryCache = this.#directoryCache; + const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; + + if (importers?.length) { + if (importers.some((i) => !isFileImporter(i))) { + throw new Error('Only File Importers are supported.'); + } + + finalImporters = [ + new AsyncModuleUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + async (specifier: string, options: CanonicalizeContext): Promise => { + for (const importer of importers) { + const result = await (importer as FileImporter<'async'>).findFileUrl( + specifier, + options, + ); + if (result) { + return result; + } + } + + return null; + }, + ), + ]; + } + + if (loadPaths?.length) { + finalImporters ??= []; + finalImporters.push( + new LoadPathsUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + loadPaths, + ), + ); + loadPaths = undefined; + } + + const relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + + const result = await compiler.compileStringAsync(source, { + ...serializableOptions, + url, + loadPaths, + importers: finalImporters, + importer: relativeImporter, + logger, + }); + + if (result.sourceMap && rebaseSourceMaps?.size) { + result.sourceMap = mergeSourceMaps( + result.sourceMap as unknown as RawSourceMap, + (file, context) => (file !== context.importer ? rebaseSourceMaps.get(file) : null), + ) as unknown as typeof result.sourceMap; + } + + return result; + } + + /** + * Clear the directory cache. + */ + clearCache(): void { + this.#directoryCache.clear(); + } + + /** + * Shutdown the Sass compiler. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + this.clearCache(); + + if (this.#asyncCompilerPromise) { + try { + await this.#ensureAsyncCompiler(); + } catch { + // Ignore compiler initialization failures on shutdown + } + } + + if (this.#asyncCompiler) { + const compiler = this.#asyncCompiler; + this.#asyncCompiler = undefined; + await compiler.dispose(); + } + } +} diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts new file mode 100644 index 000000000000..a1aa19361c5c --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -0,0 +1,261 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFileSync } from 'node:fs'; +import type { ServerResponse } from 'node:http'; +import { extname } from 'node:path'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { ResultFile } from '../../../builders/application/results'; +import { calculateHash } from '../../../utils/hash'; +import { AngularMemoryOutputFiles, AngularOutputAssets, pathnameWithoutBasePath } from '../utils'; + +export interface ComponentStyleRecord { + rawContent: Uint8Array; + used?: Set; + reload?: boolean; +} + +const CSS_PREPROCESSOR_REGEXP = /\.(?:s[ac]ss|less|css)$/; +const JS_TS_REGEXP = /\.[cm]?[tj]sx?$/; + +export function createAngularAssetsMiddleware( + server: ViteDevServer, + assets: AngularOutputAssets, + outputFiles: AngularMemoryOutputFiles, + componentStyles: Map, + encapsulateStyle: (style: Uint8Array, componentId: string) => string, +): Connect.NextHandleFunction { + return function angularAssetsMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + // Parse the incoming request. + // The base of the URL is unused but required to parse the URL. + const pathname = pathnameWithoutBasePath(req.url, server.config.base); + const extension = extname(pathname); + const pathnameHasTrailingSlash = pathname.at(-1) === '/'; + + // Rewrite all build assets to a vite raw fs URL + const asset = assets.get(pathname); + if (asset) { + // This is a workaround to serve extensionless, CSS, JS and TS files without Vite transformations. + if (!extension || JS_TS_REGEXP.test(extension) || CSS_PREPROCESSOR_REGEXP.test(extension)) { + const contents = readFileSync(asset.source); + const etag = `W/${calculateHash(contents)}`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + const mimeType = lookupMimeType(extension); + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + + res.setHeader('ETag', etag); + res.setHeader('Cache-Control', 'no-cache'); + res.end(contents); + } else { + // The encoding needs to match what happens in the vite static middleware. + // ref: https://github.com/vitejs/vite/blob/d4f13bd81468961c8c926438e815ab6b1c82735e/packages/vite/src/node/server/middlewares/static.ts#L163 + req.url = `${server.config.base}@fs/${encodeURI(asset.source)}`; + next(); + } + + return; + } + + // HTML fallbacking + // This matches what happens in the vite html fallback middleware. + // ref: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L9 + const htmlAssetSourcePath = pathnameHasTrailingSlash + ? // Trailing slash check for `index.html`. + assets.get(pathname + 'index.html') + : // Non-trailing slash check for fallback `.html` + assets.get(pathname + '.html'); + + if (htmlAssetSourcePath) { + req.url = `${server.config.base}@fs/${encodeURI(htmlAssetSourcePath.source)}`; + next(); + + return; + } + + // Support HTTP HEAD requests for the virtual output files from the Angular build + if (req.method === 'HEAD' && outputFiles.get(pathname)?.servable) { + // While a GET will also generate content, the rest of the response is equivalent + req.method = 'GET'; + } + + // Resource files are handled directly. + // Global stylesheets (CSS files) are currently considered resources to workaround + // dev server sourcemap issues with stylesheets. + if (extension !== '.js' && pathname !== '/index.html' && pathname !== '/index.csr.html') { + const outputFile = outputFiles.get(pathname); + if (outputFile?.servable) { + let data: Uint8Array | string = outputFile.contents; + const componentStyle = componentStyles.get(pathname); + if (componentStyle) { + // Inject component ID for view encapsulation if requested + const searchParams = new URL(req.url, 'http://localhost').searchParams; + const componentId = searchParams.get('ngcomp'); + if (componentId !== null) { + // Track if the component uses ShadowDOM encapsulation (3 = ViewEncapsulation.ShadowDom) + // Shadow DOM components currently require a full reload. + // Vite's CSS hot replacement does not support shadow root searching. + if (searchParams.get('e') === '3') { + componentStyle.reload = true; + } + + // Record the component style usage for HMR updates + if (componentStyle.used === undefined) { + componentStyle.used = new Set([componentId]); + } else { + componentStyle.used.add(componentId); + } + + const etag = `W/"${outputFile.contents.byteLength}-${outputFile.hash}-${componentId}"`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + // Shim the stylesheet if a component ID is provided + if (componentId.length > 0) { + // Validate component ID + if (!/^[_.\-\p{Letter}\d]+-c\d+$/u.test(componentId)) { + const message = 'Invalid component stylesheet ID request: ' + componentId; + // eslint-disable-next-line no-console + console.error(message); + res.statusCode = 400; + res.end(message); + + return; + } + + data = encapsulateStyle(data, componentId); + } + + res.setHeader('Content-Type', 'text/css'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('ETag', etag); + res.end(data); + + return; + } + } + + const etag = `W/"${outputFile.contents.byteLength}-${outputFile.hash}"`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + const mimeType = lookupMimeType(extension); + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('ETag', etag); + res.end(data); + + return; + } + } + + // If the path has no trailing slash and it matches a servable directory redirect to the same path with slash. + // This matches the default express static behaviour. + // See: https://github.com/expressjs/serve-static/blob/89fc94567fae632718a2157206c52654680e9d01/index.js#L182 + if (!pathnameHasTrailingSlash) { + for (const assetPath of assets.keys()) { + if (pathname === assetPath.substring(0, assetPath.lastIndexOf('/'))) { + const { pathname, search, hash } = new URL(req.url, 'http://localhost'); + const location = [pathname, '/', search, hash].join(''); + + res.statusCode = 301; + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Location', location); + res.end(` + + + + + Redirecting + + +
Redirecting to ${location}
+ + + `); + + return; + } + } + } + + next(); + }; +} + +function checkAndHandleEtag( + req: Connect.IncomingMessage, + res: ServerResponse, + etag: string, +): boolean { + if (req.headers['if-none-match'] === etag) { + res.statusCode = 304; + res.end(); + + return true; + } + + return false; +} + +export function createBuildAssetsMiddleware( + basePath: string, + buildResultFiles: ReadonlyMap, + readHandler: (path: string) => Buffer = readFileSync, +): Connect.NextHandleFunction { + return function buildAssetsMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + // Parse the incoming request. + // The base of the URL is unused but required to parse the URL. + const pathname = pathnameWithoutBasePath(req.url, basePath); + const extension = extname(pathname); + if (extension && !/\.[mc]?[jt]s(?:\.map)?$/.test(extension)) { + const outputFile = buildResultFiles.get(pathname.slice(1)); + if (outputFile) { + const contents = + outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.source); + + const etag = `W/${calculateHash(contents)}`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + const mimeType = lookupMimeType(extension); + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + + res.setHeader('ETag', etag); + res.setHeader('Cache-Control', 'no-cache'); + res.end(contents); + + return; + } + } + + next(); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts new file mode 100644 index 000000000000..a9df78fd2981 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; +import { addLeadingSlash } from '../../../utils/url'; + +/** + * Patches the Vite base middleware to correctly handle the Angular application's base href. + * This is necessary because Vite's default base middleware might not align with Angular's + * expected path handling when using SSR, especially when a base href is configured. + * + * @param middlewares The Connect server instance containing the middleware stack. + * @param base The base URL path to be handled by the middleware. + */ +export function patchBaseMiddleware(middlewares: Connect.Server, base: string): void { + const entry = middlewares.stack.find( + ({ handle }) => typeof handle === 'function' && handle.name.startsWith('viteBaseMiddleware'), + ); + + if (typeof entry?.handle !== 'function') { + return; + } + + entry.handle = function angularBaseMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, + ) { + const url = req.url || '/'; + if (url.startsWith(base)) { + // Rewrite the URL to remove the base prefix before passing it to the next middleware. + // If the URL is exactly the base, it becomes '/'. + // Otherwise, we slice off the base and ensure there's a leading slash. + // See: https://github.com/vitejs/vite/blob/e81c183f8c8ccaf7774ef0d0ee125bf63dbf30b4/packages/vite/src/node/server/middlewares/base.ts#L12 + req.url = url === base ? '/' : addLeadingSlash(url.slice(base.length - 1)); + + return next(); + } + + const { pathname, hash, search } = new URL(url, 'http://localhost'); + if (pathname === '/' || pathname === '/index.html') { + res.writeHead(302, { Location: `${base}${search}${hash}` }); + res.end(); + + return; + } + + next(); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts new file mode 100644 index 000000000000..fdeff2eec705 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; + +type DevToolsJson = { + workspace: { + root: string; + uuid: string; + }; +}; + +const CHROME_DEVTOOLS_ROUTE = '/.well-known/appspecific/com.chrome.devtools.json'; + +export function createChromeDevtoolsMiddleware( + cacheDir: string, + projectRoot: string, +): Connect.NextHandleFunction { + let devtoolsConfig: string | undefined; + const devtoolsConfigPath = join(cacheDir, 'com.chrome.devtools.json'); + + return function chromeDevtoolsMiddleware(req, res, next) { + if (req.url !== CHROME_DEVTOOLS_ROUTE) { + next(); + + return; + } + + if (!devtoolsConfig) { + // We store the UUID and re-use it to ensure Chrome does not repeatedly ask for permissions when restarting the dev server. + try { + devtoolsConfig = readFileSync(devtoolsConfigPath, 'utf-8'); + + const devtoolsConfigJson: DevToolsJson = JSON.parse(devtoolsConfig); + assert.equal(projectRoot, devtoolsConfigJson?.workspace.root); + } catch { + const devtoolsConfigJson: DevToolsJson = { + workspace: { + root: projectRoot, + uuid: randomUUID(), + }, + }; + + devtoolsConfig = JSON.stringify(devtoolsConfigJson, undefined, 2); + + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(devtoolsConfigPath, devtoolsConfig); + } catch {} + } + } + + res.setHeader('Content-Type', 'application/json'); + res.end(devtoolsConfig); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts new file mode 100644 index 000000000000..29360d78af55 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { pathnameWithoutBasePath } from '../utils'; + +const ANGULAR_COMPONENT_PREFIX = '/@ng/component'; + +export function createAngularComponentMiddleware( + server: ViteDevServer, + templateUpdates: ReadonlyMap, +): Connect.NextHandleFunction { + return function angularComponentMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + const pathname = pathnameWithoutBasePath(req.url, server.config.base); + if (!pathname.includes(ANGULAR_COMPONENT_PREFIX)) { + next(); + + return; + } + + const requestUrl = new URL(req.url, 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + if (!componentId) { + res.statusCode = 400; + res.end(); + + return; + } + + const updateCode = templateUpdates.get(encodeURIComponent(componentId)) ?? ''; + + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(updateCode); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts new file mode 100644 index 000000000000..c7f9408cec4a --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ServerResponse } from 'node:http'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; + +/** + * Creates a middleware for adding custom headers. + * + * This middleware is responsible for setting HTTP headers as configured in the Vite server options. + * If headers are defined in the server configuration, they are applied to the server response. + * + * @param server - The instance of `ViteDevServer` containing the configuration, including custom headers. + * @returns A middleware function that processes the incoming request, sets headers if available, + * and passes control to the next middleware in the chain. + */ +export function createAngularHeadersMiddleware(server: ViteDevServer): Connect.NextHandleFunction { + return function angularHeadersMiddleware( + _req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + const headers = server.config.server.headers; + if (!headers) { + return next(); + } + + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + res.setHeader(name, value); + } + } + + next(); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts new file mode 100644 index 000000000000..a4d49f4a9eeb --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; + +export function patchHostValidationMiddleware(middlewares: Connect.Server): void { + const entry = middlewares.stack.find( + ({ handle }) => + typeof handle === 'function' && handle.name.startsWith('hostValidationMiddleware'), + ); + + if (typeof entry?.handle !== 'function') { + return; + } + + const originalHandle = entry.handle as Connect.NextHandleFunction; + + entry.handle = function angularHostValidationMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, + ) { + originalHandle( + req, + { + writeHead: (code) => { + res.writeHead(code, { 'content-type': 'text/html' }); + }, + end: () => { + const hostname = req.headers.host?.toLowerCase().split(':')[0] ?? ''; + res.end(html403(hostname)); + }, + } as ServerResponse, + next, + ); + }; +} + +function html403(hostname: string): string { + return ` + + + + + Blocked request + + + +
+

Blocked request. This host ("${hostname}") is not allowed.

+

To allow this host, add it to allowedHosts under the serve target in angular.json.

+
{
+  "serve": {
+    "options": {
+      "allowedHosts": ["${hostname}"]
+    }
+  }
+}
+
+ + `; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts new file mode 100644 index 000000000000..dbef050ac4c0 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ServerResponse } from 'node:http'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; +import { lookupMimeTypeFromRequest } from '../utils'; + +const ALLOWED_FALLBACK_METHODS = Object.freeze(['GET', 'HEAD']); + +export function angularHtmlFallbackMiddleware( + req: Connect.IncomingMessage, + _res: ServerResponse, + next: Connect.NextFunction, +): void { + // Similar to how it is handled in vite + // https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L15C19-L15C45 + if (!req.method || !ALLOWED_FALLBACK_METHODS.includes(req.method)) { + // No fallback for unsupported request methods + next(); + + return; + } + + if (req.url) { + const mimeType = lookupMimeTypeFromRequest(req.url); + if ( + (mimeType === 'text/html' || mimeType === 'application/xhtml+xml') && + !/^\/index\.(?:csr\.)?html/.test(req.url) + ) { + // eslint-disable-next-line no-console + console.warn( + `Request for HTML file "${req.url}" was received but no asset found. Asset may be missing from build.`, + ); + } else if (mimeType) { + // No fallback for request of asset-like files + next(); + + return; + } + } + + if ( + !req.headers.accept || + req.headers.accept.includes('text/html') || + req.headers.accept.includes('text/*') || + req.headers.accept.includes('*/*') + ) { + req.url = '/index.html'; + } + + next(); +} diff --git a/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts new file mode 100644 index 000000000000..7767d970ee42 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { extname } from 'node:path'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { AngularMemoryOutputFiles, pathnameWithoutBasePath } from '../utils'; + +export function createAngularIndexHtmlMiddleware( + server: ViteDevServer, + outputFiles: AngularMemoryOutputFiles, + resetComponentUpdates: () => void, + indexHtmlTransformer: ((content: string) => Promise) | undefined, +): Connect.NextHandleFunction { + return function angularIndexHtmlMiddleware(req, res, next) { + if (!req.url) { + next(); + + return; + } + + // Parse the incoming request. + // The base of the URL is unused but required to parse the URL. + const pathname = pathnameWithoutBasePath(req.url, server.config.base); + const extension = extname(pathname); + if (extension !== '.html') { + next(); + + return; + } + + const rawHtml = outputFiles.get(pathname)?.contents; + if (!rawHtml) { + next(); + + return; + } + + // A request for the index indicates a full page reload request. + resetComponentUpdates(); + + server + .transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8')) + .then(async (processedHtml) => { + if (indexHtmlTransformer) { + processedHtml = await indexHtmlTransformer(processedHtml); + } + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(processedHtml); + }) + .catch((error) => next(error)); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/index.ts b/packages/angular/build/src/tools/vite/middlewares/index.ts new file mode 100644 index 000000000000..807e739eed59 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export { type ComponentStyleRecord, createAngularAssetsMiddleware } from './assets-middleware'; +export { angularHtmlFallbackMiddleware } from './html-fallback-middleware'; +export { createAngularIndexHtmlMiddleware } from './index-html-middleware'; +export { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; +export { createAngularHeadersMiddleware } from './headers-middleware'; +export { createAngularComponentMiddleware } from './component-middleware'; +export { createChromeDevtoolsMiddleware } from './chrome-devtools-middleware'; +export { patchHostValidationMiddleware } from './host-check-middleware'; +export { patchBaseMiddleware } from './base-middleware'; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts new file mode 100644 index 000000000000..c75c63e992bb --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { + AngularAppEngine as SSRAngularAppEngine, + ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp, +} from '@angular/ssr'; +import type { ServerResponse } from 'node:http'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { + isSsrNodeRequestHandler, + isSsrRequestHandler, +} from '../../../utils/server-rendering/utils'; + +export function createAngularSsrInternalMiddleware( + server: ViteDevServer, + resetComponentUpdates: () => void, + indexHtmlTransformer?: (content: string) => Promise, +): Connect.NextHandleFunction { + let cachedAngularServerApp: ReturnType | undefined; + + return function angularSsrMiddleware( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + if (req.url === undefined) { + return next(); + } + + (async () => { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + const { writeResponseToNodeResponse, createWebRequestFromNodeRequest } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + const { ɵgetOrCreateAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as { + ɵgetOrCreateAngularServerApp: typeof getOrCreateAngularServerApp; + }; + + const angularServerApp = ɵgetOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + // Only Add the transform hook only if it's a different instance. + if (cachedAngularServerApp !== angularServerApp) { + angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); + const processedHtml = await server.transformIndexHtml(url.pathname, html); + + return indexHtmlTransformer?.(processedHtml) ?? processedHtml; + }); + + cachedAngularServerApp = angularServerApp; + } + + const webReq = new Request(createWebRequestFromNodeRequest(req), { + signal: AbortSignal.timeout(30_000), + }); + const webRes = await angularServerApp.handle(webReq); + if (!webRes) { + return next(); + } + + return writeResponseToNodeResponse(webRes, res); + })().catch(next); + }; +} + +export async function createAngularSsrExternalMiddleware( + server: ViteDevServer, + resetComponentUpdates: () => void, + indexHtmlTransformer?: (content: string) => Promise, +): Promise { + let fallbackWarningShown = false; + let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined; + let angularSsrInternalMiddleware: + ReturnType | undefined; + + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + // Disable host check if allowed hosts is true meaning allow all hosts. + const { allowedHosts } = server.config.server; + const disableAllowedHostsCheck = allowedHosts === true; + + return function angularSsrExternalMiddleware( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + (async () => { + const { reqHandler, AngularAppEngine } = (await server.ssrLoadModule('./server.mjs')) as { + reqHandler?: unknown; + AngularAppEngine: typeof SSRAngularAppEngine; + }; + + if (!isSsrNodeRequestHandler(reqHandler) && !isSsrRequestHandler(reqHandler)) { + if (!fallbackWarningShown) { + // eslint-disable-next-line no-console + console.warn( + `The 'reqHandler' export in 'server.ts' is either undefined or does not provide a recognized request handler. ` + + 'Using the internal SSR middleware instead.', + ); + + fallbackWarningShown = true; + } + + angularSsrInternalMiddleware ??= createAngularSsrInternalMiddleware( + server, + resetComponentUpdates, + indexHtmlTransformer, + ); + + angularSsrInternalMiddleware(req, res, next); + + return; + } + + if (cachedAngularAppEngine !== AngularAppEngine) { + AngularAppEngine.ɵdisableAllowedHostsCheck = disableAllowedHostsCheck; + AngularAppEngine.ɵallowStaticRouteRender = true; + AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); + const processedHtml = await server.transformIndexHtml(url.pathname, html); + + return indexHtmlTransformer?.(processedHtml) ?? processedHtml; + }); + + cachedAngularAppEngine = AngularAppEngine; + } + + // Forward the request to the middleware in server.ts + if (isSsrNodeRequestHandler(reqHandler)) { + await reqHandler(req, res, next); + } else { + const webRes = await reqHandler(createWebRequestFromNodeRequest(req)); + if (!webRes) { + next(); + + return; + } + + await writeResponseToNodeResponse(webRes, res); + } + })().catch(next); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts new file mode 100644 index 000000000000..a61011043a43 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; + +type ViteDevServer = Parameters[0]; + +describe('SSR dev-server middleware', () => { + function createMockReqRes(): { req: IncomingMessage; res: ServerResponse } { + const req = { + url: '/', + method: 'GET', + headers: { host: 'localhost' }, + } as unknown as IncomingMessage; + + const res = { + statusCode: 200, + setHeader: jasmine.createSpy('setHeader'), + writeHead: jasmine.createSpy('writeHead'), + end: jasmine.createSpy('end'), + } as unknown as ServerResponse; + + return { req, res }; + } + + it('resets component updates when html:transform:pre executes in internal SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + const fakeAngularServerApp = { + hooks: { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }, + handle: jasmine.createSpy('handle').and.resolveTo(new Response('rendered')), + }; + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + ɵgetOrCreateAngularServerApp: () => fakeAngularServerApp, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html), + } as unknown as ViteDevServer; + + const middleware = createAngularSsrInternalMiddleware( + server, + resetComponentUpdates, + async (html) => html + '', + ); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); + + it('resets component updates when html:transform:pre executes in external SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + class FakeAngularAppEngine { + static ɵhooks = { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }; + static ɵdisableAllowedHostsCheck: boolean; + static ɵallowStaticRouteRender: boolean; + } + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + reqHandler: Object.assign(() => new Response('ok'), { __ng_request_handler__: true }), + AngularAppEngine: FakeAngularAppEngine, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html + ''), + } as unknown as ViteDevServer; + + const middleware = await createAngularSsrExternalMiddleware(server, resetComponentUpdates); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); +}); diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts new file mode 100644 index 000000000000..c6dc243385a8 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type * as Vite from 'vite' with { + 'resolution-mode': 'import', +}; +import { removeSourceMappingURL } from '../../../utils/source-map'; +import { AngularMemoryOutputFiles } from '../utils'; + +interface AngularMemoryPluginOptions { + virtualProjectRoot: string; + outputFiles: AngularMemoryOutputFiles; + templateUpdates?: ReadonlyMap; + external?: string[]; + disableViteTransport?: boolean; +} + +const ANGULAR_PREFIX = '/@ng/'; +const VITE_FS_PREFIX = '/@fs/'; +const FILE_PROTOCOL = 'file:'; + +export async function createAngularMemoryPlugin( + options: AngularMemoryPluginOptions, +): Promise { + const { virtualProjectRoot, outputFiles, external } = options; + const { normalizePath } = (await import('vite' as string)) as typeof Vite; + + return { + name: 'vite:angular-memory', + // Ensures plugin hooks run before built-in Vite hooks + enforce: 'pre', + async resolveId(source, importer, { ssr }) { + if (source.startsWith(VITE_FS_PREFIX)) { + return; + } + + // For SSR with component HMR, pass through as a virtual module + if (ssr && source.startsWith(FILE_PROTOCOL) && source.includes(ANGULAR_PREFIX)) { + // Vite will resolve these these files example: + // `file:///@ng/component?c=src%2Fapp%2Fapp.component.ts%40AppComponent&t=1737017253850` + const sourcePath = fileURLToPath(source); + const sourceWithoutRoot = normalizePath('/' + relative(virtualProjectRoot, sourcePath)); + + if (sourceWithoutRoot.startsWith(ANGULAR_PREFIX)) { + const [, query] = source.split('?', 2); + + return `\0${sourceWithoutRoot}?${query}`; + } + } + + // Prevent vite from resolving an explicit external dependency (`externalDependencies` option) + if (external?.includes(source)) { + // This is still not ideal since Vite will still transform the import specifier to + // `/@id/${source}` but is currently closer to a raw external than a resolved file path. + return source; + } + + if (importer && source[0] === '.') { + const normalizedImporter = normalizePath(importer); + if (normalizedImporter.startsWith(virtualProjectRoot)) { + // Remove query if present + const [importerFile] = normalizedImporter.split('?', 1); + source = '/' + join(dirname(relative(virtualProjectRoot, importerFile)), source); + } + } + + const [file] = source.split('?', 1); + if (outputFiles.has(normalizePath(file))) { + return join(virtualProjectRoot, source); + } + }, + load(id, loadOptions) { + // For SSR component updates, return the component update module or empty if none + if (loadOptions?.ssr && id.startsWith(`\0${ANGULAR_PREFIX}`)) { + // Extract component identifier (first character is rollup virtual module null) + const requestUrl = new URL(id.slice(1), 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + + return (componentId && options.templateUpdates?.get(encodeURIComponent(componentId))) ?? ''; + } + + const [file] = id.split('?', 1); + const relativeFile = '/' + normalizePath(relative(virtualProjectRoot, file)); + const codeContents = outputFiles.get(relativeFile)?.contents; + if (codeContents === undefined) { + if (relativeFile.endsWith('/node_modules/vite/dist/client/client.mjs')) { + return loadViteClientCode(file, options.disableViteTransport); + } + + return undefined; + } + + const code = Buffer.from(codeContents).toString('utf-8'); + const mapContents = outputFiles.get(relativeFile + '.map')?.contents; + + return { + // Remove source map URL comments from the code if a sourcemap is present. + // Vite will inline and add an additional sourcemap URL for the sourcemap. + code: mapContents ? removeSourceMappingURL(code) : code, + map: mapContents && Buffer.from(mapContents).toString('utf-8'), + }; + }, + }; +} + +/** + * Reads the resolved Vite client code from disk and updates the content to remove + * an unactionable suggestion to update the Vite configuration file to disable the + * error overlay. The Vite configuration file is not present when used in the Angular + * CLI. + * @param file The absolute path to the Vite client code. + * @returns + */ +async function loadViteClientCode(file: string, disableViteTransport = false): Promise { + const originalContents = await readFile(file, 'utf-8'); + let updatedContents = originalContents.replace( + '"You can also disable this overlay by setting ", ' + + 'h("code", { part: "config-option-name" }, "server.hmr.overlay"), ' + + '" to ", ' + + 'h("code", { part: "config-option-value" }, "false"), ' + + '" in ", ' + + 'h("code", { part: "config-file-name" }, hmrConfigName), ' + + '"."', + '', + ); + + assert(originalContents !== updatedContents, 'Failed to update Vite client error overlay text.'); + + if (disableViteTransport) { + const previousUpdatedContents = updatedContents; + + updatedContents = updatedContents.replace( + 'transport.connect(createHMRHandler(handleMessage));', + '', + ); + assert( + previousUpdatedContents !== updatedContents, + 'Failed to update Vite client WebSocket disable.', + ); + + updatedContents = updatedContents.replace('console.debug("[vite] connecting...")', ''); + } + + return updatedContents; +} diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts new file mode 100644 index 000000000000..74cd03179a7e --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Plugin } from 'vite' with { + 'resolution-mode': 'import', +}; + +// NOTE: the implementation for this Vite plugin is roughly based on: +// https://github.com/MilanKovacic/vite-plugin-externalize-dependencies + +const VITE_ID_PREFIX = '@id/'; + +const escapeRegexSpecialChars = (inputString: string): string => { + return inputString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +}; + +export function createRemoveIdPrefixPlugin(externals: string[]): Plugin { + return { + name: 'angular-plugin-remove-id-prefix', + apply: 'serve', + configResolved: (resolvedConfig) => { + // don't do anything when the list of externals is empty + if (externals.length === 0) { + return; + } + + const transformFn = createTransformer(resolvedConfig.base, externals); + + // @ts-expect-error: Property 'push' does not exist on type 'readonly Plugin[]' + // Reasoning: + // since the /@id/ prefix is added by Vite's import-analysis plugin, + // we must add our actual plugin dynamically, to ensure that it will run + // AFTER the import-analysis. + resolvedConfig.plugins.push({ + name: 'angular-plugin-remove-id-prefix-transform', + transform: transformFn, + }); + }, + }; +} + +/** + * Creates a transform function that removes the Vite ID prefix from externals. + * @param base The base path of the application. + * @param externals The external package names. + * @returns A function that transforms code by removing the Vite ID prefix. + */ +export function createTransformer(base: string, externals: string[]): (code: string) => string { + // The path suffix is bounded so that a match can never extend past the end of an + // import specifier string literal. With a greedy `.+`, minified (single-line) code + // would let the first match consume the remainder of the line, leaving all later + // `/@id/` occurrences on that line unstripped. + const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/[^\'"`\\s]+)?'); + + const prefixedExternalRegex = new RegExp( + `${base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, + 'g', + ); + + return (code: string) => { + return code.includes(VITE_ID_PREFIX) + ? code.replace(prefixedExternalRegex, (_, externalName) => externalName) + : // don't do anything when code does not contain the Vite prefix + code; + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts new file mode 100644 index 000000000000..37d1bdf61fe6 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createTransformer } from './id-prefix-plugin'; + +describe('createTransformer', () => { + it('should strip the prefix from every occurrence on a single (minified) line', () => { + const transform = createTransformer('/', [ + '@angular/common', + '@angular/common/http', + '@angular/core', + '@angular/router', + ]); + + const minified = + 'import{a}from"/@id/@angular/common/http";' + + 'import{b}from"/@id/@angular/router";' + + 'import{c}from"/@id/@angular/core";'; + + expect(transform(minified)).toBe( + 'import{a}from"@angular/common/http";' + + 'import{b}from"@angular/router";' + + 'import{c}from"@angular/core";', + ); + }); + + it('should strip the prefix from an external with a deep import path', () => { + const transform = createTransformer('/', ['@angular/common']); + + expect(transform('import{h}from"/@id/@angular/common/http";')).toBe( + 'import{h}from"@angular/common/http";', + ); + }); + + it('should strip the prefix when a non-root base is configured', () => { + const transform = createTransformer('/app/', ['@angular/router']); + + expect(transform('import{r}from"/app/@id/@angular/router";')).toBe( + 'import{r}from"@angular/router";', + ); + }); + + it('should strip the prefix from multi-line (unminified) code', () => { + const transform = createTransformer('/', ['@angular/common', '@angular/router']); + + const code = + 'import { CommonModule } from "/@id/@angular/common";\n' + + 'import { Router } from "/@id/@angular/router";\n'; + + expect(transform(code)).toBe( + 'import { CommonModule } from "@angular/common";\n' + + 'import { Router } from "@angular/router";\n', + ); + }); + + it('should not modify imports that are not configured externals', () => { + const transform = createTransformer('/', ['@angular/router']); + + const code = 'import{x}from"/@id/some-other-package";'; + expect(transform(code)).toBe(code); + }); +}); diff --git a/packages/angular/build/src/tools/vite/plugins/index.ts b/packages/angular/build/src/tools/vite/plugins/index.ts new file mode 100644 index 000000000000..6c4cdd4496e4 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export { createAngularMemoryPlugin } from './angular-memory-plugin'; +export { createRemoveIdPrefixPlugin } from './id-prefix-plugin'; +export { createAngularSetupMiddlewaresPlugin, ServerSsrMode } from './setup-middlewares-plugin'; +export { createAngularSsrTransformPlugin } from './ssr-transform-plugin'; +export { createAngularServerSideSSLPlugin } from './ssr-ssl-plugin'; diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts new file mode 100644 index 000000000000..34b0704f4091 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Connect, Plugin } from 'vite' with { + 'resolution-mode': 'import', +}; +import { + ComponentStyleRecord, + angularHtmlFallbackMiddleware, + createAngularAssetsMiddleware, + createAngularComponentMiddleware, + createAngularHeadersMiddleware, + createAngularIndexHtmlMiddleware, + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, + createChromeDevtoolsMiddleware, + patchBaseMiddleware, + patchHostValidationMiddleware, +} from '../middlewares'; +import { AngularMemoryOutputFiles, AngularOutputAssets } from '../utils'; + +export enum ServerSsrMode { + /** + * No SSR + */ + NoSsr, + + /** + * Internal server-side rendering (SSR) is handled through the built-in middleware. + * + * In this mode, the SSR process is managed internally by the dev-server's middleware. + * The server automatically renders pages on the server without requiring external + * middleware or additional configuration from the developer. + */ + InternalSsrMiddleware, + + /** + * External server-side rendering (SSR) is handled by a custom middleware defined in server.ts. + * + * This mode allows developers to define custom SSR behavior by providing a middleware in the + * `server.ts` file. It gives more flexibility for handling SSR, such as integrating with other + * frameworks or customizing the rendering pipeline. + */ + ExternalSsrMiddleware, +} + +interface AngularSetupMiddlewaresPluginOptions { + outputFiles: AngularMemoryOutputFiles; + assets: AngularOutputAssets; + extensionMiddleware?: Connect.NextHandleFunction[]; + indexHtmlTransformer?: (content: string) => Promise; + componentStyles: Map; + templateUpdates: Map; + ssrMode: ServerSsrMode; + resetComponentUpdates: () => void; + projectRoot: string; +} + +async function createEncapsulateStyle(): Promise< + (style: Uint8Array, componentId: string) => string +> { + const { encapsulateStyle } = await import('@angular/compiler'); + const decoder = new TextDecoder('utf-8'); + + return (style, componentId) => { + return encapsulateStyle(decoder.decode(style), componentId); + }; +} + +export function createAngularSetupMiddlewaresPlugin( + options: AngularSetupMiddlewaresPluginOptions, +): Plugin { + return { + name: 'vite:angular-setup-middlewares', + enforce: 'pre', + async configureServer(server) { + const { + indexHtmlTransformer, + outputFiles, + extensionMiddleware, + assets, + componentStyles, + templateUpdates, + ssrMode, + resetComponentUpdates, + } = options; + + const middlewares = server.middlewares; + + // Headers, assets and resources get handled first + middlewares.use(createAngularHeadersMiddleware(server)); + middlewares.use(createAngularComponentMiddleware(server, templateUpdates)); + middlewares.use( + createAngularAssetsMiddleware( + server, + assets, + outputFiles, + componentStyles, + await createEncapsulateStyle(), + ), + ); + + middlewares.use(createChromeDevtoolsMiddleware(server.config.cacheDir, options.projectRoot)); + + extensionMiddleware?.forEach((middleware) => middlewares.use(middleware)); + + // Returning a function, installs middleware after the main transform middleware but + // before the built-in HTML middleware + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async () => { + patchHostValidationMiddleware(server.middlewares); + + if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) { + patchBaseMiddleware(server.middlewares, server.config.base); + middlewares.use( + await createAngularSsrExternalMiddleware( + server, + resetComponentUpdates, + indexHtmlTransformer, + ), + ); + + return; + } + + if (ssrMode === ServerSsrMode.InternalSsrMiddleware) { + middlewares.use( + createAngularSsrInternalMiddleware(server, resetComponentUpdates, indexHtmlTransformer), + ); + } + + middlewares.use(angularHtmlFallbackMiddleware); + middlewares.use( + createAngularIndexHtmlMiddleware( + server, + outputFiles, + resetComponentUpdates, + indexHtmlTransformer, + ), + ); + }; + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts new file mode 100644 index 000000000000..ceb1f9630b7f --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { getCACertificates, setDefaultCACertificates } from 'node:tls'; +import type { Plugin } from 'vite' with { + 'resolution-mode': 'import', +}; + +export function createAngularServerSideSSLPlugin(): Plugin { + return { + name: 'angular-ssr-ssl-plugin', + apply: 'serve', + async configureServer({ config, httpServer }) { + const { + ssr, + server: { https }, + } = config; + + if (!ssr || !https?.cert) { + return; + } + + if (httpServer && 'ALPNProtocols' in httpServer) { + // Force Vite to use HTTP/1.1 when SSR and SSL are enabled. + // This is required because the Express server used for SSR does not support HTTP/2. + // See: https://github.com/vitejs/vite/blob/46d3077f2b63771cc50230bc907c48f5773c00fb/packages/vite/src/node/http.ts#L126 + + // We directly set the `ALPNProtocols` on the HTTP server to override the default behavior. + // Passing `ALPNProtocols` in the TLS options would cause Node.js to automatically include `h2`. + // Additionally, using `ALPNCallback` is not an option as it is mutually exclusive with `ALPNProtocols`. + // See: https://github.com/nodejs/node/blob/b8b4350ed3b73d225eb9e628d69151df56eaf298/lib/internal/http2/core.js#L3351 + httpServer.ALPNProtocols = ['http/1.1']; + } + + const { cert } = https; + const additionalCerts = Array.isArray(cert) ? cert : [cert]; + + const currentCerts = getCACertificates('default'); + setDefaultCACertificates([...currentCerts, ...additionalCerts]); + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts new file mode 100644 index 000000000000..b857cc3cde74 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import remapping, { SourceMapInput } from '@ampproject/remapping'; +import type * as Vite from 'vite' with { + 'resolution-mode': 'import', +}; + +export async function createAngularSsrTransformPlugin(workspaceRoot: string): Promise { + const { normalizePath } = (await import('vite' as string)) as typeof Vite; + + return { + name: 'vite:angular-ssr-transform', + enforce: 'post', + transform(code, _id, { ssr, inMap }: { ssr?: boolean; inMap?: SourceMapInput } = {}) { + if (!ssr || !inMap) { + return null; + } + + const remappedMap = remapping([inMap], () => null); + // Set the sourcemap root to the workspace root. This is needed since we set a virtual path as root. + remappedMap.sourceRoot = normalizePath(workspaceRoot) + '/'; + + return { + code, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map: remappedMap as any, + }; + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/utils.ts b/packages/angular/build/src/tools/vite/utils.ts new file mode 100644 index 000000000000..fa833066f8ed --- /dev/null +++ b/packages/angular/build/src/tools/vite/utils.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { builtinModules, isBuiltin } from 'node:module'; +import { extname } from 'node:path'; +import type { DepOptimizationConfig } from 'vite' with { + 'resolution-mode': 'import', +}; +import type { ExternalResultMetadata } from '../esbuild/bundler-execution-result'; +import { JavaScriptTransformer } from '../javascript-transformer'; + +export type AngularMemoryOutputFiles = Map< + string, + { contents: Uint8Array; hash: string; servable: boolean } +>; + +export type AngularOutputAssets = Map; + +export function pathnameWithoutBasePath(url: string, basePath: string): string { + const parsedUrl = new URL(url, 'http://localhost'); + const pathname = decodeURIComponent(parsedUrl.pathname); + + // slice(basePath.length - 1) to retain the trailing slash + return basePath !== '/' && pathname.startsWith(basePath) + ? pathname.slice(basePath.length - 1) + : pathname; +} + +export function lookupMimeTypeFromRequest(url: string): string | undefined { + const extension = extname(url.split('?')[0]); + + if (extension === '.ico') { + return 'image/x-icon'; + } + + return extension && lookupMimeType(extension); +} + +export type RolldownLoaderOption = Exclude< + DepOptimizationConfig['rolldownOptions'], + undefined +>['moduleTypes']; + +export function getDepOptimizationConfig({ + target, + disabled, + exclude, + include, + prebundleTransformer, + loader, + thirdPartySourcemaps, + define = {}, +}: { + target: string[]; + disabled: boolean; + exclude: string[]; + include: string[]; + prebundleTransformer: JavaScriptTransformer; + loader?: RolldownLoaderOption; + thirdPartySourcemaps: boolean; + define: Record | undefined; +}): DepOptimizationConfig { + const config: DepOptimizationConfig = { + // Exclude any explicitly defined dependencies (currently build defined externals) + exclude, + // NB: to disable the deps optimizer, set optimizeDeps.noDiscovery to true and optimizeDeps.include as undefined. + // Include all implict dependencies from the external packages internal option + include: disabled ? undefined : include, + noDiscovery: disabled, + rolldownOptions: { + transform: { + target, + define, + }, + moduleTypes: loader, + resolve: { + extensions: ['.mjs', '.js', '.cjs'], + }, + plugins: [ + { + name: `angular-vite-optimize-deps${thirdPartySourcemaps ? '-vendor-sourcemap' : ''}`, + load: { + filter: { id: /\.[cm]?js$/ }, + async handler(id: string) { + const code = await prebundleTransformer.transformFile(id); + + return { code: Buffer.from(code).toString('utf-8') }; + }, + }, + }, + ], + }, + }; + + return config; +} + +export interface DevServerExternalResultMetadata { + implicitBrowser: string[]; + implicitServer: string[]; + explicitBrowser: string[]; + explicitServer: string[]; +} + +export function isAbsoluteUrl(url: string): boolean { + try { + new URL(url); + + return true; + } catch { + return false; + } +} + +export function updateExternalMetadata( + result: { detail?: { externalMetadata?: ExternalResultMetadata } }, + externalMetadata: DevServerExternalResultMetadata, + externalDependencies: string[] | undefined, + explicitPackagesOnly: boolean = false, +): void { + if (!result.detail?.['externalMetadata']) { + return; + } + + const { implicitBrowser, implicitServer, explicit } = result.detail['externalMetadata']; + const implicitServerFiltered = implicitServer.filter( + (m) => !isBuiltin(m) && !isAbsoluteUrl(m) && !m.endsWith('.json'), + ); + const implicitBrowserFiltered = implicitBrowser.filter( + (m) => !isAbsoluteUrl(m) && !m.endsWith('.json'), + ); + const explicitBrowserFiltered = explicitPackagesOnly + ? explicit.filter((m) => !isAbsoluteUrl(m)) + : explicit; + + // Empty Arrays to avoid growing unlimited with every re-build. + externalMetadata.explicitBrowser.length = 0; + externalMetadata.explicitServer.length = 0; + externalMetadata.implicitServer.length = 0; + externalMetadata.implicitBrowser.length = 0; + + const externalDeps = externalDependencies ?? []; + externalMetadata.explicitBrowser.push(...explicitBrowserFiltered, ...externalDeps); + externalMetadata.explicitServer.push( + ...explicitBrowserFiltered, + ...externalDeps, + ...builtinModules, + ); + externalMetadata.implicitServer.push(...implicitServerFiltered); + externalMetadata.implicitBrowser.push(...implicitBrowserFiltered); + + // The below needs to be sorted as Vite uses these options as part of the hashing invalidation algorithm. + // See: https://github.com/vitejs/vite/blob/0873bae0cfe0f0718ad2f5743dd34a17e4ab563d/packages/vite/src/node/optimizer/index.ts#L1203-L1239 + externalMetadata.explicitBrowser.sort(); + externalMetadata.explicitServer.sort(); + externalMetadata.implicitServer.sort(); + externalMetadata.implicitBrowser.sort(); +} diff --git a/packages/angular/build/src/utils/bundle-calculator.ts b/packages/angular/build/src/utils/bundle-calculator.ts new file mode 100644 index 000000000000..71a75dafde02 --- /dev/null +++ b/packages/angular/build/src/utils/bundle-calculator.ts @@ -0,0 +1,399 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Budget as BudgetEntry, Type as BudgetType } from '../builders/application/schema'; +import { formatSize } from './format-bytes'; + +// Re-export to avoid direct schema importing throughout code +export { type BudgetEntry, BudgetType }; + +export const BYTES_IN_KILOBYTE = 1000; + +interface Size { + size: number; + label?: string; +} + +export interface Threshold { + limit: number; + type: ThresholdType; + severity: ThresholdSeverity; +} + +enum ThresholdType { + Max = 'maximum', + Min = 'minimum', +} + +export enum ThresholdSeverity { + Warning = 'warning', + Error = 'error', +} + +export interface BudgetCalculatorResult { + severity: ThresholdSeverity; + message: string; + label?: string; +} + +export interface BudgetChunk { + files?: string[]; + names?: string[]; + initial?: boolean; +} + +export interface BudgetAsset { + name: string; + size: number; + componentStyle?: boolean; +} + +export interface BudgetStats { + chunks?: BudgetChunk[]; + assets?: BudgetAsset[]; +} + +export function* calculateThresholds(budget: BudgetEntry): IterableIterator { + if (budget.maximumWarning) { + yield { + limit: calculateBytes(budget.maximumWarning, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.maximumError) { + yield { + limit: calculateBytes(budget.maximumError, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Error, + }; + } + + if (budget.minimumWarning) { + yield { + limit: calculateBytes(budget.minimumWarning, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.minimumError) { + yield { + limit: calculateBytes(budget.minimumError, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Error, + }; + } + + if (budget.warning) { + yield { + limit: calculateBytes(budget.warning, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Warning, + }; + + yield { + limit: calculateBytes(budget.warning, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.error) { + yield { + limit: calculateBytes(budget.error, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Error, + }; + + yield { + limit: calculateBytes(budget.error, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Error, + }; + } +} + +/** + * Calculates the sizes for bundles in the budget type provided. + */ +function calculateSizes(budget: BudgetEntry, stats: BudgetStats): Size[] { + type CalculatorTypes = { + new (budget: BudgetEntry, chunks: BudgetChunk[], assets: BudgetAsset[]): Calculator; + }; + const calculatorMap: Record = { + all: AllCalculator, + allScript: AllScriptCalculator, + any: AnyCalculator, + anyScript: AnyScriptCalculator, + anyComponentStyle: AnyComponentStyleCalculator, + bundle: BundleCalculator, + initial: InitialCalculator, + }; + + const ctor = calculatorMap[budget.type]; + const { chunks, assets } = stats; + if (!chunks) { + throw new Error('Webpack stats output did not include chunk information.'); + } + if (!assets) { + throw new Error('Webpack stats output did not include asset information.'); + } + + const calculator = new ctor(budget, chunks, assets); + + return calculator.calculate(); +} + +abstract class Calculator { + private assetMap?: ReadonlyMap; + + constructor( + protected budget: BudgetEntry, + protected chunks: BudgetChunk[], + protected assets: BudgetAsset[], + ) {} + + abstract calculate(): Size[]; + + /** Calculates the size of the given chunk for the provided build type. */ + protected calculateChunkSize(chunk: BudgetChunk): number { + // No differential builds, get the chunk size by summing its assets. + if (!chunk.files) { + return 0; + } + + if (!this.assetMap) { + const map = new Map(); + for (const asset of this.assets) { + map.set(asset.name, asset.size); + } + this.assetMap = map; + } + const assetMap = this.assetMap; + + return chunk.files + .filter((file) => !file.endsWith('.map')) + .map((file) => { + const assetSize = assetMap.get(file); + if (assetSize === undefined) { + throw new Error(`Could not find asset for file: ${file}`); + } + + return assetSize; + }) + .reduce((l, r) => l + r, 0); + } + + protected getAssetSize(asset: BudgetAsset): number { + return asset.size; + } +} + +/** + * A named bundle. + */ +class BundleCalculator extends Calculator { + calculate() { + const budgetName = this.budget.name; + if (!budgetName) { + return []; + } + + const size = this.chunks + .filter((chunk) => chunk?.names?.includes(budgetName)) + .map((chunk) => this.calculateChunkSize(chunk)) + .reduce((l, r) => l + r, 0); + + return [{ size, label: this.budget.name }]; + } +} + +/** + * The sum of all initial chunks (marked as initial). + */ +class InitialCalculator extends Calculator { + calculate() { + return [ + { + label: `bundle initial`, + size: this.chunks + .filter((chunk) => chunk.initial) + .map((chunk) => this.calculateChunkSize(chunk)) + .reduce((l, r) => l + r, 0), + }, + ]; + } +} + +/** + * The sum of all the scripts portions. + */ +class AllScriptCalculator extends Calculator { + calculate() { + const size = this.assets + .filter((asset) => asset.name.endsWith('.js')) + .map((asset) => this.getAssetSize(asset)) + .reduce((total: number, size: number) => total + size, 0); + + return [{ size, label: 'total scripts' }]; + } +} + +/** + * All scripts and assets added together. + */ +class AllCalculator extends Calculator { + calculate() { + const size = this.assets + .filter((asset) => !asset.name.endsWith('.map') && !asset.componentStyle) + .map((asset) => this.getAssetSize(asset)) + .reduce((total: number, size: number) => total + size, 0); + + return [{ size, label: 'total' }]; + } +} + +/** + * Any script, individually. + */ +class AnyScriptCalculator extends Calculator { + calculate() { + return this.assets + .filter((asset) => asset.name.endsWith('.js')) + .map((asset) => ({ + size: this.getAssetSize(asset), + label: asset.name, + })); + } +} + +/** + * Any script or asset (images, css, etc). + */ +class AnyCalculator extends Calculator { + calculate() { + return this.assets + .filter((asset) => !asset.name.endsWith('.map') && !asset.componentStyle) + .map((asset) => ({ + size: this.getAssetSize(asset), + label: asset.name, + })); + } +} + +/** + * Any compoonent stylesheet + */ +class AnyComponentStyleCalculator extends Calculator { + calculate() { + return this.assets + .filter((asset) => asset.componentStyle) + .map((asset) => ({ + size: this.getAssetSize(asset), + label: asset.name, + })); + } +} + +/** + * Calculate the bytes given a string value. + */ +function calculateBytes(input: string, baseline?: string, factor: 1 | -1 = 1): number { + const matches = input.trim().match(/^(\d+(?:\.\d+)?)[ \t]*(%|[kmg]?b)?$/i); + if (!matches) { + return NaN; + } + + const baselineBytes = (baseline && calculateBytes(baseline)) || 0; + + let value = Number(matches[1]); + switch (matches[2] && matches[2].toLowerCase()) { + case '%': + value = (baselineBytes * value) / 100; + break; + case 'kb': + value *= BYTES_IN_KILOBYTE; + break; + case 'mb': + value *= BYTES_IN_KILOBYTE * BYTES_IN_KILOBYTE; + break; + case 'gb': + value *= BYTES_IN_KILOBYTE * BYTES_IN_KILOBYTE * BYTES_IN_KILOBYTE; + break; + } + + if (baselineBytes === 0) { + return value; + } + + return baselineBytes + value * factor; +} + +export function* checkBudgets( + budgets: BudgetEntry[], + stats: BudgetStats, + checkComponentStyles?: boolean, +): IterableIterator { + // Ignore AnyComponentStyle budgets as these are handled in `AnyComponentStyleBudgetChecker` unless requested + const computableBudgets = checkComponentStyles + ? budgets + : budgets.filter((budget) => budget.type !== BudgetType.AnyComponentStyle); + + for (const budget of computableBudgets) { + const sizes = calculateSizes(budget, stats); + for (const { size, label } of sizes) { + yield* checkThresholds(calculateThresholds(budget), size, label); + } + } +} + +export function* checkThresholds( + thresholds: Iterable, + size: number, + label?: string, +): IterableIterator { + for (const threshold of thresholds) { + switch (threshold.type) { + case ThresholdType.Max: { + if (size <= threshold.limit) { + continue; + } + + const sizeDifference = formatSize(size - threshold.limit); + yield { + severity: threshold.severity, + label, + message: `${label} exceeded maximum budget. Budget ${formatSize( + threshold.limit, + )} was not met by ${sizeDifference} with a total of ${formatSize(size)}.`, + }; + break; + } + case ThresholdType.Min: { + if (size >= threshold.limit) { + continue; + } + + const sizeDifference = formatSize(threshold.limit - size); + yield { + severity: threshold.severity, + label, + message: `${label} failed to meet minimum budget. Budget ${formatSize( + threshold.limit, + )} was not met by ${sizeDifference} with a total of ${formatSize(size)}.`, + }; + break; + } + default: { + throw new Error(`Unexpected threshold type: ${ThresholdType[threshold.type]}`); + } + } + } +} diff --git a/packages/angular/build/src/utils/bundle-calculator_spec.ts b/packages/angular/build/src/utils/bundle-calculator_spec.ts new file mode 100644 index 000000000000..9bb44394496b --- /dev/null +++ b/packages/angular/build/src/utils/bundle-calculator_spec.ts @@ -0,0 +1,379 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + BYTES_IN_KILOBYTE, + BudgetEntry, + BudgetType, + ThresholdSeverity, + checkBudgets, +} from './bundle-calculator'; + +describe('bundle-calculator', () => { + describe('checkBudgets()', () => { + it('yields maximum budgets exceeded', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Any, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [], + assets: [ + { + name: 'foo.js', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo.js', + message: jasmine.stringMatching('foo.js exceeded maximum budget.'), + }); + }); + + it('yields minimum budgets exceeded', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Any, + minimumError: '1kb', + }, + ]; + const stats = { + chunks: [], + assets: [ + { + name: 'foo.js', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'bar.js', + message: jasmine.stringMatching('bar.js failed to meet minimum budget.'), + }); + }); + + it('yields exceeded bundle budgets', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Bundle, + name: 'foo', + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo', + message: jasmine.stringMatching('foo exceeded maximum budget.'), + }); + }); + + it('yields exceeded initial budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Initial, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'bundle initial', + message: jasmine.stringMatching('initial exceeded maximum budget.'), + }); + }); + + it('yields exceeded total scripts budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.AllScript, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'baz.css', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'total scripts', + message: jasmine.stringMatching('total scripts exceeded maximum budget.'), + }); + }); + + it('yields exceeded total budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.All, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.css'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.css', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'total', + message: jasmine.stringMatching('total exceeded maximum budget.'), + }); + }); + + it('skips component style budgets', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.AnyComponentStyle, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.css', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.css', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(0); + }); + + it('yields exceeded individual script budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.AnyScript, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo.js', + message: jasmine.stringMatching('foo.js exceeded maximum budget.'), + }); + }); + + it('yields exceeded individual file budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Any, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.ext', 'bar.ext'], + }, + ], + assets: [ + { + name: 'foo.ext', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.ext', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo.ext', + message: jasmine.stringMatching('foo.ext exceeded maximum budget.'), + }); + }); + + it('does not exceed the individual file budget limit', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Bundle, + maximumError: '1000kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['main'], + files: ['main.ext', 'bar.ext'], + }, + ], + assets: [ + { + name: 'main.ext', + size: 1 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.ext', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures).toHaveSize(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/cache/cache.ts b/packages/angular/build/src/utils/cache/cache.ts new file mode 100644 index 000000000000..1773caca71ea --- /dev/null +++ b/packages/angular/build/src/utils/cache/cache.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * @fileoverview + * Provides infrastructure for common caching functionality within the build system. + */ + +import { persistentCacheStoreSetting } from '../environment-options'; +import { assertIsError } from '../error'; + +/** + * A backing data store for one or more Cache instances. + * The interface is intentionally designed to support using a JavaScript + * Map instance as a potential cache store. + */ +export interface CacheStore { + /** + * Returns the specified value from the cache store or `undefined` if not found. + * @param key The key to retrieve from the store. + */ + get(key: string): V | undefined | Promise; + + /** + * Returns whether the provided key is present in the cache store. + * @param key The key to check from the store. + */ + has(key: string): boolean | Promise; + + /** + * Adds a new value to the cache store if the key is not present. + * Updates the value for the key if already present. + * @param key The key to associate with the value in the cache store. + * @param value The value to add to the cache store. + */ + set(key: string, value: V): this | Promise; +} + +/** + * A persistent backing data store that supports namespace partitioning + * and manual lifecycle close operations. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface PersistentCacheStore extends CacheStore { + createCache(namespace: string): Cache; + close(): void | Promise; +} + +/** + * A backing data store wrapper that namespaces all keys using length-prefix framing. + * Prevents key collisions between namespaces regardless of characters (such as colons) + * in the namespace or key. + */ +export class NamespacedCacheStore implements CacheStore { + readonly #prefix: string; + + constructor( + private readonly store: CacheStore, + readonly namespace: string, + ) { + this.#prefix = `${namespace.length}:${namespace}:`; + } + + get(key: string): V | undefined | Promise { + return this.store.get(this.#prefix + key); + } + + has(key: string): boolean | Promise { + return this.store.has(this.#prefix + key); + } + + set(key: string, value: V): this | Promise { + const result = this.store.set(this.#prefix + key, value); + if (result instanceof Promise) { + return result.then(() => this); + } + + return this; + } +} + +/** + * A cache object that allows accessing and storing key/value pairs in + * an underlying CacheStore. This class is the primary method for consumers + * to use a cache. + */ +export class Cache = CacheStore> { + // In-flight creator promises to deduplicate concurrent requests for the same key. + readonly #requests = new Map>(); + // Track how many writes occurred for a key to detect mutations during await gaps. + readonly #writeCounts = new Map(); + // Count the number of active, pending getOrCreate operations per key to avoid memory leaks. + readonly #pendingGets = new Map(); + + constructor(protected readonly store: S) {} + + #incrementWrite(key: string) { + // Only track write counts if there is a pending getOrCreate operation active for the key. + // This ensures that write counts are not leaked when no concurrent gets are running. + if (this.#pendingGets.has(key)) { + this.#writeCounts.set(key, (this.#writeCounts.get(key) || 0) + 1); + } + } + + /** + * Gets the value associated with a provided key if available. + * Otherwise, creates a value using the factory creator function, puts the value + * in the cache, and returns the new value. + * @param key A key associated with the value. + * @param creator A factory function for the value if no value is present. + * @returns A value associated with the provided key. + */ + async getOrCreate(key: string, creator: () => V | Promise): Promise { + // 1. If another call is already running the creator for this key, share its promise. + let activeRequest = this.#requests.get(key); + if (activeRequest !== undefined) { + return activeRequest; + } + + // Increment pending gets count to enable write-tracking for this key. + const currentPending = this.#pendingGets.get(key) || 0; + this.#pendingGets.set(key, currentPending + 1); + + try { + const startWriteCount = this.#writeCounts.get(key) || 0; + + // 2. Query the backing store. Since store.get can be async, we yield to the event loop. + const value = await this.store.get(key); + + // If a write (e.g. put) occurred during the store.get await gap, we must abort + // the current execution and restart to ensure we return the newly written value. + if ((this.#writeCounts.get(key) || 0) !== startWriteCount) { + return this.getOrCreate(key, creator); + } + + if (value !== undefined) { + return value; + } + + // 3. Recheck active request after the await gap in case another concurrent call + // initiated a creator during the store.get wait. + activeRequest = this.#requests.get(key); + if (activeRequest !== undefined) { + return activeRequest; + } + + // 4. Run the creator to produce the new value, and store its promise in #requests. + activeRequest = Promise.resolve(creator()).then( + async (newValue) => { + // Ensure this request is still the active one before writing back to the store + // (prevents overwriting newer data if put() was called before resolution). + if (this.#requests.get(key) === activeRequest) { + this.#incrementWrite(key); + await this.store.set(key, newValue); + this.#requests.delete(key); + } + + return newValue; + }, + (error) => { + // Clean up the active request if the creator fails. + if (this.#requests.get(key) === activeRequest) { + this.#requests.delete(key); + } + throw error; + }, + ); + + this.#requests.set(key, activeRequest); + + return activeRequest; + } finally { + // Clean up write counts and pending gets once all concurrent gets for this key finish. + const current = this.#pendingGets.get(key) || 0; + if (current <= 1) { + this.#pendingGets.delete(key); + this.#writeCounts.delete(key); + } else { + this.#pendingGets.set(key, current - 1); + } + } + } + + /** + * Gets the value associated with a provided key if available. + * @param key A key associated with the value. + * @returns A value associated with the provided key if present. Otherwise, `undefined`. + */ + async get(key: string): Promise { + const value = await this.store.get(key); + + return value; + } + + /** + * Puts a value in the cache and associates it with the provided key. + * If the key is already present, the value is updated instead. + * @param key A key associated with the value. + * @param value A value to put in the cache. + */ + async put(key: string, value: V): Promise { + this.#requests.delete(key); + this.#incrementWrite(key); + await this.store.set(key, value); + } + + /** + * Clears internal state for a specific key (requests, write counts, and pending gets). + */ + protected deleteInternal(key: string): void { + this.#requests.delete(key); + this.#writeCounts.delete(key); + this.#pendingGets.delete(key); + } + + /** + * Clears the base class internal state (requests, write counts, and pending gets). + */ + protected clearInternal(): void { + this.#requests.clear(); + this.#writeCounts.clear(); + this.#pendingGets.clear(); + } +} + +/** + * A lightweight in-memory cache implementation based on a JavaScript Map object. + */ +export class MemoryCache extends Cache> { + constructor() { + super(new Map()); + } + + /** + * Removes the specified key from the cache instance. + * @param key The key to remove. + * @returns True if an element in the Map existed and has been removed, or false if the element does not exist. + */ + delete(key: string): boolean { + this.deleteInternal(key); + + return this.store.delete(key); + } + + /** + * Removes all entries from the cache instance. + */ + clear() { + this.clearInternal(); + this.store.clear(); + } + + /** + * Provides all the values currently present in the cache instance. + * @returns An iterable of all values in the cache. + */ + values() { + return this.store.values(); + } + + /** + * Provides all the keys/values currently present in the cache instance. + * @returns An iterable of all key/value pairs in the cache. + */ + entries() { + return this.store.entries(); + } +} + +/** + * Creates and returns a persistent cache store. + * Attempts to use the native LMDB store first, and falls back to the built-in SQLite store + * if LMDB fails to initialize. + * + * @param baseCachePath The base path of the cache file/directory without suffix/extension. + * @returns A promise resolving to a PersistentCacheStore instance. + */ +export async function createPersistentCacheStore( + baseCachePath: string, +): Promise { + if (persistentCacheStoreSetting === 'sqlite') { + try { + const { SqliteCacheStore } = await import('./sqlite-cache-store'); + + return new SqliteCacheStore(baseCachePath + '-sqlite.db'); + } catch (err) { + assertIsError(err); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + `SQLite error: ${err.message}`, + { cause: err }, + ); + } + } + + if (persistentCacheStoreSetting === 'lmdb') { + try { + const { LmdbCacheStore } = await import('./lmdb-cache-store'); + + return new LmdbCacheStore(baseCachePath + '.db'); + } catch (err) { + assertIsError(err); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + `LMDB error: ${err.message}`, + { cause: err }, + ); + } + } + + try { + const { LmdbCacheStore } = await import('./lmdb-cache-store'); + + return new LmdbCacheStore(baseCachePath + '.db'); + } catch (lmdbError) { + try { + const { SqliteCacheStore } = await import('./sqlite-cache-store'); + + return new SqliteCacheStore(baseCachePath + '-sqlite.db'); + } catch (sqliteError) { + assertIsError(lmdbError); + assertIsError(sqliteError); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + + `LMDB error: ${lmdbError.message.split('\n')[0]}\n` + + `SQLite error: ${sqliteError.message.split('\n')[0]}`, + { cause: sqliteError }, + ); + } + } +} diff --git a/packages/angular/build/src/utils/cache/cache_spec.ts b/packages/angular/build/src/utils/cache/cache_spec.ts new file mode 100644 index 000000000000..c5c0b18f6a49 --- /dev/null +++ b/packages/angular/build/src/utils/cache/cache_spec.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Cache, CacheStore, MemoryCache, NamespacedCacheStore } from './cache'; + +describe('MemoryCache', () => { + let cache: MemoryCache; + + beforeEach(() => { + cache = new MemoryCache(); + }); + + it('should return cached value on subsequent getOrCreate calls', async () => { + let callCount = 0; + const creator = () => { + callCount++; + + return 'value'; + }; + + const val1 = await cache.getOrCreate('key', creator); + const val2 = await cache.getOrCreate('key', creator); + + expect(val1).toBe('value'); + expect(val2).toBe('value'); + expect(callCount).toBe(1); + }); + + it('should call creator only once for concurrent getOrCreate calls with the same key', async () => { + let callCount = 0; + let resolveCreator!: (value: string) => void; + const promise = new Promise((resolve) => { + resolveCreator = resolve; + }); + + const creator = () => { + callCount++; + + return promise; + }; + + const p1 = cache.getOrCreate('key', creator); + const p2 = cache.getOrCreate('key', creator); + + resolveCreator('concurrent-value'); + + const [val1, val2] = await Promise.all([p1, p2]); + + expect(val1).toBe('concurrent-value'); + expect(val2).toBe('concurrent-value'); + expect(callCount).toBe(1); + }); + + it('should call creator multiple times for concurrent getOrCreate calls with different keys', async () => { + let callCount = 0; + const creator = (val: string) => { + callCount++; + + return Promise.resolve(val); + }; + + const p1 = cache.getOrCreate('key1', () => creator('value1')); + const p2 = cache.getOrCreate('key2', () => creator('value2')); + + const [val1, val2] = await Promise.all([p1, p2]); + + expect(val1).toBe('value1'); + expect(val2).toBe('value2'); + expect(callCount).toBe(2); + }); + + it('should clean up active request if creator throws/rejects', async () => { + let callCount = 0; + let rejectCreator!: (err: Error) => void; + const promise = new Promise((_, reject) => { + rejectCreator = reject; + }); + + const creator = () => { + callCount++; + + return promise; + }; + + const p1 = cache.getOrCreate('key', creator); + const p2 = cache.getOrCreate('key', creator); + + rejectCreator(new Error('creator error')); + + await expectAsync(p1).toBeRejectedWithError('creator error'); + await expectAsync(p2).toBeRejectedWithError('creator error'); + + // Subsequent call should trigger the creator again + const p3 = cache.getOrCreate('key', () => { + callCount++; + + return Promise.resolve('new-value'); + }); + const val3 = await p3; + expect(val3).toBe('new-value'); + expect(callCount).toBe(2); + }); + + it('should override/clear active requests when put is called', async () => { + let resolveCreator!: (value: string) => void; + const promise = new Promise((resolve) => { + resolveCreator = resolve; + }); + + let creatorStarted!: (value: void) => void; + const creatorStartedPromise = new Promise((resolve) => { + creatorStarted = resolve; + }); + + const creator = () => { + creatorStarted(); + + return promise; + }; + + const p1 = cache.getOrCreate('key', creator); + + // Wait for the creator to be called so that the active request is created + await creatorStartedPromise; + + // Call put before the creator promise resolves + await cache.put('key', 'override-value'); + + resolveCreator('original-value'); + + const val1 = await p1; + // p1 was already returned, so it resolves to original-value + expect(val1).toBe('original-value'); + + // Subsequent getOrCreate should return the put/overridden value, not the resolved original-value + const val2 = await cache.getOrCreate('key', () => 'should-not-run'); + expect(val2).toBe('override-value'); + }); + + it('should delete a cached key and allow creating a new value', async () => { + await cache.put('key', 'value-1'); + expect(await cache.get('key')).toBe('value-1'); + + const deleted = cache.delete('key'); + expect(deleted).toBeTrue(); + expect(await cache.get('key')).toBeUndefined(); + + // Subsequent getOrCreate should call creator + const newValue = await cache.getOrCreate('key', () => 'value-2'); + expect(newValue).toBe('value-2'); + }); + + it('should return false when deleting a non-existent key', () => { + expect(cache.delete('non-existent')).toBeFalse(); + }); + + it('should return unencoded keys in entries() and allow deletion', async () => { + await cache.put('component/style.scss', 'content'); + + const entries = Array.from(cache.entries()); + expect(entries).toEqual([['component/style.scss', 'content']]); + + expect(cache.delete(entries[0][0])).toBeTrue(); + expect(await cache.get('component/style.scss')).toBeUndefined(); + }); +}); + +describe('NamespacedCacheStore', () => { + class TestStore implements CacheStore { + readonly map = new Map(); + + get(key: string): string | undefined { + return this.map.get(key); + } + + has(key: string): boolean { + return this.map.has(key); + } + + set(key: string, value: string): this { + this.map.set(key, value); + + return this; + } + } + + let store: TestStore; + + beforeEach(() => { + store = new TestStore(); + }); + + it('should encode namespaced keys with ::', async () => { + const namespacedStore = new NamespacedCacheStore(store, 'test-ns'); + const cache = new Cache(namespacedStore); + await cache.put('my-key', 'my-val'); + + expect(store.map.has('7:test-ns:my-key')).toBeTrue(); + expect(await cache.get('my-key')).toBe('my-val'); + }); + + it('should prevent collisions between namespaces containing colons', async () => { + const cacheA = new Cache(new NamespacedCacheStore(store, 'a')); + const cacheB = new Cache(new NamespacedCacheStore(store, 'a:b')); + + await cacheA.put('b:c', 'val-a'); + await cacheB.put('c', 'val-b'); + + expect(await cacheA.get('b:c')).toBe('val-a'); + expect(await cacheB.get('c')).toBe('val-b'); + expect(store.map.get('1:a:b:c')).toBe('val-a'); + expect(store.map.get('3:a:b:c')).toBe('val-b'); + }); + + it('should encode empty string namespace as 0::', async () => { + const namespacedStore = new NamespacedCacheStore(store, ''); + const cache = new Cache(namespacedStore); + await cache.put('key', 'val'); + + expect(store.map.has('0::key')).toBeTrue(); + expect(await cache.get('key')).toBe('val'); + }); + + it('should forward get, has, and set calls with the namespaced prefix', async () => { + const namespacedStore = new NamespacedCacheStore(store, 'custom'); + await namespacedStore.set('hello', 'world'); + + expect(store.map.has('6:custom:hello')).toBeTrue(); + expect(await namespacedStore.has('hello')).toBeTrue(); + expect(await namespacedStore.get('hello')).toBe('world'); + }); + + it('should return this when the underlying store set is asynchronous', async () => { + class AsyncStore implements CacheStore { + readonly map = new Map(); + + get(key: string): Promise { + return Promise.resolve(this.map.get(key)); + } + + has(key: string): Promise { + return Promise.resolve(this.map.has(key)); + } + + async set(key: string, value: string): Promise { + this.map.set(key, value); + + return this; + } + } + + const asyncStore = new AsyncStore(); + const namespacedStore = new NamespacedCacheStore(asyncStore, 'async-ns'); + const setPromise = namespacedStore.set('foo', 'bar'); + + expect(setPromise instanceof Promise).toBeTrue(); + expect(await setPromise).toBe(namespacedStore); + expect(asyncStore.map.get('8:async-ns:foo')).toBe('bar'); + expect(await namespacedStore.get('foo')).toBe('bar'); + expect(await namespacedStore.has('foo')).toBeTrue(); + }); +}); diff --git a/packages/angular/build/src/utils/cache/index.ts b/packages/angular/build/src/utils/cache/index.ts new file mode 100644 index 000000000000..417d13326f50 --- /dev/null +++ b/packages/angular/build/src/utils/cache/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from './cache'; diff --git a/packages/angular/build/src/utils/cache/lmdb-cache-store.ts b/packages/angular/build/src/utils/cache/lmdb-cache-store.ts new file mode 100644 index 000000000000..d7ac3fed49db --- /dev/null +++ b/packages/angular/build/src/utils/cache/lmdb-cache-store.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { RootDatabase, open } from 'lmdb'; +import { Cache, NamespacedCacheStore, PersistentCacheStore } from './cache'; + +export class LmdbCacheStore implements PersistentCacheStore { + readonly #cacheFileUrl; + #db: RootDatabase | undefined; + + constructor(readonly cachePath: string) { + this.#cacheFileUrl = cachePath; + } + + #ensureCacheFile(): RootDatabase { + this.#db ??= open({ + path: this.#cacheFileUrl, + compression: true, + }); + + return this.#db; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(key: string): Promise { + const db = this.#ensureCacheFile(); + const value = db.get(key); + + return value; + } + + has(key: string): boolean { + return this.#ensureCacheFile().doesExist(key); + } + + async set(key: string, value: unknown): Promise { + const db = this.#ensureCacheFile(); + await db.put(key, value); + + return this; + } + + createCache(namespace: string): Cache { + return new Cache(new NamespacedCacheStore(this, namespace)); + } + + async close() { + try { + await this.#db?.close(); + } catch { + // Failure to close should not be fatal + } + } +} diff --git a/packages/angular/build/src/utils/cache/sqlite-cache-store.ts b/packages/angular/build/src/utils/cache/sqlite-cache-store.ts new file mode 100644 index 000000000000..d9273f5816e7 --- /dev/null +++ b/packages/angular/build/src/utils/cache/sqlite-cache-store.ts @@ -0,0 +1,384 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { mkdirSync, rmSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { DatabaseSync, StatementSync } from 'node:sqlite'; +import { promisify } from 'node:util'; +import { deserialize, serialize } from 'node:v8'; +import { deflateRaw, inflateRawSync } from 'node:zlib'; +import { Cache, NamespacedCacheStore, PersistentCacheStore } from './cache'; + +const deflateRawAsync = promisify(deflateRaw); + +/** + * Minimum payload size (in bytes) required to attempt compression. + * Smaller payloads generally do not achieve meaningful compression ratios, while larger payloads + * (such as transformed JavaScript modules from node_modules) achieve 70-80% size reduction + * and drastically reduce SQLite overflow pages. + */ +const COMPRESSION_THRESHOLD = 32 * 1024; // 32 KB + +/** + * Storage format discriminator values for cache table entries. + */ +const enum CacheFormat { + V8 = 0, + V8Compressed = 1, + RawBinary = 2, + RawBinaryCompressed = 3, +} + +/** + * Common SQLite primary result codes. + * @see https://www.sqlite.org/rescode.html + */ +const enum SqliteResultCode { + Busy = 5, + Locked = 6, +} + +interface SqliteError extends Error { + code?: string; + errcode?: number; + errstr?: string; +} + +function isSqliteError(error: unknown): error is SqliteError { + return ( + error instanceof Error && + ('errcode' in error || + ('code' in error && (error as { code: unknown }).code === 'ERR_SQLITE_ERROR')) + ); +} + +/** + * A persistent cache store backed by SQLite. + * + * Values are persisted with the V8 structured clone serialization API instead of JSON. Cached + * values include binary data such as the `Uint8Array` output of the JavaScript transformer and + * the `contents` of an esbuild load result. A JSON round-trip converts those into plain objects + * (`{"0":105,"1":109,...}`), which breaks consumers on any build that reads them back from disk. + */ +export class SqliteCacheStore implements PersistentCacheStore { + #db: DatabaseSync | undefined; + #disabled = false; + #getStmt: StatementSync | undefined; + #hasStmt: StatementSync | undefined; + #setStmt: StatementSync | undefined; + #updateAccessedStmt: StatementSync | undefined; + readonly #pendingAccessedKeys = new Set(); + #flushTimeout: NodeJS.Timeout | undefined; + readonly #busyTimeoutMs: number; + + constructor( + readonly cachePath: string, + private readonly maxPayloadSize = 1024 * 1024 * 1024, + private readonly ttlDays = 14, + busyTimeoutMs = 5000, + ) { + this.#busyTimeoutMs = + Number.isSafeInteger(busyTimeoutMs) && busyTimeoutMs >= 0 ? busyTimeoutMs : 5000; + } + + #openDatabase(): DatabaseSync { + let db: DatabaseSync | undefined; + try { + if (this.cachePath === ':memory:') { + db = new DatabaseSync(this.cachePath); + } else { + // Optimistically attempt to open the database file first to avoid directory creation + // syscalls on warm builds where the parent directory already exists. + try { + db = new DatabaseSync(this.cachePath); + } catch { + mkdirSync(dirname(this.cachePath), { recursive: true }); + db = new DatabaseSync(this.cachePath); + } + } + + // Optimize SQLite for cache usage + db.exec(`PRAGMA busy_timeout = ${this.#busyTimeoutMs};`); + db.exec('PRAGMA auto_vacuum = FULL;'); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec('PRAGMA synchronous = NORMAL;'); + db.exec('PRAGMA temp_store = MEMORY;'); + db.exec('PRAGMA mmap_size = 268435456;'); + db.exec( + 'CREATE TABLE IF NOT EXISTS cache (' + + 'key TEXT PRIMARY KEY, ' + + 'value BLOB, ' + + 'format INTEGER NOT NULL DEFAULT 0, ' + + 'last_accessed INTEGER NOT NULL' + + ') WITHOUT ROWID;', + ); + try { + db.exec('ALTER TABLE cache ADD COLUMN format INTEGER NOT NULL DEFAULT 0;'); + } catch { + // Ignore error if format column already exists + } + db.exec( + 'CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache (last_accessed DESC, key DESC);', + ); + + this.#getStmt = db.prepare('SELECT value, format FROM cache WHERE key = ?'); + this.#hasStmt = db.prepare('SELECT 1 FROM cache WHERE key = ?'); + this.#setStmt = db.prepare( + 'INSERT OR REPLACE INTO cache (key, value, format, last_accessed) VALUES (?, ?, ?, unixepoch())', + ); + this.#updateAccessedStmt = db.prepare( + 'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?', + ); + + this.#db = db; + + return db; + } catch (error) { + try { + db?.close(); + } catch { + // Ignore close error on corrupted handle + } + this.#getStmt = undefined; + this.#hasStmt = undefined; + this.#setStmt = undefined; + this.#updateAccessedStmt = undefined; + throw error; + } + } + + #ensureDb(): DatabaseSync | undefined { + if (this.#disabled) { + return undefined; + } + + if (!this.#db) { + try { + return this.#openDatabase(); + } catch (error) { + // If the database is locked by another active process, + // do not attempt to delete the database files as that could corrupt the active process's database. + const isBusy = + isSqliteError(error) && + (error.errcode === SqliteResultCode.Busy || error.errcode === SqliteResultCode.Locked); + + // Attempt to recover from database corruption by deleting the corrupted files and recreating + if (!isBusy && this.cachePath !== ':memory:') { + try { + rmSync(this.cachePath, { force: true }); + rmSync(this.cachePath + '-wal', { force: true }); + rmSync(this.cachePath + '-shm', { force: true }); + rmSync(this.cachePath + '-journal', { force: true }); + + return this.#openDatabase(); + } catch { + // If recovery fails (e.g. read-only filesystem or permission denied), disable caching + } + } + + this.#disabled = true; + + return undefined; + } + } + + return this.#db; + } + + #queueAccessUpdate(key: string): void { + this.#pendingAccessedKeys.add(key); + + if (!this.#flushTimeout) { + this.#flushTimeout = setTimeout(() => this.#flushAccessUpdates(), 1000); + this.#flushTimeout.unref?.(); + } + } + + #flushAccessUpdates(): void { + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout); + this.#flushTimeout = undefined; + } + + if (this.#pendingAccessedKeys.size === 0) { + return; + } + + try { + if (this.#db && this.#updateAccessedStmt) { + this.#db.exec('BEGIN IMMEDIATE TRANSACTION;'); + for (const key of this.#pendingAccessedKeys) { + this.#updateAccessedStmt.run(key); + } + this.#db.exec('COMMIT;'); + } + } catch { + try { + this.#db?.exec('ROLLBACK;'); + } catch { + // Ignore rollback errors if transaction was not active + } + } finally { + this.#pendingAccessedKeys.clear(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(key: string): Promise { + if (!this.#ensureDb()) { + return undefined; + } + + try { + // SQLite column types are dynamic, so the stored value is only known at runtime. + const row = this.#getStmt?.get(key) as { value: unknown; format: CacheFormat } | undefined; + + if (row) { + this.#queueAccessUpdate(key); + + if (row.value instanceof Uint8Array) { + try { + switch (row.format) { + case CacheFormat.RawBinary: + return row.value; + case CacheFormat.RawBinaryCompressed: { + const decompressed = inflateRawSync(row.value); + + return new Uint8Array( + decompressed.buffer, + decompressed.byteOffset, + decompressed.byteLength, + ); + } + case CacheFormat.V8Compressed: + return deserialize(inflateRawSync(row.value)); + case CacheFormat.V8: + default: + return deserialize(row.value); + } + } catch { + // Treat corrupt or unparseable cached payloads as a cache miss. + } + } + } + } catch { + // Treat query errors (e.g. disk read failures) as a cache miss. + } + + return undefined; + } + + has(key: string): boolean { + if (!this.#ensureDb()) { + return false; + } + + try { + return !!this.#hasStmt?.get(key); + } catch { + return false; + } + } + + async set(key: string, value: unknown): Promise { + if (!this.#ensureDb()) { + return this; + } + + try { + this.#pendingAccessedKeys.delete(key); + + const isBinary = value instanceof Uint8Array; + const data = isBinary ? value : serialize(value); + let format = isBinary ? CacheFormat.RawBinary : CacheFormat.V8; + let payload: Uint8Array = data; + + if (data.byteLength >= COMPRESSION_THRESHOLD) { + const compressed = await deflateRawAsync(data, { level: 1 }); + if (compressed.byteLength < data.byteLength) { + payload = compressed; + format = isBinary ? CacheFormat.RawBinaryCompressed : CacheFormat.V8Compressed; + } + } + + this.#setStmt?.run(key, payload, format); + } catch { + // Writing to cache is non-fatal and should not fail the build. + } + + return this; + } + + createCache(namespace: string): Cache { + return new Cache(new NamespacedCacheStore(this, namespace)); + } + + close(): void { + this.#flushAccessUpdates(); + + if (this.#db) { + try { + this.#db.exec('BEGIN IMMEDIATE TRANSACTION;'); + try { + // 1. Delete items older than N days + this.#db + .prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);") + .run(`-${this.ttlDays} days`); + + // 2. Prune oldest items if payload exceeds maxPayloadSize + // Skip the expensive window aggregate query if total database size is below maxPayloadSize + const sizeResult = this.#db + .prepare( + 'SELECT (page_count - freelist_count) * page_size AS total_size ' + + 'FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();', + ) + .get() as { total_size?: number } | undefined; + + if ((sizeResult?.total_size ?? 0) > this.maxPayloadSize) { + this.#db + .prepare( + `DELETE FROM cache WHERE key IN ( + SELECT key FROM ( + SELECT key, + sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size + FROM cache + ) WHERE running_size > ? + );`, + ) + .run(this.maxPayloadSize); + } + + this.#db.exec('COMMIT;'); + } catch (error) { + try { + this.#db.exec('ROLLBACK;'); + } catch { + // Ignore rollback errors if transaction was not active + } + throw error; + } + } catch { + // Pruning errors should not block build success + } finally { + this.#getStmt = undefined; + this.#hasStmt = undefined; + this.#setStmt = undefined; + this.#updateAccessedStmt = undefined; + + try { + this.#db.close(); + } catch { + // Failure to close should not block build success + } + this.#db = undefined; + } + } + + this.#disabled = false; + } +} diff --git a/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts b/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts new file mode 100644 index 000000000000..4eac21f3b34b --- /dev/null +++ b/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts @@ -0,0 +1,630 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { SqliteCacheStore } from './sqlite-cache-store'; + +describe('SqliteCacheStore', () => { + let tempDir: string; + let cachePath: string; + let store: SqliteCacheStore; + + beforeEach(async () => { + // Create a temporary directory in the workspace for testing + tempDir = join(__dirname, `sqlite-test-temp-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + cachePath = join(tempDir, 'test-cache.db'); + store = new SqliteCacheStore(cachePath); + }); + + afterEach(async () => { + store.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should store and retrieve a value', async () => { + const data = { foo: 'bar', list: [1, 2, 3] }; + await store.set('test-key', data); + + const result = await store.get('test-key'); + expect(result).toEqual(data); + }); + + it('should preserve binary values', async () => { + const data = new TextEncoder().encode('export const value = 1;\n'); + await store.set('binary-key', data); + + const result = await store.get('binary-key'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + }); + + it('should preserve binary values nested within an object', async () => { + const data = { + contents: new TextEncoder().encode('export const value = 1;\n'), + loader: 'js', + watchFiles: ['/some/file.js'], + }; + await store.set('nested-binary-key', data); + + const result = await store.get('nested-binary-key'); + expect(result.contents).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + }); + + it('should preserve binary values across store instances', async () => { + const data = new TextEncoder().encode('export const value = 1;\n'); + await store.set('persisted-binary-key', data); + store.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('persisted-binary-key'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupt payload as a cache miss', async () => { + await store.set('corrupt-key', 'value'); + store.close(); + + // Simulate an entry with an invalid/corrupt payload that fails deserialization. + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare('UPDATE cache SET value = ? WHERE key = ?') + .run(new Uint8Array([0x00, 0x01, 0x02]), 'corrupt-key'); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + + it('should automatically recover from a corrupted database file', async () => { + // Write corrupted header data to the database file + await fs.writeFile(cachePath, 'CORRUPTED_DATABASE_FILE_CONTENTS'); + + // The store should detect corruption, reset the database files, and operate normally + await store.set('recover-key', 'recovered-value'); + const result = await store.get('recover-key'); + expect(result).toBe('recovered-value'); + }); + + it('should automatically recover from a corrupted B-tree page', async () => { + // Initialize valid database + await store.set('initial-key', 'initial-val'); + store.close(); + + // Overwrite page data with garbage + const handle = await fs.open(cachePath, 'r+'); + await handle.write(Buffer.alloc(200, 0xff), 0, 200, 100); + await handle.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + await reopenedStore.set('new-key', 'new-val'); + expect(await reopenedStore.get('new-key')).toBe('new-val'); + } finally { + reopenedStore.close(); + } + }); + + it('should gracefully degrade when database path is permanently unwritable', async () => { + // A regular file in place of a directory path ensures unwritability across all platforms (ENOTDIR) + const blockingFile = join(tempDir, 'blocking-file'); + await fs.writeFile(blockingFile, 'cannot-be-a-directory'); + + const unwritableStore = new SqliteCacheStore(join(blockingFile, 'cannot-create.db')); + try { + expect(unwritableStore.has('any-key')).toBeFalse(); + expect(await unwritableStore.get('any-key')).toBeUndefined(); + await unwritableStore.set('any-key', 'any-val'); + expect(await unwritableStore.get('any-key')).toBeUndefined(); + expect(unwritableStore.has('any-key')).toBeFalse(); + } finally { + unwritableStore.close(); + } + }); + + it('should automatically recover from a corrupted journal file', async () => { + await store.set('key-before', 'val-before'); + store.close(); + + // Create a corrupt rollback journal file + await fs.writeFile(cachePath + '-journal', 'CORRUPTED_JOURNAL_FILE'); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + await reopenedStore.set('new-key', 'new-val'); + expect(await reopenedStore.get('new-key')).toBe('new-val'); + } finally { + reopenedStore.close(); + } + }); + + it('should not delete database files when database is locked by another process', async () => { + await store.set('persist-key', 'persist-val'); + store.close(); + + // Open direct connection with an exclusive transaction holding a write lock + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb.exec('BEGIN EXCLUSIVE TRANSACTION;'); + + // Use a tiny busyTimeoutMs so the test fails fast instead of waiting 5s + const lockedStore = new SqliteCacheStore(cachePath, undefined, undefined, 10); + try { + expect(lockedStore.has('persist-key')).toBeFalse(); + expect(await lockedStore.get('persist-key')).toBeUndefined(); + } finally { + lockedStore.close(); + directDb.exec('COMMIT;'); + directDb.close(); + } + + // Verify the original database file and its data were not deleted + const verifyStore = new SqliteCacheStore(cachePath); + try { + expect(await verifyStore.get('persist-key')).toBe('persist-val'); + } finally { + verifyStore.close(); + } + }); + + it('should safely fall back to default timeout if invalid busyTimeoutMs is provided', async () => { + // Pass NaN as busyTimeoutMs + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invalidStore = new SqliteCacheStore(cachePath, undefined, undefined, NaN as any); + try { + await invalidStore.set('valid-key', 'valid-value'); + expect(await invalidStore.get('valid-key')).toBe('valid-value'); + } finally { + invalidStore.close(); + } + }); + + it('should flush pending access updates on close', async () => { + await store.set('flush-key', 'flush-val'); + + // Manually backdate the entry's last_accessed timestamp to simulate elapsed time + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; + directDb + .prepare('UPDATE cache SET last_accessed = ? WHERE key = ?') + .run(pastTimestamp, 'flush-key'); + directDb.close(); + + // Access the key via store.get() to queue an access update + await store.get('flush-key'); + + // Immediately close the store before the debounced 500ms timeout fires + store.close(); + + // Verify the timestamp in SQLite was updated upon close + const checkDb = new DatabaseSync(cachePath); + const row = checkDb + .prepare('SELECT last_accessed FROM cache WHERE key = ?') + .get('flush-key') as { last_accessed: number }; + checkDb.close(); + + expect(row.last_accessed).toBeGreaterThan(pastTimestamp); + }); + + it('should selectively compress entries that exceed the 32KB threshold', async () => { + const largeContent = + 'export const testFunction = () => { console.log("hello world"); };\n'.repeat(1000); + await store.set('large-key', largeContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 1 (CacheFormat.V8Compressed) and compressed size is drastically smaller + expect(row.format).toBe(1); + expect(row.value.byteLength).toBeLessThan(largeContent.length / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-key'); + expect(result).toBe(largeContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries below the 32KB threshold', async () => { + const smallContent = 'export const small = true;'; + await store.set('small-key', smallContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('small-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 0 (CacheFormat.V8) + expect(row.format).toBe(0); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('small-key'); + expect(result).toBe(smallContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries exceeding the 32KB threshold if compression does not reduce size', async () => { + const { randomBytes } = await import('node:crypto'); + // High-entropy random bytes cannot be compressed by DEFLATE + const incompressibleData = new Uint8Array(randomBytes(48 * 1024)); + await store.set('incompressible-key', incompressibleData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('incompressible-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify entry is stored uncompressed as raw binary (format 2 = CacheFormat.RawBinary) + expect(row.format).toBe(2); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('incompressible-key'); + expect(result).toEqual(incompressibleData); + } finally { + reopenedStore.close(); + } + }); + + it('should store raw Uint8Array directly with zero serialization', async () => { + const binaryData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + await store.set('binary-key', binaryData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 2 (CacheFormat.RawBinary) and value is the exact raw bytes + expect(row.format).toBe(2); + expect(row.value).toEqual(binaryData); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('binary-key'); + expect(result).toEqual(binaryData); + } finally { + reopenedStore.close(); + } + }); + + it('should selectively compress large Uint8Array entries', async () => { + const largeBinary = new Uint8Array(Buffer.from('export const x = 1;\n'.repeat(2000))); + await store.set('large-binary-key', largeBinary); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 3 (CacheFormat.RawBinaryCompressed) + expect(row.format).toBe(3); + expect(row.value.byteLength).toBeLessThan(largeBinary.byteLength / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-binary-key'); + expect(result).toEqual(largeBinary); + } finally { + reopenedStore.close(); + } + }); + + it('should seamlessly read legacy entries from databases created without a format column', async () => { + const { serialize } = await import('node:v8'); + const { DatabaseSync } = await import('node:sqlite'); + + // Create a legacy database without a format column + const directDb = new DatabaseSync(cachePath); + directDb.exec( + 'CREATE TABLE cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + ); + const legacyPayload = serialize('legacy-content'); + directDb + .prepare('INSERT INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())') + .run('legacy-key', legacyPayload); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('legacy-key'); + expect(result).toBe('legacy-content'); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupted compressed payload as a cache miss', async () => { + // Initialize database + await store.set('init-key', 'init-val'); + store.close(); + + // Insert an entry with format = 1 (V8Compressed) followed by garbage + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare( + 'INSERT INTO cache (key, value, format, last_accessed) VALUES (?, ?, 1, unixepoch())', + ) + .run('corrupt-compressed-key', new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-compressed-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a non-binary payload as a cache miss', async () => { + await store.set('text-key', 'value'); + store.close(); + + // SQLite column types are dynamic, so a stored value is not guaranteed to be binary. + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb.prepare('UPDATE cache SET value = ? WHERE key = ?').run('"value"', 'text-key'); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('text-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + + it('should return undefined for non-existent key', async () => { + const result = await store.get('missing-key'); + expect(result).toBeUndefined(); + }); + + it('should correctly report existence of a key', async () => { + expect(store.has('exist-key')).toBeFalse(); + + await store.set('exist-key', 'value'); + expect(store.has('exist-key')).toBeTrue(); + }); + + it('should overwrite values for existing keys', async () => { + await store.set('overwrite-key', 'initial'); + await store.set('overwrite-key', 'updated'); + + const result = await store.get('overwrite-key'); + expect(result).toBe('updated'); + }); + + it('should prune items older than TTL on close', async () => { + // Write two items + await store.set('new-key', 'new-val'); + await store.set('old-key', 'old-val'); + + // Close the store so we can modify the DB safely + store.close(); + + // Directly open database to update timestamp of 'old-key' to 15 days ago + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare('UPDATE cache SET last_accessed = unixepoch() - 15 * 24 * 3600 WHERE key = ?') + .run('old-key'); + directDb.close(); + + // Reopen store with a 14-day TTL, access it to open connection, then close to trigger pruning + const pruneStore = new SqliteCacheStore(cachePath, undefined, 14); + expect(pruneStore.has('new-key')).toBeTrue(); + pruneStore.close(); + + // Verify 'old-key' is gone but 'new-key' remains + const checkStore = new SqliteCacheStore(cachePath); + expect(checkStore.has('old-key')).toBeFalse(); + expect(checkStore.has('new-key')).toBeTrue(); + checkStore.close(); + }); + + it('should prune oldest items when total payload size exceeds maximum on close', async () => { + // Close the default store so we can instantiate one with a small limit + store.close(); + + // Create a store with a tiny size limit (e.g. 25 bytes) + // Keys 'k1', 'k2', 'k3' are small (each is 12 bytes: 2 byte key + 10 byte serialized value). + // Total size of k1 + k2 + k3 is 36 bytes, which exceeds the 25 bytes limit. + const sizeStore = new SqliteCacheStore(cachePath, 25); + + // Set k1, then k2, then k3. + // Order of inserts: k1 (oldest), k2 (middle), k3 (newest) + await sizeStore.set('k1', 'value1'); + await sizeStore.set('k2', 'value2'); + await sizeStore.set('k3', 'value3'); + + // Close sizeStore to trigger pruning + sizeStore.close(); + + // Reopen to check which keys were kept + const checkStore = new SqliteCacheStore(cachePath); + // k3 (newest) and k2 (middle) should be kept (~20 bytes total) + // k1 (oldest) should be pruned to get under 25 bytes. + expect(checkStore.has('k3')).toBeTrue(); + expect(checkStore.has('k2')).toBeTrue(); + expect(checkStore.has('k1')).toBeFalse(); + checkStore.close(); + }); + + it('should not prune items when total database size is within maxPayloadSize on close', async () => { + store.close(); + + const sizeStore = new SqliteCacheStore(cachePath, 1024 * 1024); + await sizeStore.set('k1', 'value1'); + await sizeStore.set('k2', 'value2'); + sizeStore.close(); + + const checkStore = new SqliteCacheStore(cachePath); + expect(checkStore.has('k1')).toBeTrue(); + expect(checkStore.has('k2')).toBeTrue(); + checkStore.close(); + }); + + it('should create an index on last_accessed and key', async () => { + // Trigger db initialization + await store.set('test-key', 'test-value'); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + try { + const indexRows = directDb + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'cache' AND name = 'idx_cache_accessed'", + ) + .all(); + expect(indexRows.length).toBe(1); + } finally { + directDb.close(); + } + }); + + it('should create parent directories if they do not exist', async () => { + const nestedDir = join(tempDir, 'nested', 'deeply', 'cache'); + const nestedCachePath = join(nestedDir, 'nested-cache.db'); + const nestedStore = new SqliteCacheStore(nestedCachePath); + + try { + await nestedStore.set('nested-key', 'nested-value'); + const result = await nestedStore.get('nested-key'); + expect(result).toBe('nested-value'); + } finally { + nestedStore.close(); + } + }); + + it('should support in-memory databases', async () => { + const memoryStore = new SqliteCacheStore(':memory:'); + try { + await memoryStore.set('mem-key', 'mem-value'); + const result = await memoryStore.get('mem-key'); + expect(result).toBe('mem-value'); + } finally { + memoryStore.close(); + } + }); + + describe('NG_BUILD_CACHE_STORE env variable option', () => { + it('should force SQLite when NG_BUILD_CACHE_STORE=sqlite', () => { + const code = ` + (async () => { + const { createPersistentCacheStore } = await import('./cache.js'); + const { SqliteCacheStore } = await import('./sqlite-cache-store.js'); + const store = await createPersistentCacheStore('dummy-sqlite-env'); + if (!(store instanceof SqliteCacheStore)) { + console.error('Expected SqliteCacheStore, got:', store.constructor.name); + process.exit(1); + } + })().catch(err => { + console.error(err); + process.exit(2); + }); + `; + const { execFileSync } = require('node:child_process'); + execFileSync(process.execPath, ['--input-type=module', '-e', code], { + cwd: __dirname, + env: { + ...process.env, + NG_BUILD_CACHE_STORE: 'sqlite', + }, + }); + }); + + it('should force LMDB when NG_BUILD_CACHE_STORE=lmdb', () => { + const code = ` + (async () => { + const { createPersistentCacheStore } = await import('./cache.js'); + const { LmdbCacheStore } = await import('./lmdb-cache-store.js'); + const store = await createPersistentCacheStore('dummy-lmdb-env'); + if (!(store instanceof LmdbCacheStore)) { + console.error('Expected LmdbCacheStore, got:', store.constructor.name); + process.exit(1); + } + })().catch(err => { + console.error(err); + process.exit(2); + }); + `; + const { execFileSync } = require('node:child_process'); + try { + execFileSync(process.execPath, ['--input-type=module', '-e', code], { + cwd: __dirname, + env: { + ...process.env, + NG_BUILD_CACHE_STORE: 'lmdb', + }, + }); + } catch (e) { + if (e && typeof e === 'object' && 'message' in e) { + const error = e as { message: string; stderr?: Buffer }; + const output = error.stderr?.toString() || error.message; + if (!output.includes('Unable to initialize JavaScript cache storage')) { + throw e; + } + } else { + throw e; + } + } + }); + }); +}); diff --git a/packages/angular/build/src/utils/check-port.ts b/packages/angular/build/src/utils/check-port.ts new file mode 100644 index 000000000000..d7c04f0b9f72 --- /dev/null +++ b/packages/angular/build/src/utils/check-port.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { createServer } from 'node:net'; +import { isTTY } from './tty'; + +function createInUseError(port: number): Error { + return new Error(`Port ${port} is already in use. Use '--port' to specify a different port.`); +} + +export async function checkPort(port: number, host: string): Promise { + // Disabled due to Vite not handling port 0 and instead always using the default value (5173) + // TODO: Enable this again once Vite is fixed + // if (port === 0) { + // return 0; + // } + + return new Promise((resolve, reject) => { + const server = createServer(); + + server + .once('error', (err: NodeJS.ErrnoException) => { + if (err.code !== 'EADDRINUSE') { + reject(err); + + return; + } + + if (!isTTY()) { + reject(createInUseError(port)); + + return; + } + + import('@inquirer/confirm') + .then(({ default: confirm }) => + confirm({ + message: `Port ${port} is already in use.\nWould you like to use a different port?`, + default: true, + theme: { prefix: '' }, + }), + ) + .then( + (answer) => (answer ? resolve(checkPort(0, host)) : reject(createInUseError(port))), + () => reject(createInUseError(port)), + ); + }) + .once('listening', () => { + // Get the actual address from the listening server instance + const address = server.address(); + assert( + address && typeof address !== 'string', + 'Port check server address should always be an object.', + ); + + server.close(); + resolve(address.port); + }) + .listen(port, host); + }); +} diff --git a/packages/angular/build/src/utils/color.ts b/packages/angular/build/src/utils/color.ts new file mode 100644 index 000000000000..3915d99ce248 --- /dev/null +++ b/packages/angular/build/src/utils/color.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { WriteStream } from 'node:tty'; + +export { color as colors, figures } from 'listr2'; + +export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean { + if (stream instanceof WriteStream) { + return stream.hasColors(); + } + + try { + // The hasColors function does not rely on any instance state and should ideally be static + return WriteStream.prototype.hasColors(); + } catch { + return process.env['FORCE_COLOR'] !== undefined && process.env['FORCE_COLOR'] !== '0'; + } +} diff --git a/packages/angular/build/src/utils/concurrency.ts b/packages/angular/build/src/utils/concurrency.ts new file mode 100644 index 000000000000..39f2b70c95df --- /dev/null +++ b/packages/angular/build/src/utils/concurrency.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * Maximum number of concurrent file system read/stat operations to prevent OS file descriptor exhaustion. + */ +export const MAX_CONCURRENT_READS = 16; + +/** + * Executes an asynchronous function for each item in an array concurrently up to a specified limit. + * + * If any task fails, processing of subsequent items stops and the first encountered error is re-thrown + * after all currently in-flight tasks have settled. + * + * @param items Array of items to process. + * @param limit Maximum number of concurrent tasks in flight. + * @param fn Async task function. + */ +export async function runConcurrent( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return; + } + + let index = 0; + let firstError: unknown; + + const concurrency = Math.min(Math.max(1, Math.floor(limit) || 1), items.length); + const workers = Array.from({ length: concurrency }, async () => { + while (!firstError && index < items.length) { + const i = index++; + try { + await fn(items[i], i); + } catch (error) { + firstError ??= error; + } + } + }); + + await Promise.allSettled(workers); + + if (firstError) { + throw firstError; + } +} + +/** + * Maps an array asynchronously with a sliding worker pool up to a specified concurrency limit. + * + * If any task fails, processing of subsequent items stops and the first encountered error is re-thrown + * after all currently in-flight tasks have settled. + * + * @param items Array of items to map. + * @param limit Maximum number of concurrent tasks in flight. + * @param fn Async mapper function. + * @returns Array of mapped results in the original item order. + */ +export async function mapConcurrent( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const results: R[] = new Array(items.length); + let index = 0; + let firstError: unknown; + + const concurrency = Math.min(Math.max(1, Math.floor(limit) || 1), items.length); + const workers = Array.from({ length: concurrency }, async () => { + while (!firstError && index < items.length) { + const i = index++; + try { + results[i] = await fn(items[i], i); + } catch (error) { + firstError ??= error; + } + } + }); + + await Promise.allSettled(workers); + + if (firstError) { + throw firstError; + } + + return results; +} diff --git a/packages/angular/build/src/utils/concurrency_spec.ts b/packages/angular/build/src/utils/concurrency_spec.ts new file mode 100644 index 000000000000..ab63054046c5 --- /dev/null +++ b/packages/angular/build/src/utils/concurrency_spec.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { mapConcurrent, runConcurrent } from './concurrency'; + +describe('concurrency utilities', () => { + describe('runConcurrent', () => { + it('should process all items in an array', async () => { + const items = [1, 2, 3, 4, 5]; + const processed: number[] = []; + + await runConcurrent(items, 2, async (item) => { + processed.push(item); + }); + + expect(processed.sort((a, b) => a - b)).toEqual(items); + }); + + it('should respect the concurrency limit', async () => { + const items = [10, 20, 30, 40, 50, 60]; + const limit = 2; + let active = 0; + let maxActive = 0; + + await runConcurrent(items, limit, async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + }); + + expect(maxActive).toBeLessThanOrEqual(limit); + }); + + it('should handle non-integer and NaN limits', async () => { + const items = [1, 2, 3]; + const processed: number[] = []; + + await runConcurrent(items, 2.7, async (item) => { + processed.push(item); + }); + expect(processed).toEqual(items); + + const processedNaN: number[] = []; + await runConcurrent(items, NaN, async (item) => { + processedNaN.push(item); + }); + expect(processedNaN).toEqual(items); + }); + + it('should handle an empty array', async () => { + let called = false; + await runConcurrent([], 3, async () => { + called = true; + }); + + expect(called).toBe(false); + }); + + it('should pass item and index to callback', async () => { + const items = ['a', 'b', 'c']; + const passed: { item: string; index: number }[] = []; + + await runConcurrent(items, 2, async (item, index) => { + passed.push({ item, index }); + }); + + expect(passed.sort((a, b) => a.index - b.index)).toEqual([ + { item: 'a', index: 0 }, + { item: 'b', index: 1 }, + { item: 'c', index: 2 }, + ]); + }); + + it('should stop processing new items and rethrow the first error', async () => { + const items = [1, 2, 3, 4, 5, 6]; + const executed: number[] = []; + + await expectAsync( + runConcurrent(items, 1, async (item) => { + executed.push(item); + if (item === 2) { + throw new Error('Task failed'); + } + }), + ).toBeRejectedWithError('Task failed'); + + // Subsequent items should not have been executed + expect(executed).toEqual([1, 2]); + }); + + it('should wait for in-flight tasks to settle when an error occurs', async () => { + const items = [1, 2, 3, 4]; + let task2Finished = false; + + await expectAsync( + runConcurrent(items, 2, async (item) => { + if (item === 1) { + throw new Error('Task 1 failed'); + } + if (item === 2) { + await new Promise((resolve) => setTimeout(resolve, 20)); + task2Finished = true; + } + }), + ).toBeRejectedWithError('Task 1 failed'); + + expect(task2Finished).toBe(true); + }); + }); + + describe('mapConcurrent', () => { + it('should map items and return results in original order', async () => { + const items = [1, 2, 3, 4, 5]; + + const results = await mapConcurrent(items, 2, async (item) => { + // Add varying delay so tasks finish out of order + await new Promise((resolve) => setTimeout(resolve, (5 - item) * 5)); + + return item * 2; + }); + + expect(results).toEqual([2, 4, 6, 8, 10]); + }); + + it('should respect the concurrency limit', async () => { + const items = [1, 2, 3, 4, 5]; + const limit = 2; + let active = 0; + let maxActive = 0; + + await mapConcurrent(items, limit, async (item) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + + return item; + }); + + expect(maxActive).toBeLessThanOrEqual(limit); + }); + + it('should handle non-integer and NaN limits', async () => { + const items = [1, 2, 3]; + + const results = await mapConcurrent(items, 2.7, async (item) => item * 2); + expect(results).toEqual([2, 4, 6]); + + const resultsNaN = await mapConcurrent(items, NaN, async (item) => item * 2); + expect(resultsNaN).toEqual([2, 4, 6]); + }); + + it('should handle an empty array', async () => { + const results = await mapConcurrent([], 3, async (item) => item); + + expect(results).toEqual([]); + }); + + it('should pass item and index to mapper function', async () => { + const items = ['x', 'y', 'z']; + + const results = await mapConcurrent(items, 2, async (item, index) => `${item}:${index}`); + + expect(results).toEqual(['x:0', 'y:1', 'z:2']); + }); + + it('should stop processing new items and rethrow the first error', async () => { + const items = [1, 2, 3, 4, 5, 6]; + const executed: number[] = []; + + await expectAsync( + mapConcurrent(items, 1, async (item) => { + executed.push(item); + if (item === 2) { + throw new Error('Map failed'); + } + + return item; + }), + ).toBeRejectedWithError('Map failed'); + + expect(executed).toEqual([1, 2]); + }); + + it('should wait for in-flight tasks to settle when an error occurs', async () => { + const items = [1, 2, 3, 4]; + let task2Finished = false; + + await expectAsync( + mapConcurrent(items, 2, async (item) => { + if (item === 1) { + throw new Error('Map 1 failed'); + } + if (item === 2) { + await new Promise((resolve) => setTimeout(resolve, 20)); + task2Finished = true; + } + + return item; + }), + ).toBeRejectedWithError('Map 1 failed'); + + expect(task2Finished).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/utils/debug-id.ts b/packages/angular/build/src/utils/debug-id.ts new file mode 100644 index 000000000000..a03fd571ec72 --- /dev/null +++ b/packages/angular/build/src/utils/debug-id.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createHash } from 'node:crypto'; + +/** + * Fixed RFC 4122 namespace UUID used to derive deterministic UUIDv5 build/Debug IDs. + * Treated as 16 raw bytes when fed into the SHA-1 of `namespace || name`. + * + * The exact value is arbitrary but must remain stable across releases so that + * the same source map content always yields the same Debug ID. + */ +const ANGULAR_BUILD_NAMESPACE = Buffer.from('6f9619ff8b86d011b42d00cf4fc964ff', 'hex'); + +/** + * Generates a deterministic UUIDv5 (RFC 4122 §4.3) Debug ID from the given name bytes. + * + * Determinism is recommended by the ECMA-426 "Source Map Debug ID" proposal + * (https://github.com/tc39/ecma426/blob/main/proposals/debug-id.md) so that the + * produced artifacts are stable across builds with the same source content. + * + * @param name Bytes that uniquely identify the artifact (typically the source map content). + * @returns A canonical UUIDv5 string (lowercase, hyphenated). + */ +export function generateDebugId(name: string | Uint8Array): string { + const sha = createHash('sha1').update(ANGULAR_BUILD_NAMESPACE).update(name).digest(); + + // Set version (5) in the high nibble of byte 6. + sha[6] = (sha[6] & 0x0f) | 0x50; + // Set RFC 4122 variant bits (10xx) in byte 8. + sha[8] = (sha[8] & 0x3f) | 0x80; + + const h = sha.toString('hex'); + + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; +} + +/** Pattern matching an existing `//# debugId=` comment at the end of the file. */ +const DEBUG_ID_COMMENT = /(\n\/\/# debugId=[^\r\n]*)(\n\/\/# sourceMappingURL=[^\r\n]*)?(\s*)$/; + +/** Pattern matching the `//# sourceMappingURL=` comment, used to position the debug-id line. */ +const SOURCE_MAPPING_URL_COMMENT = /\n\/\/# sourceMappingURL=[^\r\n]*\s*$/; + +/** + * Inserts (or replaces) a `//# debugId=` comment in the given JavaScript text. + * + * Per ECMA-426, the comment must appear within the last 5 lines and SHOULD be + * placed immediately above any `//# sourceMappingURL=` comment so that existing + * tools that only consult the final line still find the source-map URL. + */ +export function injectDebugIdIntoJs(text: string, id: string): string { + const comment = `//# debugId=${id}`; + + // Replace any existing debugId comment to keep the operation idempotent. + if (DEBUG_ID_COMMENT.test(text)) { + return text.replace(DEBUG_ID_COMMENT, (_, p1, p2, p3) => `\n${comment}${p2 || ''}${p3 || ''}`); + } + + if (SOURCE_MAPPING_URL_COMMENT.test(text)) { + return text.replace(SOURCE_MAPPING_URL_COMMENT, (match) => `\n${comment}${match}`); + } + + // No source map reference; append at the very end on its own line. + return text.endsWith('\n') ? `${text}${comment}\n` : `${text}\n${comment}\n`; +} + +/** + * Sets the top-level `debugId` field on a JSON source map. + * + * Per ECMA-426, source maps embed the same Debug ID under a `debugId` key so + * that consumers can pair a generated file with its source map without relying + * on URL/path conventions. + */ +export function injectDebugIdIntoSourceMap(json: string, id: string): string { + let parsed: Record; + try { + parsed = JSON.parse(json) as Record; + } catch { + // Source map is malformed; do not corrupt it further. + return json; + } + + if (parsed['debugId'] === id) { + return json; + } + + parsed['debugId'] = id; + + // Preserve existing pretty-print indentation when the source map is formatted. + const indent = json.match(/^[^{]*{\r?\n([ \t]+)/)?.[1]; + + return JSON.stringify(parsed, null, indent); +} + +/** + * Strips any existing `debugId` field from the source map JSON string to restore + * the original JSON contents for deterministic/idempotent hashing. + */ +export function stripDebugIdFromSourceMap(json: string): string { + return json.replace(/,\s*"debugId"\s*:\s*"[^"]*"|\s*"debugId"\s*:\s*"[^"]*"\s*,?/g, ''); +} diff --git a/packages/angular/build/src/utils/debug-id_spec.ts b/packages/angular/build/src/utils/debug-id_spec.ts new file mode 100644 index 000000000000..f254e28f6eee --- /dev/null +++ b/packages/angular/build/src/utils/debug-id_spec.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + generateDebugId, + injectDebugIdIntoJs, + injectDebugIdIntoSourceMap, + stripDebugIdFromSourceMap, +} from './debug-id'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('debug-id', () => { + describe('generateDebugId', () => { + it('produces a canonical UUIDv5 string', () => { + expect(generateDebugId('hello')).toMatch(UUID); + expect(generateDebugId(new TextEncoder().encode('hello'))).toMatch(UUID); + }); + + it('is deterministic for identical inputs', () => { + expect(generateDebugId('same content')).toBe(generateDebugId('same content')); + }); + + it('differs for different inputs', () => { + expect(generateDebugId('one')).not.toBe(generateDebugId('two')); + }); + }); + + describe('injectDebugIdIntoJs', () => { + const id = '11111111-2222-5333-9444-555555555555'; + + it('inserts the debugId comment immediately above sourceMappingURL', () => { + const text = 'console.log(1);\n//# sourceMappingURL=foo.js.map\n'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe( + 'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n', + ); + }); + + it('appends the comment when no sourceMappingURL is present', () => { + const text = 'console.log(1);\n'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n'); + }); + + it('appends a leading newline when input does not end with one', () => { + const text = 'console.log(1);'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n'); + }); + + it('replaces an existing debugId comment (idempotent)', () => { + const original = + 'console.log(1);\n//# debugId=00000000-0000-5000-8000-000000000000\n//# sourceMappingURL=foo.js.map\n'; + const result = injectDebugIdIntoJs(original, id); + expect(result).toBe( + 'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n', + ); + // Re-running with the same id is a no-op. + expect(injectDebugIdIntoJs(result, id)).toBe(result); + }); + + it('does not replace a debugId comment that is inside a string or template literal', () => { + const text = + 'const t = `\n//# debugId=00000000-0000-5000-8000-000000000000\n`;\n//# sourceMappingURL=foo.js.map\n'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe( + 'const t = `\n//# debugId=00000000-0000-5000-8000-000000000000\n`;\n' + + `//# debugId=${id}\n` + + '//# sourceMappingURL=foo.js.map\n', + ); + }); + }); + + describe('injectDebugIdIntoSourceMap', () => { + const id = '11111111-2222-5333-9444-555555555555'; + + it('adds a top-level debugId field', () => { + const map = JSON.stringify({ version: 3, sources: ['a.ts'], mappings: '' }); + const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id)); + expect(updated.debugId).toBe(id); + expect(updated.version).toBe(3); + }); + + it('overwrites an existing debugId field', () => { + const map = JSON.stringify({ version: 3, debugId: 'old', mappings: '' }); + const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id)); + expect(updated.debugId).toBe(id); + }); + + it('returns the original input when debugId already matches', () => { + const map = + '{\n "version": 3,\n "debugId": "11111111-2222-5333-9444-555555555555",\n "mappings": ""\n}'; + expect(injectDebugIdIntoSourceMap(map, id)).toBe(map); + }); + + it('preserves indentation when updating a pretty-printed source map', () => { + const map = '{\n\t"version": 3,\n\t"mappings": ""\n}'; + expect(injectDebugIdIntoSourceMap(map, id)).toBe( + '{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}', + ); + }); + + it('returns the original input when JSON is malformed', () => { + const malformed = '{ this is not json'; + expect(injectDebugIdIntoSourceMap(malformed, id)).toBe(malformed); + }); + }); + + describe('stripDebugIdFromSourceMap', () => { + it('removes the debugId field from pretty-printed source maps', () => { + const original = '{\n\t"version": 3,\n\t"mappings": ""\n}'; + const updated = + '{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}'; + expect(stripDebugIdFromSourceMap(updated)).toBe(original); + }); + + it('removes the debugId field from minified source maps', () => { + const original = '{"version":3,"mappings":""}'; + const updated = + '{"version":3,"mappings":"","debugId":"11111111-2222-5333-9444-555555555555"}'; + expect(stripDebugIdFromSourceMap(updated)).toBe(original); + }); + + it('removes the debugId field when it is the first property', () => { + const original = '{\n\t"version": 3,\n\t"mappings": ""\n}'; + const updated = + '{\n\t"debugId": "11111111-2222-5333-9444-555555555555",\n\t"version": 3,\n\t"mappings": ""\n}'; + expect(stripDebugIdFromSourceMap(updated)).toBe(original); + }); + }); +}); diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts new file mode 100644 index 000000000000..1f676e77b759 --- /dev/null +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +/** + * Delete an output directory, but error out if it's the root of the project. + */ +export async function deleteOutputDir( + root: string, + outputPath: string, + emptyOnlyDirectories?: string[], +): Promise { + const resolvedRoot = resolve(root); + const resolvedOutputPath = resolve(resolvedRoot, outputPath); + if (resolvedOutputPath === resolvedRoot) { + throw new Error('Output path MUST not be project root directory!'); + } + + const directoriesToEmpty = emptyOnlyDirectories + ? new Set(emptyOnlyDirectories.map((directory) => join(resolvedOutputPath, directory))) + : undefined; + + // Avoid removing the actual directory to avoid errors in cases where the output + // directory is mounted or symlinked. Instead the contents are removed. + let entries; + try { + entries = await readdir(resolvedOutputPath); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return; + } + throw error; + } + + for (const entry of entries) { + const fullEntry = join(resolvedOutputPath, entry); + + // Leave requested directories. This allows symlinks to continue to function. + if (directoriesToEmpty?.has(fullEntry)) { + await deleteOutputDir(resolvedOutputPath, fullEntry); + continue; + } + + await rm(fullEntry, { force: true, recursive: true, maxRetries: 3 }); + } +} diff --git a/packages/angular/build/src/utils/disk-files.ts b/packages/angular/build/src/utils/disk-files.ts new file mode 100644 index 000000000000..dbbe34ee919e --- /dev/null +++ b/packages/angular/build/src/utils/disk-files.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { constants, cp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { runConcurrent } from './concurrency'; + +/** + * Maximum number of concurrent file system write/copy operations to avoid open file descriptor exhaustion. + */ +const MAX_CONCURRENT_OPS = 64; + +/** + * Represents a file that can be emitted to disk from memory or copied from an existing path on disk. + */ +export type EmittableFile = + { origin: 'memory'; contents: string | Uint8Array } | { origin: 'disk'; source: string }; + +/** + * Writes or copies a collection of files to disk with bounded concurrency, + * ensuring parent directories are created once per invocation. + * + * @param files The collection of files to emit. + * @param resolveDestinationPath Callback returning the absolute destination path for a file, or `undefined` to skip it. + */ +export async function emitFilesToDisk( + files: readonly T[], + resolveDestinationPath: (file: T) => string | undefined, +): Promise { + if (files.length === 0) { + return; + } + + const directoryCreationPromises = new Map>(); + + await runConcurrent(files, MAX_CONCURRENT_OPS, async (file) => { + const fullFilePath = resolveDestinationPath(file); + if (!fullFilePath) { + return; + } + + // Ensure output subdirectories exist + const fileBasePath = dirname(fullFilePath); + if (fileBasePath) { + let mkdirPromise = directoryCreationPromises.get(fileBasePath); + if (!mkdirPromise) { + mkdirPromise = mkdir(fileBasePath, { recursive: true }); + directoryCreationPromises.set(fileBasePath, mkdirPromise); + } + await mkdirPromise; + } + + if (file.origin === 'memory') { + // Write file contents + await writeFile(fullFilePath, file.contents); + + return; + } + + // Copy file contents + await cp(file.source, fullFilePath, { + mode: constants.COPYFILE_FICLONE, + preserveTimestamps: true, + }); + }); +} + +/** + * Deletes a collection of files from disk with bounded concurrency. + * @param files The collection of files to delete. + */ +export async function deleteFilesFromDisk(files: readonly string[]): Promise { + if (files.length === 0) { + return; + } + + await runConcurrent(files, MAX_CONCURRENT_OPS, (file) => rm(file, { force: true })); +} diff --git a/packages/angular/build/src/utils/disk-files_spec.ts b/packages/angular/build/src/utils/disk-files_spec.ts new file mode 100644 index 000000000000..4a9422bd4373 --- /dev/null +++ b/packages/angular/build/src/utils/disk-files_spec.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type EmittableFile, deleteFilesFromDisk, emitFilesToDisk } from './disk-files'; + +describe('disk-files', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-disk-files-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + describe('emitFilesToDisk', () => { + it('should write memory files and copy disk files while creating nested directories', async () => { + const sourceFile = join(tempRoot, 'source-asset.txt'); + await writeFile(sourceFile, 'from-disk'); + + const files: (EmittableFile & { path: string })[] = [ + { + origin: 'memory', + path: 'nested/a/mem1.txt', + contents: new TextEncoder().encode('mem-1'), + }, + { origin: 'memory', path: 'nested/a/mem2.txt', contents: 'mem-2' }, + { origin: 'disk', path: 'nested/b/disk1.txt', source: sourceFile }, + { origin: 'disk', path: 'nested/c/disk2.txt', source: sourceFile }, + { origin: 'memory', path: 'skipped/file.txt', contents: 'should-not-exist' }, + ]; + + const outDir = join(tempRoot, 'out'); + await emitFilesToDisk(files, (file) => + file.path.startsWith('skipped/') ? undefined : join(outDir, file.path), + ); + + expect(await readFile(join(outDir, 'nested/a/mem1.txt'), 'utf-8')).toBe('mem-1'); + expect(await readFile(join(outDir, 'nested/a/mem2.txt'), 'utf-8')).toBe('mem-2'); + expect(await readFile(join(outDir, 'nested/b/disk1.txt'), 'utf-8')).toBe('from-disk'); + expect(await readFile(join(outDir, 'nested/c/disk2.txt'), 'utf-8')).toBe('from-disk'); + expect(existsSync(join(outDir, 'skipped/file.txt'))).toBeFalse(); + }); + }); + + describe('deleteFilesFromDisk', () => { + it('should delete existing files and ignore non-existent files', async () => { + const fileA = join(tempRoot, 'a.txt'); + const fileB = join(tempRoot, 'b.txt'); + const nonExistent = join(tempRoot, 'missing.txt'); + + await writeFile(fileA, 'a'); + await writeFile(fileB, 'b'); + + await deleteFilesFromDisk([fileA, nonExistent]); + + expect(existsSync(fileA)).toBeFalse(); + expect(existsSync(fileB)).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts new file mode 100644 index 000000000000..d87c104aba65 --- /dev/null +++ b/packages/angular/build/src/utils/environment-options.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { availableParallelism } from 'node:os'; + +/** A set of strings that are considered "truthy" when parsing environment variables. */ +const TRUTHY_VALUES = new Set(['1', 'true']); + +/** A set of strings that are considered "falsy" when parsing environment variables. */ +const FALSY_VALUES = new Set(['0', 'false']); + +/** + * Checks if an environment variable is present and has a non-empty value. + * @param variable The environment variable to check. + * @returns `true` if the variable is a non-empty string. + */ +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +/** + * Parses an environment variable into a boolean or undefined. + * @returns `true` if the variable is truthy ('1', 'true'). + * @returns `false` if the variable is falsy ('0', 'false'). + * @returns `undefined` if the variable is not present or has an unknown value. + */ +function parseTristate(variable: string | undefined): boolean | undefined { + if (!isPresent(variable)) { + return undefined; + } + + const value = variable.toLowerCase(); + if (TRUTHY_VALUES.has(value)) { + return true; + } + if (FALSY_VALUES.has(value)) { + return false; + } + + // TODO: Consider whether a warning is useful in this case of a malformed value + return undefined; +} + +// Optimization and mangling +const debugOptimizeVariable = process.env['NG_BUILD_DEBUG_OPTIMIZE']; +const debugOptimize = (() => { + if (!isPresent(debugOptimizeVariable) || parseTristate(debugOptimizeVariable) === false) { + return { + mangle: true, + minify: true, + beautify: false, + }; + } + + const debugValue = { + mangle: false, + minify: false, + beautify: true, + }; + + if (parseTristate(debugOptimizeVariable) === true) { + return debugValue; + } + + for (const part of debugOptimizeVariable.split(',')) { + switch (part.trim().toLowerCase()) { + case 'mangle': + debugValue.mangle = true; + break; + case 'minify': + debugValue.minify = true; + break; + case 'beautify': + debugValue.beautify = true; + break; + } + } + + return debugValue; +})(); + +/** + * Allows disabling of code mangling when the `NG_BUILD_MANGLE` environment variable is set to `0` or `false`. + * This is useful for debugging build output. + */ +export const allowMangle = parseTristate(process.env['NG_BUILD_MANGLE']) ?? debugOptimize.mangle; + +/** + * Allows beautification of build output when the `NG_BUILD_DEBUG_OPTIMIZE` environment variable is enabled. + * This is useful for debugging build output. + */ +export const shouldBeautify = debugOptimize.beautify; + +/** + * Allows disabling of code minification when the `NG_BUILD_DEBUG_OPTIMIZE` environment variable is enabled. + * This is useful for debugging build output. + */ +export const allowMinify = debugOptimize.minify; + +/** + * Allows using Rolldown for chunk optimization instead of Rollup. + * This is useful for debugging and testing scenarios. + */ +export const useRolldownChunks = parseTristate(process.env['NG_BUILD_CHUNKS_ROLLDOWN']) ?? true; + +const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; + +let customMaxWorkers: number | undefined; +if (isPresent(maxWorkersVariable)) { + const parsed = +maxWorkersVariable; + if (Number.isInteger(parsed) && parsed >= 1) { + customMaxWorkers = parsed; + } +} + +/** + * Whether the maximum number of workers was explicitly configured via the + * `NG_BUILD_MAX_WORKERS` environment variable. + */ +export const hasCustomMaxWorkers = customMaxWorkers !== undefined; + +/** + * The maximum number of workers to use for parallel processing. + * This can be controlled by the `NG_BUILD_MAX_WORKERS` environment variable. + * When not set, defaults to available parallelism minus one to ensure the main thread is not starved. + */ +export const maxWorkers = customMaxWorkers ?? Math.max(availableParallelism() - 1, 1); + +/** + * The maximum number of workers to use for JavaScript transformations during bundling. + * Transformation tasks are short-lived, and esbuild concurrently utilizes all CPU cores + * for bundling. To prevent CPU starvation and thread startup overhead, concurrency is + * budgeted to a fraction of available cores, capped at 6, unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxTransformWorkers = + customMaxWorkers ?? Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + +/** + * The maximum number of workers to use for i18n translation inlining. + * Translation inlining and sourcemap remapping are CPU- and memory-intensive operations. + * To prevent thread oversubscription, memory allocator lock contention, and high-core + * performance degradation, concurrency is capped at 8 unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxInlinerWorkers = customMaxWorkers ?? Math.min(8, availableParallelism()); + +/** + * When `NG_BUILD_PARALLEL_TS` is set to `0` or `false`, parallel TypeScript compilation is disabled. + */ +export const useParallelTs = parseTristate(process.env['NG_BUILD_PARALLEL_TS']) !== false; + +/** + * When `NG_BUILD_DEBUG_PERF` is enabled, performance debugging information is printed. + */ +export const debugPerformance = parseTristate(process.env['NG_BUILD_DEBUG_PERF']) === true; + +/** + * When `NG_BUILD_WATCH_ROOT` is enabled, the build will watch the root directory for changes. + */ +export const shouldWatchRoot = parseTristate(process.env['NG_BUILD_WATCH_ROOT']) === true; + +/** + * When `NG_BUILD_TYPE_CHECK` is set to `0` or `false`, type checking is disabled. + */ +export const useTypeChecking = parseTristate(process.env['NG_BUILD_TYPE_CHECK']) !== false; + +/** + * When `NG_BUILD_LOGS_JSON` is enabled, build logs will be output in JSON format. + */ +export const useJSONBuildLogs = parseTristate(process.env['NG_BUILD_LOGS_JSON']) === true; + +/** + * When `NG_BUILD_OPTIMIZE_CHUNKS` is enabled, the build will optimize chunks. + */ +/** + * The threshold of lazy chunks required to enable the chunk optimization pass. + * Can be configured via the `NG_BUILD_OPTIMIZE_CHUNKS` environment variable. + * - `false` or `0` disables the feature. + * - `true` or `1` forces the feature on (threshold 0). + * - A number sets the specific threshold. + * - Default is 3. + */ +const optimizeChunksEnv = process.env['NG_BUILD_OPTIMIZE_CHUNKS']; +export const optimizeChunksThreshold = (() => { + if (optimizeChunksEnv === undefined) { + return 3; + } + if (optimizeChunksEnv === 'false' || optimizeChunksEnv === '0') { + return Infinity; + } + if (optimizeChunksEnv === 'true' || optimizeChunksEnv === '1') { + return 0; + } + const num = Number.parseInt(optimizeChunksEnv, 10); + + return Number.isNaN(num) || num < 0 ? 3 : num; +})(); + +/** + * When `NG_HMR_CSTYLES` is enabled, component styles will be hot-reloaded. + */ +export const useComponentStyleHmr = parseTristate(process.env['NG_HMR_CSTYLES']) === true; + +/** + * When `NG_HMR_TEMPLATES` is set to `0` or `false`, component templates will not be hot-reloaded. + */ +export const useComponentTemplateHmr = parseTristate(process.env['NG_HMR_TEMPLATES']) !== false; + +/** + * When `NG_BUILD_PARTIAL_SSR` is enabled, a partial server-side rendering build will be performed. + */ +export const usePartialSsrBuild = parseTristate(process.env['NG_BUILD_PARTIAL_SSR']) === true; + +/** + * When `NG_BUILD_BABEL_LINKER` is enabled (`1` or `true`), the Babel-based + * Angular Linker (`@angular/compiler-cli/linker/babel`) will be used instead of the + * default OXC in-place linker. + */ +export const useBabelLinker = parseTristate(process.env['NG_BUILD_BABEL_LINKER']) === true; + +/** + * When `NG_BUILD_SASS_EMBEDDED` is set to `0` or `false`, or when running within a + * WebContainer environment, the native embedded Sass compiler is disabled + * and the pure-JavaScript Sass compiler is used instead. + */ +export const useSassEmbedded = + !process.versions.webcontainer && parseTristate(process.env['NG_BUILD_SASS_EMBEDDED']) !== false; + +const bazelBinDirectory = process.env['BAZEL_BINDIR']; +const bazelExecRoot = process.env['JS_BINARY__EXECROOT']; + +export const bazelEsbuildPluginPath = + bazelBinDirectory && bazelExecRoot + ? process.env['NG_INTERNAL_ESBUILD_PLUGINS_DO_NOT_USE'] + : undefined; + +/** + * The persistent cache store configuration to use. + * Managed by the `NG_BUILD_CACHE_STORE` environment variable. + * - 'lmdb': Forces the use of LMDB. + * - 'sqlite': Forces the use of SQLite. + * - undefined / 'auto' / other: Automatically uses LMDB and falls back to SQLite. + */ +export const persistentCacheStoreSetting = (() => { + const env = process.env['NG_BUILD_CACHE_STORE']?.trim().toLowerCase(); + if (env === 'lmdb' || env === 'sqlite') { + return env; + } + + return undefined; +})(); diff --git a/packages/angular/build/src/utils/environment-options_spec.ts b/packages/angular/build/src/utils/environment-options_spec.ts new file mode 100644 index 000000000000..4ad098a24763 --- /dev/null +++ b/packages/angular/build/src/utils/environment-options_spec.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { availableParallelism } from 'node:os'; + +describe('environment options - maxWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxWorkers to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(8); + }); + + it('allows maxWorkers greater than 4 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(32); + }); + + it('supports maxWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(1); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is 0', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is not a number', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is a float', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '2.5'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is an empty string', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ''; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is whitespace only', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ' '; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('parses positive integers with surrounding whitespace', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ' 8 '; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(8); + }); +}); + +describe('environment options - maxTransformWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxTransformWorkers to availableParallelism / 4 (bounded between 1 and 6) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(maxTransformWorkers).toBe(expected); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(8); + }); + + it('allows maxTransformWorkers greater than 6 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(32); + }); + + it('supports maxTransformWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(1); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxTransformWorkers: zeroWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(zeroWorkers).toBe(expected); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxTransformWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(expected); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + + expect(maxTransformWorkers).toBe(expected); + }); +}); + +describe('environment options - maxInlinerWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxInlinerWorkers to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(Math.min(8, availableParallelism())); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '4'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(4); + }); + + it('allows maxInlinerWorkers greater than 8 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '16'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(16); + }); + + it('supports maxInlinerWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(1); + }); + + it('falls back to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxInlinerWorkers: zeroWorkers } = loadEnvironmentOptions(); + expect(zeroWorkers).toBe(Math.min(8, availableParallelism())); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxInlinerWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(Math.min(8, availableParallelism())); + }); + + it('falls back to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(Math.min(8, availableParallelism())); + }); +}); diff --git a/packages/angular/build/src/utils/error.ts b/packages/angular/build/src/utils/error.ts new file mode 100644 index 000000000000..0ca77c331d2d --- /dev/null +++ b/packages/angular/build/src/utils/error.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); +} diff --git a/packages/angular/build/src/utils/format-bytes.ts b/packages/angular/build/src/utils/format-bytes.ts new file mode 100644 index 000000000000..5c9ecee6875a --- /dev/null +++ b/packages/angular/build/src/utils/format-bytes.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export function formatSize(size: number): string { + if (size <= 0) { + return '0 bytes'; + } + + const abbreviations = ['bytes', 'kB', 'MB', 'GB']; + const index = Math.floor(Math.log(size) / Math.log(1000)); + const roundedSize = size / Math.pow(1000, index); + // bytes don't have a fraction + const fractionDigits = index === 0 ? 0 : 2; + + return `${roundedSize.toFixed(fractionDigits)} ${abbreviations[index]}`; +} diff --git a/packages/angular/build/src/utils/format-bytes_spec.ts b/packages/angular/build/src/utils/format-bytes_spec.ts new file mode 100644 index 000000000000..63cb3f761ecf --- /dev/null +++ b/packages/angular/build/src/utils/format-bytes_spec.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { formatSize } from './format-bytes'; + +describe('formatSize', () => { + it('1000 bytes to be 1kB', () => { + expect(formatSize(1000)).toBe('1.00 kB'); + }); + + it('1_000_000 bytes to be 1MB', () => { + expect(formatSize(1_000_000)).toBe('1.00 MB'); + }); + + it('1_500_000 bytes to be 1.5MB', () => { + expect(formatSize(1_500_000)).toBe('1.50 MB'); + }); + + it('1_000_000_000 bytes to be 1GB', () => { + expect(formatSize(1_000_000_000)).toBe('1.00 GB'); + }); +}); diff --git a/packages/angular/build/src/utils/hash.ts b/packages/angular/build/src/utils/hash.ts new file mode 100644 index 000000000000..fdc5565afbb1 --- /dev/null +++ b/packages/angular/build/src/utils/hash.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import type { XXHashAPI } from 'xxhash-wasm'; + +let xxhashInstance: XXHashAPI | undefined; +let xxhashPromise: Promise | undefined; + +/** + * Initializes the xxHash WASM instance early to ensure synchronous hashing uses xxHash. + */ +export async function initializeHash(): Promise { + if (xxhashInstance) { + return; + } + + xxhashPromise ??= import('xxhash-wasm').then((m) => m.default()); + xxhashInstance = await xxhashPromise; +} + +function getXxhash(): XXHashAPI { + assert( + xxhashInstance, + 'Hash utility must be initialized by awaiting `initializeHash()` before use.', + ); + + return xxhashInstance; +} + +/** + * Calculates a fast 64-bit non-cryptographic hash of the provided content. + * Suitable for cache keys, ETags, and change detection. + */ +export function calculateHash(data: string | Uint8Array): string { + const instance = getXxhash(); + + if (typeof data === 'string') { + return instance.h64ToString(data); + } + + return instance.h64Raw(data).toString(16).padStart(16, '0'); +} + +export interface ContentHasher { + update(data: string | Uint8Array): ContentHasher; + digest(): string; +} + +/** + * Creates a streaming 64-bit non-cryptographic content hasher. + */ +export function createContentHash(): ContentHasher { + const instance = getXxhash(); + const hasher = instance.create64(); + + const contentHasher: ContentHasher = { + update(data: string | Uint8Array): ContentHasher { + hasher.update(data); + + return contentHasher; + }, + digest(): string { + return hasher.digest().toString(16).padStart(16, '0'); + }, + }; + + return contentHasher; +} diff --git a/packages/angular/build/src/utils/hash_spec.ts b/packages/angular/build/src/utils/hash_spec.ts new file mode 100644 index 000000000000..77fcbc909403 --- /dev/null +++ b/packages/angular/build/src/utils/hash_spec.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { calculateHash, createContentHash, initializeHash } from './hash'; + +describe('hash utility', () => { + beforeAll(async () => { + await initializeHash(); + }); + + it('should calculate identical 64-bit hex hash for string and Buffer with same content', () => { + const text = 'export const message = "hello world";'; + const buffer = Buffer.from(text, 'utf-8'); + + const stringHash = calculateHash(text); + const bufferHash = calculateHash(buffer); + + expect(typeof stringHash).toBe('string'); + expect(stringHash.length).toBe(16); + expect(stringHash).toBe(bufferHash); + }); + + it('should calculate different hashes for different contents', () => { + const hash1 = calculateHash('const a = 1;'); + const hash2 = calculateHash('const a = 2;'); + + expect(hash1).not.toBe(hash2); + }); + + it('should support streaming multi-part hashing matching combined single-shot hash', () => { + const part1 = 'header: '; + const part2 = 'body content: '; + const part3 = 'footer'; + + const hasher = createContentHash(); + hasher.update(part1); + hasher.update(part2); + hasher.update(Buffer.from(part3, 'utf-8')); + const streamingHash = hasher.digest(); + + const singleShotHash = calculateHash(part1 + part2 + part3); + + expect(streamingHash.length).toBe(16); + expect(streamingHash).toBe(singleShotHash); + }); + + it('should handle Uint8Array chunks in streaming hasher', () => { + const hasher = createContentHash(); + hasher.update(new Uint8Array([1, 2, 3, 4])).update('some-string'); + const digest = hasher.digest(); + + expect(typeof digest).toBe('string'); + expect(digest.length).toBe(16); + }); +}); diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts new file mode 100644 index 000000000000..9a55656e6798 --- /dev/null +++ b/packages/angular/build/src/utils/i18n-options.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import path from 'node:path'; +import type { TranslationLoader } from './load-translations'; + +export interface LocaleDescription { + files: { + path: string; + integrity?: string; + format?: string; + }[]; + translation?: Record; + dataPath?: string; + baseHref?: string; + subPath: string; +} + +export interface I18nOptions { + inlineLocales: Set; + sourceLocale: string; + locales: Record; + flatOutput?: boolean; + readonly shouldInline: boolean; + hasDefinedSourceLocale?: boolean; + localizeVersion?: string; +} + +function normalizeTranslationFileOption( + option: unknown, + locale: string, + expectObjectInError: boolean, +): string[] { + if (typeof option === 'string') { + return [option]; + } + + if (Array.isArray(option) && option.every((element) => typeof element === 'string')) { + return option; + } + + let errorMessage = `Project i18n locales translation field value for '${locale}' is malformed. `; + if (expectObjectInError) { + errorMessage += 'Expected a string, array of strings, or object.'; + } else { + errorMessage += 'Expected a string or array of strings.'; + } + + throw new Error(errorMessage); +} + +function ensureObject(value: unknown, name: string): asserts value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Project field '${name}' is malformed. Expected an object.`); + } +} + +function ensureString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string') { + throw new Error(`Project field '${name}' is malformed. Expected a string.`); + } +} + +function ensureValidSubPath(value: unknown, name: string): asserts value is string { + ensureString(value, name); + + if (!/^[\w-]*$/.test(value)) { + throw new Error( + `Project field '${name}' is invalid. It can only contain letters, numbers, hyphens, and underscores.`, + ); + } +} +export function createI18nOptions( + projectMetadata: { i18n?: unknown }, + inline?: boolean | string[], + logger?: { + warn(message: string): void; + }, + ssrEnabled?: boolean, +): I18nOptions { + const { i18n: metadata = {} } = projectMetadata; + + ensureObject(metadata, 'i18n'); + + const i18n: I18nOptions = { + inlineLocales: new Set(), + // en-US is the default locale added to Angular applications (https://angular.dev/guide/i18n/format-data-locale) + sourceLocale: 'en-US', + locales: {}, + get shouldInline() { + return this.inlineLocales.size > 0; + }, + }; + + let rawSourceLocale: string | undefined; + let rawSourceLocaleBaseHref: string | undefined; + let rawsubPath: string | undefined; + if (typeof metadata.sourceLocale === 'string') { + rawSourceLocale = metadata.sourceLocale; + } else if (metadata.sourceLocale !== undefined) { + ensureObject(metadata.sourceLocale, 'i18n.sourceLocale'); + + if (metadata.sourceLocale.code !== undefined) { + ensureString(metadata.sourceLocale.code, 'i18n.sourceLocale.code'); + rawSourceLocale = metadata.sourceLocale.code; + } + + if (metadata.sourceLocale.baseHref !== undefined) { + ensureString(metadata.sourceLocale.baseHref, 'i18n.sourceLocale.baseHref'); + if (ssrEnabled) { + logger?.warn( + `'baseHref' in 'i18n.sourceLocale' may lead to undefined behavior when used with SSR. ` + + `Consider using 'subPath' instead.\n\n` + + `Note: 'subPath' specifies the URL segment for the locale, serving as both the HTML base HREF ` + + `and the output directory name.\nBy default, if not explicitly set, 'subPath' defaults to the locale code.`, + ); + } + + rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref; + } + + if (metadata.sourceLocale.subPath !== undefined) { + ensureValidSubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath'); + rawsubPath = metadata.sourceLocale.subPath; + } + + if (rawsubPath !== undefined && rawSourceLocaleBaseHref !== undefined) { + throw new Error( + `'i18n.sourceLocale.subPath' and 'i18n.sourceLocale.baseHref' cannot be used together.`, + ); + } + } + + if (rawSourceLocale !== undefined) { + i18n.sourceLocale = rawSourceLocale; + i18n.hasDefinedSourceLocale = true; + } + + i18n.locales[i18n.sourceLocale] = { + files: [], + baseHref: rawSourceLocaleBaseHref, + subPath: rawsubPath ?? i18n.sourceLocale, + }; + + if (metadata.locales !== undefined) { + ensureObject(metadata.locales, 'i18n locales'); + + for (const [locale, options] of Object.entries(metadata.locales)) { + let translationFiles: string[] | undefined; + let baseHref: string | undefined; + let subPath: string | undefined; + + if (options && typeof options === 'object' && 'translation' in options) { + translationFiles = normalizeTranslationFileOption(options.translation, locale, false); + + if ('baseHref' in options) { + ensureString(options.baseHref, `i18n.locales.${locale}.baseHref`); + + if (ssrEnabled) { + logger?.warn( + `'baseHref' in 'i18n.locales.${locale}' may lead to undefined behavior when used with SSR. ` + + `Consider using 'subPath' instead.\n\n` + + `Note: 'subPath' specifies the URL segment for the locale, serving as both the HTML base HREF ` + + `and the output directory name.\nBy default, if not explicitly set, 'subPath' defaults to the locale code.`, + ); + } + baseHref = options.baseHref; + } + + if ('subPath' in options) { + ensureValidSubPath(options.subPath, `i18n.locales.${locale}.subPath`); + subPath = options.subPath; + } + + if (subPath !== undefined && baseHref !== undefined) { + throw new Error( + `'i18n.locales.${locale}.subPath' and 'i18n.locales.${locale}.baseHref' cannot be used together.`, + ); + } + } else { + translationFiles = normalizeTranslationFileOption(options, locale, true); + } + + if (locale === i18n.sourceLocale) { + throw new Error( + `An i18n locale ('${locale}') cannot both be a source locale and provide a translation.`, + ); + } + + i18n.locales[locale] = { + files: translationFiles.map((file) => ({ path: file })), + baseHref, + subPath: subPath ?? locale, + }; + } + } + + if (inline === true) { + i18n.inlineLocales.add(i18n.sourceLocale); + Object.keys(i18n.locales).forEach((locale) => i18n.inlineLocales.add(locale)); + } else if (inline) { + for (const locale of inline) { + if (!i18n.locales[locale] && i18n.sourceLocale !== locale) { + throw new Error(`Requested locale '${locale}' is not defined for the project.`); + } + + i18n.inlineLocales.add(locale); + } + } + + // Check that subPaths are unique only the locales that we are inlining. + const localesData = Object.entries(i18n.locales).filter(([locale]) => + i18n.inlineLocales.has(locale), + ); + + for (let i = 0; i < localesData.length; i++) { + const [localeA, { subPath: subPathA }] = localesData[i]; + + for (let j = i + 1; j < localesData.length; j++) { + const [localeB, { subPath: subPathB }] = localesData[j]; + + if (subPathA === subPathB) { + throw new Error( + `Invalid i18n configuration: Locales '${localeA}' and '${localeB}' cannot have the same subPath: '${subPathB}'.`, + ); + } + } + } + + return i18n; +} + +export function loadTranslations( + locale: string, + desc: LocaleDescription, + workspaceRoot: string, + loader: TranslationLoader, + logger: { warn: (message: string) => void; error: (message: string) => void }, + usedFormats?: Set, + duplicateTranslation?: 'ignore' | 'error' | 'warning', +) { + let translations: Record | undefined = undefined; + for (const file of desc.files) { + const loadResult = loader(path.join(workspaceRoot, file.path)); + + for (const diagnostics of loadResult.diagnostics.messages) { + if (diagnostics.type === 'error') { + logger.error(`Error parsing translation file '${file.path}': ${diagnostics.message}`); + } else { + logger.warn(`WARNING [${file.path}]: ${diagnostics.message}`); + } + } + + if (loadResult.locale !== undefined && loadResult.locale !== locale) { + logger.warn( + `WARNING [${file.path}]: File target locale ('${loadResult.locale}') does not match configured locale ('${locale}')`, + ); + } + + usedFormats?.add(loadResult.format); + file.format = loadResult.format; + file.integrity = loadResult.integrity; + + if (translations) { + // Merge translations + for (const [id, message] of Object.entries(loadResult.translations)) { + if (translations[id] !== undefined) { + const duplicateTranslationMessage = `[${file.path}]: Duplicate translations for message '${id}' when merging.`; + switch (duplicateTranslation) { + case 'ignore': + break; + case 'error': + logger.error(`ERROR ${duplicateTranslationMessage}`); + break; + case 'warning': + default: + logger.warn(`WARNING ${duplicateTranslationMessage}`); + break; + } + } + translations[id] = message; + } + } else { + // First or only translation file + translations = loadResult.translations; + } + } + desc.translation = translations; +} diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract.ts new file mode 100644 index 000000000000..749a489f8a4a --- /dev/null +++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { htmlRewritingStream } from './html-rewriting-stream'; + +let jsActionContractScript: string; + +export async function addEventDispatchContract(html: string): Promise { + const { rewriter, transformedContent } = await htmlRewritingStream(html); + + jsActionContractScript ??= + ''; + + rewriter.on('startTag', (tag) => { + rewriter.emitStartTag(tag); + + if (tag.tagName === 'body') { + rewriter.emitRaw(jsActionContractScript); + } + }); + + return transformedContent(); +} diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts new file mode 100644 index 000000000000..6c0747730c29 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { addEventDispatchContract } from './add-event-dispatch-contract'; + +describe('addEventDispatchContract', () => { + it('should inline event dispatcher script', async () => { + const result = await addEventDispatchContract(` + + + +

Hello World!

+ + + `); + + expect(result).toMatch( + /\s*`); + } + + let subResourceIntegrityTag: string | undefined; + let headerLinkTags: string[] = []; + let bodyLinkTags: string[] = []; + + // Emit an integrity-only import map so the browser can validate lazy chunks + // resolved via dynamic `import()` (which otherwise carry no SRI metadata). + // The block is placed first inside `` so it precedes any module + // script, as required by the import-map spec. + if (sri && chunksIntegrity?.size) { + const integrity: Record = {}; + // Stable iteration order for reproducible builds. + const sortedEntries = [...chunksIntegrity.entries()].sort(([keyA], [keyB]) => + keyA.localeCompare(keyB), + ); + for (const [url, integrityHash] of sortedEntries) { + const resolvedUrl = generateUrl(url, deployUrl); + const key = RESOLVABLE_URL_REGEXP.test(resolvedUrl) ? resolvedUrl : `./${resolvedUrl}`; + integrity[key] = integrityHash; + } + const importMapJson = JSON.stringify({ integrity }).replace(/${importMapJson}`; + } + + for (const src of stylesheets) { + const attrs = [`rel="stylesheet"`, `href="${generateUrl(src, deployUrl)}"`]; + + if (crossOrigin !== 'none') { + attrs.push(`crossorigin="${crossOrigin}"`); + } + + if (sri) { + const content = await loadOutputFile(src); + attrs.push(generateSriAttributes(content)); + } + + headerLinkTags.push(``); + } + + if (params.hints?.length) { + for (const hint of params.hints) { + const attrs = [`rel="${hint.mode}"`, `href="${generateUrl(hint.url, deployUrl)}"`]; + + if (hint.mode !== 'modulepreload' && crossOrigin !== 'none') { + // Value is considered anonymous by the browser when not present or empty + attrs.push(crossOrigin === 'anonymous' ? 'crossorigin' : `crossorigin="${crossOrigin}"`); + } + + if (hint.mode === 'preload' || hint.mode === 'prefetch') { + switch (extname(hint.url)) { + case '.js': + attrs.push('as="script"'); + break; + case '.css': + attrs.push('as="style"'); + break; + default: + if (hint.as) { + attrs.push(`as="${hint.as}"`); + } + break; + } + } + + if ( + sri && + (hint.mode === 'preload' || hint.mode === 'prefetch' || hint.mode === 'modulepreload') + ) { + const content = await loadOutputFile(hint.url); + attrs.push(generateSriAttributes(content)); + } + + const tag = ``; + if (hint.mode === 'modulepreload') { + // Module preloads should be placed by the inserted script elements in the body since + // they are only useful in combination with the scripts. + bodyLinkTags.push(tag); + } else { + headerLinkTags.push(tag); + } + } + } + + const dir = lang ? await getLanguageDirection(lang, warnings) : undefined; + const { rewriter, transformedContent } = await htmlRewritingStream(html); + const baseTagExists = html.includes('(); + + rewriter + .on('startTag', (tag, rawTagHtml) => { + switch (tag.tagName) { + case 'html': + // Adjust document locale if specified + if (isString(lang)) { + updateAttribute(tag, 'lang', lang); + } + + if (dir) { + updateAttribute(tag, 'dir', dir); + } + break; + case 'head': + // Base href should be added before any link, meta tags + if (!baseTagExists && isString(baseHref)) { + rewriter.emitStartTag(tag); + rewriter.emitRaw(``); + if (subResourceIntegrityTag) { + rewriter.emitRaw(subResourceIntegrityTag); + } + + return; + } + break; + case 'base': + // Adjust base href if specified + if (isString(baseHref)) { + updateAttribute(tag, 'href', baseHref); + } + + if (subResourceIntegrityTag) { + rewriter.emitRaw(subResourceIntegrityTag); + } + + break; + case 'link': + if (readAttribute(tag, 'rel') === 'preconnect') { + const href = readAttribute(tag, 'href'); + if (href) { + foundPreconnects.add(href); + } + } + break; + default: + if (tag.selfClosing && !VALID_SELF_CLOSING_TAGS.has(tag.tagName)) { + errors.push(`Invalid self-closing element in index HTML file: '${rawTagHtml}'.`); + + return; + } + } + + rewriter.emitStartTag(tag); + }) + .on('endTag', (tag) => { + switch (tag.tagName) { + case 'head': + for (const linkTag of headerLinkTags) { + rewriter.emitRaw(linkTag); + } + if (imageDomains) { + for (const imageDomain of imageDomains) { + if (!foundPreconnects.has(imageDomain)) { + rewriter.emitRaw(``); + } + } + } + headerLinkTags = []; + break; + case 'body': + for (const linkTag of bodyLinkTags) { + rewriter.emitRaw(linkTag); + } + bodyLinkTags = []; + + // Add script tags + for (const scriptTag of scriptTags) { + rewriter.emitRaw(scriptTag); + } + + scriptTags = []; + break; + } + + rewriter.emitEndTag(tag); + }); + + const content = await transformedContent(); + + return { + content: + headerLinkTags.length || scriptTags.length + ? // In case no body/head tags are not present (dotnet partial templates) + headerLinkTags.join('') + scriptTags.join('') + content + : content, + warnings, + errors, + }; +} + +function generateSriAttributes(content: string): string { + const algo = 'sha384'; + const hash = createHash(algo).update(content, 'utf8').digest('base64'); + + return `integrity="${algo}-${hash}"`; +} + +function generateUrl(value: string, deployUrl: string | undefined): string { + if (!deployUrl) { + return value; + } + + // Skip if root-relative, absolute or protocol relative url + if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) { + return value; + } + + return `${deployUrl}${value}`; +} + +function updateAttribute( + tag: { attrs: { name: string; value: string }[] }, + name: string, + value: string, +): void { + const index = tag.attrs.findIndex((a) => a.name === name); + const newValue = { name, value }; + + if (index === -1) { + tag.attrs.push(newValue); + } else { + tag.attrs[index] = newValue; + } +} + +function readAttribute( + tag: { attrs: { name: string; value: string }[] }, + name: string, +): string | undefined { + const targetAttr = tag.attrs.find((attr) => attr.name === name); + + return targetAttr ? targetAttr.value : undefined; +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +async function getLanguageDirection( + locale: string, + warnings: string[], +): Promise { + const dir = await getLanguageDirectionFromLocales(locale); + + if (!dir) { + warnings.push( + `Locale data for '${locale}' cannot be found. 'dir' attribute will not be set for this locale.`, + ); + } + + return dir; +} + +async function getLanguageDirectionFromLocales(locale: string): Promise { + try { + const localeData = (await import(`@angular/common/locales/${locale}`)).default; + const dir = localeData[localeData.length - 2]; + + return isString(dir) ? dir : undefined; + } catch { + // In some cases certain locales might map to files which are named only with language id. + // Example: `en-US` -> `en`. + const [languageId] = locale.split('-', 1); + if (languageId !== locale) { + return getLanguageDirectionFromLocales(languageId); + } + } + + return undefined; +} diff --git a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts new file mode 100644 index 000000000000..df292b8771a3 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts @@ -0,0 +1,592 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { AugmentIndexHtmlOptions, augmentIndexHtml } from './augment-index-html'; + +describe('augment-index-html', () => { + const indexGeneratorOptions: AugmentIndexHtmlOptions = { + html: '', + baseHref: '/', + sri: false, + files: [], + loadOutputFile: async (_fileName: string) => '', + entrypoints: [ + ['scripts', false], + ['polyfills', true], + ['main', true], + ['styles', false], + ], + }; + + const oneLineHtml = (html: TemplateStringsArray) => + `${html}`.replace(/(>\s+)/g, '>').replace(/\s+ { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + files: [ + { file: 'styles.css', extension: '.css', name: 'styles' }, + { file: 'runtime.js', extension: '.js', name: 'main' }, + { file: 'main.js', extension: '.js', name: 'main' }, + { file: 'runtime.js', extension: '.js', name: 'polyfills' }, + { file: 'polyfills.js', extension: '.js', name: 'polyfills' }, + ], + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + + `); + }); + + it('should replace base href value', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + baseHref: '/Apps/', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + `); + }); + + it('should add lang and dir LTR attribute for French (fr)', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'fr', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it('should add lang and dir RTL attribute for Pashto (ps)', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'ps', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should fallback to use language ID to set the dir attribute (en-US)`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'en-US', + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should work when lang (locale) is not provided by '@angular/common'`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'xx-XX', + }); + + expect(warnings).toEqual([ + `Locale data for 'xx-XX' cannot be found. 'dir' attribute will not be set for this locale.`, + ]); + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should add script and link tags even when body and head element doesn't exist`, async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ``, + files: [ + { file: 'styles.css', extension: '.css', name: 'styles' }, + { file: 'runtime.js', extension: '.js', name: 'main' }, + { file: 'main.js', extension: '.js', name: 'main' }, + { file: 'runtime.js', extension: '.js', name: 'polyfills' }, + { file: 'polyfills.js', extension: '.js', name: 'polyfills' }, + ], + }); + + expect(content).toEqual(oneLineHtml` + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "use-credentials" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'use-credentials', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "anonymous" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'anonymous', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "none" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'none', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with no cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add modulepreload hint when provided`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'modulepreload', url: 'x.js' }, + { mode: 'modulepreload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add modulepreload hint with no crossorigin attribute when provided with cross origin set`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'anonymous', + hints: [ + { mode: 'modulepreload', url: 'x.js' }, + { mode: 'modulepreload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=script when specified with a JS url`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'x.js' }, + { mode: 'preload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=style when specified with a CSS url`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'x.css' }, + { mode: 'preload', url: 'y/z.css' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=style when specified with a URL and an 'as' option`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'https://example.com/x?a=1', as: 'style' }, + { mode: 'preload', url: 'http://example.com/y?b=2', as: 'style' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should not add deploy URL to hints with an absolute URL`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + deployUrl: 'https://localhost/', + hints: [{ mode: 'preload', url: 'http://example.com/y?b=2' }], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + `); + }); + + it(`should not add deploy URL to hints with a root-relative URL`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + deployUrl: 'https://example.com/', + hints: [{ mode: 'preload', url: '/y?b=2' }], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + `); + }); + + it('should add `.mjs` script tags', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + files: [{ file: 'main.mjs', extension: '.mjs', name: 'main' }], + entrypoints: [['main', true /* isModule */]], + }); + + expect(content).toContain(''); + }); + + it('should reject non-module `.mjs` scripts', async () => { + const options: AugmentIndexHtmlOptions = { + ...indexGeneratorOptions, + files: [{ file: 'main.mjs', extension: '.mjs', name: 'main' }], + entrypoints: [['main', false /* isModule */]], + }; + + await expectAsync(augmentIndexHtml(options)).toBeRejectedWithError( + '`.mjs` files *must* set `isModule` to `true`.', + ); + }); + + it('should escape `<` characters in inline importmap JSON', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + sri: true, + chunksIntegrity: new Map([['lazy([^<]+)<\/script>/); + expect(match).withContext('importmap script tag missing').not.toBeNull(); + expect(match?.[1]).toContain('./lazy\\u003cchunk.js'); + expect(match?.[1]).not.toContain('./lazy { + const imageDomains = ['https://www.example.com', 'https://www.example2.com']; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add no image preconnects if provided empty domain list', async () => { + const imageDomains: Array = []; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it('should not add duplicate preconnects', async () => { + const imageDomains = ['https://www.example1.com', 'https://www.example2.com']; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add image preconnects if it encounters preconnect elements for other resources', async () => { + const imageDomains = ['https://www.example2.com', 'https://www.example3.com']; + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + + `); + }); + + describe('self-closing tags', () => { + it('should return an error when used on a not supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + + + + ' + `, + }); + + expect(errors.length).toEqual(1); + expect(errors).toEqual([`Invalid self-closing element in index HTML file: ''.`]); + }); + + it('should not return an error when used on a supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + +
+ + + ' + `, + }); + + expect(errors.length).toEqual(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/auto-csp.ts b/packages/angular/build/src/utils/index-file/auto-csp.ts new file mode 100644 index 000000000000..cd07fb821640 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/auto-csp.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import * as crypto from 'node:crypto'; +import { StartTag, htmlRewritingStream } from './html-rewriting-stream'; + +/** + * The hash function to use for hash directives to use in the CSP. + */ +const HASH_FUNCTION = 'sha256'; + +/** + * Store the appropriate attributes of a sourced script tag to generate the loader script. + */ +interface SrcScriptTag { + src: string; + type?: string; + async: boolean; + defer: boolean; + integrity?: string; + crossOrigin?: string; +} + +/** + * Get the specified attribute or return undefined if the tag doesn't have that attribute. + * + * @param tag StartTag of the `); + scriptContent = []; + } + + rewriter.on('startTag', (tag) => { + if (tag.tagName === 'script') { + openedScriptTag = tag; + const src = getScriptAttributeValue(tag, 'src'); + + if (src) { + // If there are any interesting attributes, note them down. + const scriptType = getScriptAttributeValue(tag, 'type'); + if (shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) { + const crossOrigin = getScriptAttributeValue(tag, 'crossorigin'); + scriptContent.push({ + src: src, + type: scriptType, + async: getScriptAttributeValue(tag, 'async') !== undefined, + defer: getScriptAttributeValue(tag, 'defer') !== undefined, + integrity: getScriptAttributeValue(tag, 'integrity'), + crossOrigin: crossOrigin === '' ? 'anonymous' : crossOrigin, + }); + + return; // Skip writing my script tag until we've read it all. + } + } + } + // We are encountering the first start tag that's not tag if it's a part of the + // dynamic loader script. + if (src && shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) { + return; + } + } + + if (tag.tagName === 'head' || tag.tagName === 'body' || tag.tagName === 'html') { + // Write the loader script if a string of +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(csps[0]).toContain(hashTextContent("console.log('foo');")); + }); + + it('should rewrite a single source script', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain(`const scripts = [['./main.js', "", false, false, null, null]];`); + }); + + it('should rewrite a single source script in place', async () => { + const result = await autoCsp(` + + + + +
Some text
+ + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // Our loader script appears after the HTML text content. + expect(result).toMatch( + /Some text<\/div>\s* + + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_TWO_HASHES_REGEX); + expect(result).toContain( + // eslint-disable-next-line max-len + `const scripts = [['./main1.js', "", false, false, null, null],['./main2.js', "", true, false, null, null],['./main3.js', "module", true, true, null, null]];`, + ); + // Head loader script is in the head. + expect(result).toContain(``); + // Only two loader scripts are created. + expect(Array.from(result.matchAll(/ + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // & encodes correctly + expect(result).toContain(`'/foo&bar'`); + // Impossible to escape a string and create invalid loader JS with a ' + // (Quotes and backslashes work) + expect(result).toContain(`'/one\\'two%5C\\'three%5C%5C\\'four%5C%5C%5C\\'five'`); + // HTML entities work + expect(result).toContain(`'/one&two&three&four'`); + // Cannot escape JS context to HTML + expect(result).toContain(`'./%3C/script%3E'`); + }); + + it('should rewrite all script tags', async () => { + const result = await autoCsp(` + + + + + + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + // Exactly four hashes for the four scripts that remain (inline, loader, inline, loader). + expect(csps[0]).toMatch(CSP_FOUR_HASHES_REGEX); + expect(csps[0]).toContain(hashTextContent("console.log('foo');")); + expect(csps[0]).toContain(hashTextContent("console.log('bar');")); + // Loader script for main.js and main2.js appear after 'foo' and before 'bar'. + expect(result).toMatch( + // eslint-disable-next-line max-len + /console.log\('foo'\);<\/script>\s* + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + + expect(result).toContain( + // eslint-disable-next-line max-len + `document.lastElementChild.appendChild`, + ); + // Head loader script is in the head. + expect(result).toContain(``); + // Only one loader script is created. + expect(Array.from(result.matchAll(/ +
Some text
+ + \r\n + `); + + const csps = getCsps(result); + expect(result).not.toContain(`\r\n`); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(csps[0]).toContain(hashTextContent(`\r\nconsole.log('foo');\r\n`)); + }); + + it('should preserve integrity and crossorigin attributes in loader script', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "module", false, false, "sha384-xyz123", "anonymous"]];`, + ); + }); + + it('should preserve only integrity attribute when crossorigin is omitted', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "", false, false, "sha384-xyz123", null]];`, + ); + }); + + it('should map empty crossorigin attribute to anonymous', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "", false, false, null, "anonymous"]];`, + ); + }); + + it('should rewrite scripts with application/javascript type', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "application/javascript", false, false, null, null]];`, + ); + }); + + it('should rewrite scripts with case-insensitive type and parameters with whitespace', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "Text/JavaScript ; charset=utf-8", false, false, null, null]];`, + ); + }); + + it('should rewrite scripts with case-insensitive module type', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "Module", false, false, null, null]];`, + ); + }); + + it('should not rewrite non-JavaScript script tags', async () => { + const result = await autoCsp(` + + + + + + + `); + + // No dynamic loader script is emitted because application/json is not JavaScript. + expect(result).toContain(''); + expect(result).not.toContain('const scripts ='); + }); + + it('should encode a script type that carries MIME parameters', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // The type stays inside its string literal. + expect(result).toContain( + `const scripts = [['./main.js', "text/javascript;']];var x=1;var junk=[['a','b", false, false, null, null]];`, + ); + }); + + it('should encode a script type that contains a closing script tag', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // Only the loader element is emitted. + expect(Array.from(result.matchAll(/ + + + + + `; + + const { content } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(content).toContain(''); + }); + + it('should capture warnings from beasties', async () => { + const initialContent = ` + + + + + + `; + + const { warnings } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain('missing.css'); + }); + + it('should retain nonce on stylesheet links and noscript fallback', async () => { + const initialContent = ` + + + + + + `; + + const { content, errors, warnings } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(content).toContain( + '', + ); + expect(content).toContain( + '', + ); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/inline-fonts.ts b/packages/angular/build/src/utils/index-file/inline-fonts.ts new file mode 100644 index 000000000000..52181200c885 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/inline-fonts.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createHash } from 'node:crypto'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { type Agent, get as httpsGet } from 'node:https'; +import { join } from 'node:path'; +import { NormalizedCachedOptions } from '../normalize-cache'; +import { htmlRewritingStream } from './html-rewriting-stream'; + +interface FontProviderDetails { + preconnectUrl: string; +} + +export interface InlineFontsOptions { + minify?: boolean; + cache?: NormalizedCachedOptions; +} + +const SUPPORTED_PROVIDERS: Record = { + 'fonts.googleapis.com': { + preconnectUrl: 'https://fonts.gstatic.com', + }, + 'use.typekit.net': { + preconnectUrl: 'https://use.typekit.net', + }, +}; + +/** + * Hash algorithm used for cached files. + */ +const CONTENT_HASH_ALGORITHM = 'sha256'; + +/** + * String length of the SHA-256 content hash stored in cached files. + */ +const CONTENT_HASH_LENGTH = 64; + +export class InlineFontsProcessor { + private readonly cachePath: string | undefined; + constructor(private options: InlineFontsOptions) { + const { path: cacheDirectory, enabled } = this.options.cache || {}; + if (cacheDirectory && enabled) { + this.cachePath = join(cacheDirectory, 'angular-build-fonts'); + } + } + + async process(content: string): Promise { + const hrefList: string[] = []; + const existingPreconnect = new Set(); + + // Collector link tags with href + const { rewriter: collectorStream, transformedContent: initCollectorStream } = + await htmlRewritingStream(content); + + collectorStream.on('startTag', (tag) => { + const { tagName, attrs } = tag; + + if (tagName !== 'link') { + return; + } + + let hrefValue: string | undefined; + let relValue: string | undefined; + for (const { name, value } of attrs) { + switch (name) { + case 'rel': + relValue = value; + break; + + case 'href': + hrefValue = value; + break; + } + + if (hrefValue && relValue) { + switch (relValue) { + case 'stylesheet': + // + hrefList.push(hrefValue); + break; + + case 'preconnect': + // + existingPreconnect.add(hrefValue.replace(/\/$/, '')); + break; + } + + return; + } + } + }); + + void initCollectorStream().catch(() => { + // We don't really care about any errors here because it just initializes + // the rewriting stream, as we are waiting for `finish` below. + }); + + await new Promise((resolve) => collectorStream.on('finish', resolve)); + + // Download stylesheets + const hrefsContent = new Map(); + const newPreconnectUrls = new Set(); + + for (const hrefItem of hrefList) { + const url = this.createNormalizedUrl(hrefItem); + if (!url) { + continue; + } + + const content = await this.processURL(url); + if (content === undefined) { + continue; + } + + hrefsContent.set(hrefItem, content); + + // Add preconnect + const preconnectUrl = this.getFontProviderDetails(url)?.preconnectUrl; + if (preconnectUrl && !existingPreconnect.has(preconnectUrl)) { + newPreconnectUrls.add(preconnectUrl); + } + } + + if (hrefsContent.size === 0) { + return content; + } + + // Replace link with style tag. + const { rewriter, transformedContent } = await htmlRewritingStream(content); + rewriter.on('startTag', (tag) => { + const { tagName, attrs } = tag; + + switch (tagName) { + case 'head': + rewriter.emitStartTag(tag); + for (const url of newPreconnectUrls) { + rewriter.emitRaw(``); + } + break; + + case 'link': { + const hrefAttr = + attrs.some(({ name, value }) => name === 'rel' && value === 'stylesheet') && + attrs.find(({ name, value }) => name === 'href' && hrefsContent.has(value)); + if (hrefAttr) { + const href = hrefAttr.value; + const cssContent = hrefsContent.get(href); + rewriter.emitRaw(``); + } else { + rewriter.emitStartTag(tag); + } + break; + } + default: + rewriter.emitStartTag(tag); + + break; + } + }); + + return transformedContent(); + } + + private async getResponse(url: URL): Promise { + let cacheFile; + if (this.cachePath) { + const key = createHash(CONTENT_HASH_ALGORITHM).update(`${url}`).digest('hex'); + cacheFile = join(this.cachePath, key); + } + + if (cacheFile) { + try { + const data = await readFile(cacheFile, 'utf8'); + // Check for valid content via stored hash + if (data.length > CONTENT_HASH_LENGTH) { + const storedHash = data.slice(0, CONTENT_HASH_LENGTH); + const content = data.slice(CONTENT_HASH_LENGTH); + const contentHash = createHash(CONTENT_HASH_ALGORITHM).update(content).digest('base64'); + if (storedHash === contentHash) { + // Return valid content + return content; + } else { + // Delete corrupted cache content + await rm(cacheFile); + } + } + } catch {} + } + + const httpsProxy = process.env.HTTPS_PROXY ?? process.env.https_proxy; + let agent: Agent | undefined; + if (httpsProxy) { + // TODO: Remove `https-proxy-agent` usage once the min supported version of Node.js is 24.5.0 + // https.globalAgent = new https.Agent({ + // proxyEnv: { HTTPS_PROXY: 'http://proxy.company.com:8080' }, + // }); + // See: https://nodejs.org/en/learn/http/enterprise-network-configuration + // See: https://nodejs.org/docs/latest/api/https.html + + const { HttpsProxyAgent } = (await import('https-proxy-agent' as string)) as typeof import( + 'https-proxy-agent', + { with: { 'resolution-mode': 'import' } } + ); + agent = new HttpsProxyAgent(httpsProxy) as unknown as Agent; + } + + const data = await new Promise((resolve, reject) => { + let rawResponse = ''; + httpsGet( + url, + { + agent, + headers: { + /** + * Always use a Windows UA. This is because Google fonts will including hinting in fonts for Windows. + * Hinting is a technique used with Windows files to improve appearance however + * results in 20-50% larger file sizes. + * + * @see http://google3/java/com/google/fonts/css/OpenSansWebFontsCssBuilder.java?l=22 + * @see https://fonts.google.com/knowledge/glossary/hinting (short) + * @see https://glyphsapp.com/learn/hinting-manual-truetype-hinting (deep dive) + */ + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }, + (res) => { + if (res.statusCode !== 200) { + reject( + new Error( + `Inlining of fonts failed. ${url} returned status code: ${res.statusCode}.`, + ), + ); + + return; + } + + res.on('data', (chunk) => (rawResponse += chunk)).on('end', () => resolve(rawResponse)); + }, + ).on('error', (e) => + reject( + new Error( + `Inlining of fonts failed. An error has occurred while retrieving ${url} over the internet.\n` + + e.message, + ), + ), + ); + }); + + if (cacheFile) { + try { + const dataHash = createHash(CONTENT_HASH_ALGORITHM).update(data).digest('hex'); + await writeFile(cacheFile, dataHash + data); + } catch {} + } + + return data; + } + + async processURL(url: string | URL): Promise { + const normalizedURL = url instanceof URL ? url : this.createNormalizedUrl(url); + if (!normalizedURL) { + return; + } + + const provider = this.getFontProviderDetails(normalizedURL); + if (!provider) { + return undefined; + } + + let cssContent = await this.getResponse(normalizedURL); + + if (this.options.minify) { + cssContent = cssContent + // Comments. + .replace(/\/\*([\s\S]*?)\*\//g, '') + // New lines. + .replace(/\n/g, '') + // Safe spaces. + .replace(/\s?[{:;]\s+/g, (s) => s.trim()); + } + + return cssContent; + } + + canInlineRequest(url: string): boolean { + const normalizedUrl = this.createNormalizedUrl(url); + + return normalizedUrl ? !!this.getFontProviderDetails(normalizedUrl) : false; + } + + private getFontProviderDetails(url: URL): FontProviderDetails | undefined { + return SUPPORTED_PROVIDERS[url.hostname]; + } + + private createNormalizedUrl(value: string): URL | undefined { + // Need to convert '//' to 'https://' because the URL parser will fail with '//'. + const url = new URL(value.startsWith('//') ? `https:${value}` : value, 'resolve://'); + + switch (url.protocol) { + case 'http:': + case 'https:': + url.protocol = 'https:'; + + return url; + default: + return undefined; + } + } +} diff --git a/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts b/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts new file mode 100644 index 000000000000..46511f853ecf --- /dev/null +++ b/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { InlineFontsProcessor } from './inline-fonts'; + +describe('InlineFontsProcessor', () => { + describe('Google fonts', () => { + const content = ` + + + + + + `; + + it('works with // protocol', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content.replace('https://', '//')); + expect(html).toContain(`format('woff2');`); + }); + + it('should inline supported fonts and icons in HTML', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(` + + + + + + + + `); + + expect(html).not.toContain( + 'href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"', + ); + expect(html).not.toContain('href="https://fonts.googleapis.com/icon?family=Material+Icons"'); + expect(html).toContain('href="theme.css"'); + expect(html).toContain(`font-family: 'Roboto'`); + expect(html).toContain(`font-family: 'Material Icons'`); + }); + + it('should inline multiple fonts from a single request with minification enabled', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: true, + }); + + const html = await inlineFontsProcessor.process(` + + + + + + + `); + + expect(html).toContain(`'Google Sans'`); + expect(html).toContain(`'Roboto'`); + expect(html).toContain(`'Roboto Mono'`); + expect(html).toContain(`'Material Icons'`); + }); + + it('works with http protocol', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content.replace('https://', 'http://')); + expect(html).toContain(`format('woff2');`); + }); + + it('should remove comments and line breaks when `minifyInlinedCSS` is true', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: true, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).not.toContain('/*'); + expect(html).toContain(';font-style:normal;'); + }); + + it('should add preconnect hint', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).toContain( + ``, + ); + }); + }); + + describe('Adobe Typekit fonts', () => { + const content = ` + + + + + + `; + + it('should add preconnect hint', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).toContain(``); + }); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/ngcm-attribute.ts b/packages/angular/build/src/utils/index-file/ngcm-attribute.ts new file mode 100644 index 000000000000..af74778915e6 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/ngcm-attribute.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { htmlRewritingStream } from './html-rewriting-stream'; + +/** + * Defines a name of an attribute that is added to the `` tag + * in the `index.html` file in case a given route was configured + * with `RenderMode.Client`. 'cm' is an abbreviation for "Client Mode". + * + * @see https://github.com/angular/angular/pull/58004 + */ +const CLIENT_RENDER_MODE_FLAG = 'ngcm'; + +/** + * Transforms the provided HTML by adding the `ngcm` attribute to the `` tag. + * This is used in the client-side rendered (CSR) version of `index.html` to prevent hydration warnings. + * + * @param html The HTML markup to be transformed. + * @returns A promise that resolves to the transformed HTML string with the necessary modifications. + */ +export async function addNgcmAttribute(html: string): Promise { + const { rewriter, transformedContent } = await htmlRewritingStream(html); + + rewriter.on('startTag', (tag) => { + if ( + tag.tagName === 'body' && + !tag.attrs.some((attr) => attr.name === CLIENT_RENDER_MODE_FLAG) + ) { + tag.attrs.push({ name: CLIENT_RENDER_MODE_FLAG, value: '' }); + } + + rewriter.emitStartTag(tag); + }); + + return transformedContent(); +} diff --git a/packages/angular/build/src/utils/index-file/ngcm-attribute_spec.ts b/packages/angular/build/src/utils/index-file/ngcm-attribute_spec.ts new file mode 100644 index 000000000000..2eec900c862d --- /dev/null +++ b/packages/angular/build/src/utils/index-file/ngcm-attribute_spec.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { addNgcmAttribute } from './ngcm-attribute'; + +describe('addNgcmAttribute', () => { + it('should add the ngcm attribute to the tag', async () => { + const result = await addNgcmAttribute(` + + +

hello world!

+ + `); + + expect(result).toContain('

hello world!

'); + }); + + it('should not override an existing ngcm attribute', async () => { + const result = await addNgcmAttribute(` + + +

hello world!

+ + `); + + expect(result).toContain('

hello world!

'); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/nonce.ts b/packages/angular/build/src/utils/index-file/nonce.ts new file mode 100644 index 000000000000..fabb118cd9da --- /dev/null +++ b/packages/angular/build/src/utils/index-file/nonce.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { htmlRewritingStream } from './html-rewriting-stream'; + +/** + * Pattern matching the name of the Angular nonce attribute. Note that this is + * case-insensitive, because HTML attribute names are case-insensitive as well. + */ +const NONCE_ATTR_PATTERN = /ngCspNonce/i; + +const TARGET_LINK_RELS: ReadonlySet = new Set(['stylesheet', 'modulepreload']); + +/** + * Finds the `ngCspNonce` value and copies it to all ` + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should add a lowercase nonce expression to style tags', async () => { + const result = await addNonce(` + + + + + + + + + `); + + expect(result).toContain(''); + }); + + it('should preserve any pre-existing nonces', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should use the first nonce that is defined on the page', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + }); + + it('should to all script tags', async () => { + const result = await addNonce(` + + + + + + + + + + + `); + + expect(result).toContain(``); + expect(result).toContain(''); + expect(result).toContain(``); + }); + + it('should add the nonce expression to stylesheet and modulepreload link tags', async () => { + const result = await addNonce(` + + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should add the nonce expression when link rel has mixed case or multiple tokens', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts new file mode 100644 index 000000000000..f86d556b36f0 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** A list of valid self closing HTML elements */ +export const VALID_SELF_CLOSING_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + + /** SVG tags */ + 'animate', + 'animateMotion', + 'animateTransform', + 'circle', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'line', + 'path', + 'polygon', + 'polyline', + 'rect', + 'text', + 'tspan', + 'linearGradient', + 'radialGradient', + 'stop', + 'image', + 'pattern', + 'defs', + 'g', + 'marker', + 'mask', + 'style', + 'symbol', + 'use', + 'view', + + /** MathML tags */ + 'mspace', + 'mphantom', + 'mrow', + 'mfrac', + 'msqrt', + 'mroot', + 'mstyle', + 'merror', + 'mpadded', + 'mtable', +]); diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts new file mode 100644 index 000000000000..9df32fc10568 --- /dev/null +++ b/packages/angular/build/src/utils/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from './normalize-asset-patterns'; +export * from './normalize-optimization'; +export * from './normalize-source-maps'; +export * from './load-proxy-config'; +export * from './source-map'; diff --git a/packages/angular/build/src/utils/load-esm.ts b/packages/angular/build/src/utils/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/build/src/utils/load-esm.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * Lazily compiled dynamic import loader function. + */ +let load: ((modulePath: string | URL) => Promise) | undefined; + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export function loadEsmModule(modulePath: string | URL): Promise { + load ??= new Function('modulePath', `return import(modulePath);`) as Exclude< + typeof load, + undefined + >; + + return load(modulePath); +} diff --git a/packages/angular/build/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts new file mode 100644 index 000000000000..f32db992ac92 --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { makeRe as makeRegExpFromGlob } from 'picomatch'; +import { isDynamicPattern } from 'tinyglobby'; +import { assertIsError } from './error'; +import { loadEsmModule } from './load-esm'; + +export async function loadProxyConfiguration( + root: string, + proxyConfig: string | undefined, +): Promise | undefined> { + if (!proxyConfig) { + return undefined; + } + + const proxyPath = resolve(root, proxyConfig); + + if (!existsSync(proxyPath)) { + throw new Error(`Proxy configuration file ${proxyPath} does not exist.`); + } + + let proxyConfiguration; + switch (extname(proxyPath)) { + case '.json': { + const content = await readFile(proxyPath, 'utf-8'); + + const { parse, printParseErrorCode } = await import('jsonc-parser'); + const parseErrors: import('jsonc-parser').ParseError[] = []; + proxyConfiguration = parse(content, parseErrors, { allowTrailingComma: true }); + + if (parseErrors.length > 0) { + let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`; + for (const parseError of parseErrors) { + const { line, column } = getJsonErrorLineColumn(parseError.offset, content); + errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(parseError.error)}`; + } + throw new Error(errorMessage); + } + + break; + } + default: { + try { + proxyConfiguration = await import(proxyPath); + } catch (e) { + assertIsError(e); + if (e.code !== 'ERR_REQUIRE_ASYNC_MODULE') { + throw e; + } + + proxyConfiguration = await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath)); + } + + break; + } + } + + if ('default' in proxyConfiguration) { + proxyConfiguration = proxyConfiguration.default; + } + + return normalizeProxyConfiguration(proxyConfiguration); +} + +/** + * Converts glob patterns to regular expressions to support Vite's proxy option. + * Also converts the Webpack supported array form to an object form supported by both. + * + * @param proxy A proxy configuration object. + */ +function normalizeProxyConfiguration( + proxy: Record | object[], +): Record { + let normalizedProxy: Record | undefined; + + if (Array.isArray(proxy)) { + // Construct an object-form proxy configuration from the array + normalizedProxy = {}; + for (const proxyEntry of proxy) { + if (!('context' in proxyEntry)) { + continue; + } + + // Array-form entries contain a context string array with the path(s) + // to use for the configuration entry. A single path string is also + // accepted, as with the Webpack-based development server. + const context = + typeof proxyEntry.context === 'string' ? [proxyEntry.context] : proxyEntry.context; + if (!Array.isArray(context)) { + continue; + } + + delete proxyEntry.context; + for (const contextEntry of context) { + if (typeof contextEntry !== 'string') { + continue; + } + + normalizedProxy[contextEntry] = proxyEntry; + } + } + } else { + normalizedProxy = proxy; + } + + // TODO: Consider upstreaming glob support + // The object is rebuilt so that converted glob entries keep their original position, + // since Vite proxies a request with the first entry that matches it. + const orderedProxy: Record = {}; + for (const [key, value] of Object.entries(normalizedProxy)) { + const context = key[0] !== '^' && isDynamicPattern(key) ? makeUrlRegExpFromGlob(key) : key; + orderedProxy[context] = value; + } + normalizedProxy = orderedProxy; + + // Replace `pathRewrite` field with a `rewrite` function + for (const proxyEntry of Object.values(normalizedProxy)) { + if ( + typeof proxyEntry === 'object' && + 'pathRewrite' in proxyEntry && + proxyEntry.pathRewrite && + typeof proxyEntry.pathRewrite === 'object' + ) { + // Preprocess path rewrite entries + const pathRewriteEntries: [RegExp, string][] = []; + for (const [pattern, value] of Object.entries( + proxyEntry.pathRewrite as Record, + )) { + pathRewriteEntries.push([new RegExp(pattern), value]); + } + + (proxyEntry as Record).rewrite = pathRewriter.bind( + undefined, + pathRewriteEntries, + ); + + delete proxyEntry.pathRewrite; + } + } + + return normalizedProxy; +} + +/** + * Converts a glob pattern to a regular expression source string that matches + * the pathname portion of a request URL while allowing an optional query string. + * + * Vite tests regex proxy keys against `req.url`, which includes the query string, + * whereas `picomatch` generates regexes assuming the input is only a pathname. + */ +function makeUrlRegExpFromGlob(glob: string): string { + const { source } = makeRegExpFromGlob(glob); + + return ( + source + // Ensure picomatch's "at least one character" lookahead does not + // treat the `?` query delimiter as a pathname character (e.g., `/api/*` on `/api/?a=1`). + .replaceAll('(?=.)', '(?=[^?])') + // Prevent single-segment wildcards (`*` and `?`) from matching + // across `?` into the query string (e.g., `/api/*.json` on `/api/users?file=data.json`). + .replaceAll('[^/]', '[^/?]') + // Prevent leading-dot-aware `?` wildcards from matching the `?` delimiter. + .replaceAll('[^.\\/]', '[^.\\/?]') + // Prevent multi-segment globstars (`**`) from matching across `?` into the query string + // (e.g., `/api/**/users` on `/api/other?redirect=/users`). + .replaceAll('\\.).)*?', '\\.)[^?])*?') + // Replace unescaped end-of-input anchors (`$`, both at the end of the pattern and inside + // internal alternation groups for trailing `/**` or negated globs) so an optional query + // string is permitted after the matched pathname. + .replace(/(? offset) { + break; + } + + position = nextNewline + 1; + } + + return { line, column: offset - position + 1 }; +} diff --git a/packages/angular/build/src/utils/load-proxy-config_spec.ts b/packages/angular/build/src/utils/load-proxy-config_spec.ts new file mode 100644 index 000000000000..c44d74e9556b --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config_spec.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { loadProxyConfiguration } from './load-proxy-config'; + +describe('loadProxyConfiguration', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-proxy-config-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function createGlobMatcher(glob: string): Promise<(url: string) => boolean> { + const configPath = join(tempDir, 'proxy.conf.json'); + await writeFile(configPath, JSON.stringify({ [glob]: { target: 'http://localhost:3000' } })); + const proxy = await loadProxyConfiguration(tempDir, 'proxy.conf.json'); + assert(proxy, 'Expected proxy configuration to be loaded'); + const keys = Object.keys(proxy); + expect(keys.length).toBe(1); + const regex = new RegExp(keys[0]); + + return (url: string) => regex.test(url); + } + + describe('glob to RegExp conversion with query strings', () => { + it('handles trailing globstar (/**) with and without query strings', async () => { + const matches = await createGlobMatcher('/search/**'); + + expect(matches('/search')).toBeTrue(); + expect(matches('/search?q=angular')).toBeTrue(); + expect(matches('/search/')).toBeTrue(); + expect(matches('/search/?q=angular')).toBeTrue(); + expect(matches('/search/items')).toBeTrue(); + expect(matches('/search/items?q=angular')).toBeTrue(); + expect(matches('/search/items?callback=/.hidden')).toBeTrue(); + + expect(matches('/searching')).toBeFalse(); + expect(matches('/searching?q=angular')).toBeFalse(); + expect(matches('/other?redirect=/search')).toBeFalse(); + }); + + it('handles single-segment wildcard (*) without matching across slashes or query strings', async () => { + const matches = await createGlobMatcher('/api/*'); + + expect(matches('/api/users')).toBeTrue(); + expect(matches('/api/users?id=1')).toBeTrue(); + expect(matches('/api/users?redirect=/home/page')).toBeTrue(); + + expect(matches('/api')).toBeFalse(); + expect(matches('/api/')).toBeFalse(); + expect(matches('/api/?a=1')).toBeFalse(); + expect(matches('/api/users/profile')).toBeFalse(); + expect(matches('/api/users/profile?a=1')).toBeFalse(); + }); + + it('handles suffix globs (*.json) without matching query parameters', async () => { + const matches = await createGlobMatcher('/api/*.json'); + + expect(matches('/api/data.json')).toBeTrue(); + expect(matches('/api/data.json?v=1')).toBeTrue(); + + expect(matches('/api/users')).toBeFalse(); + expect(matches('/api/users?file=data.json')).toBeFalse(); + expect(matches('/api/.json?v=1')).toBeFalse(); + }); + + it('handles middle globstar (/**/) without matching paths in query parameters', async () => { + const matches = await createGlobMatcher('/api/**/users'); + + expect(matches('/api/users')).toBeTrue(); + expect(matches('/api/users?page=1')).toBeTrue(); + expect(matches('/api/v1/users?page=1')).toBeTrue(); + expect(matches('/api/v1/v2/users?page=1')).toBeTrue(); + + expect(matches('/api/other?redirect=/users')).toBeFalse(); + expect(matches('/api/users/profile?page=1')).toBeFalse(); + }); + + it('handles single-character wildcard (?) without matching the query delimiter', async () => { + const matches = await createGlobMatcher('/api/v?'); + + expect(matches('/api/v1')).toBeTrue(); + expect(matches('/api/v1?a=1')).toBeTrue(); + + expect(matches('/api/v')).toBeFalse(); + expect(matches('/api/v?')).toBeFalse(); + expect(matches('/api/v?a=1')).toBeFalse(); + expect(matches('/api/v12?a=1')).toBeFalse(); + }); + + it('handles negated globs (!) with and without query strings', async () => { + const matches = await createGlobMatcher('!/api/public/**'); + + expect(matches('/api/public')).toBeFalse(); + expect(matches('/api/public?foo=1')).toBeFalse(); + expect(matches('/api/public/assets?foo=1')).toBeFalse(); + + expect(matches('/api/private')).toBeTrue(); + expect(matches('/api/private?foo=1')).toBeTrue(); + }); + + it('handles brace expansion and extglobs with query strings', async () => { + const matchesBraces = await createGlobMatcher('/api/{auth,users}/**'); + expect(matchesBraces('/api/auth?q=1')).toBeTrue(); + expect(matchesBraces('/api/users/list?q=1')).toBeTrue(); + expect(matchesBraces('/api/other?q=1')).toBeFalse(); + + const matchesExtglob = await createGlobMatcher('/**/!(*.d).ts'); + expect(matchesExtglob('/app/main.ts')).toBeTrue(); + expect(matchesExtglob('/app/main.ts?extra=.d.ts')).toBeTrue(); + expect(matchesExtglob('/app/main.d.ts')).toBeFalse(); + expect(matchesExtglob('/app/main.d.ts?extra=.ts')).toBeFalse(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/load-rolldown.ts b/packages/angular/build/src/utils/load-rolldown.ts new file mode 100644 index 000000000000..78af6aeb3c9d --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +let rolldownPromise: Promise | undefined; + +/** + * Dynamically loads the `rolldown` module while configuring its embedded + * mimalloc v3 allocator to immediately purge freed arena memory. + * + * Rolldown's native binding statically links mimalloc v3, which defaults to a + * 4-second arena purge delay (`MIMALLOC_PURGE_DELAY=1000` * `MIMALLOC_ARENA_PURGE_MULT=4`) + * and only triggers purges cooperatively during subsequent allocations. In single-run + * CLI builds where Rolldown is not invoked again after bundling completes, non-zero + * purge delays prevent freed arena pages from being decommitted back to the OS, + * leading to high peak RSS during post-bundle stages. + * + * Because mimalloc reads and caches environment options synchronously when the native + * `.node` addon is first loaded (`mi_process_init` -> `_mi_options_init`), temporarily + * setting `MIMALLOC_PURGE_DELAY=0` during the initial `import('rolldown')` call + * configures Rolldown's allocator without leaving the environment variable set for + * subsequent code or child processes. + * + * @returns A promise that resolves to the `rolldown` module namespace. + */ +export function loadRolldown(): Promise { + if (!rolldownPromise) { + const hasCustomPurgeDelay = Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY); + if (!hasCustomPurgeDelay) { + process.env[MIMALLOC_PURGE_DELAY] = '0'; + } + + rolldownPromise = import('rolldown').finally(() => { + if (!hasCustomPurgeDelay) { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + }); + } + + return rolldownPromise; +} diff --git a/packages/angular/build/src/utils/load-rolldown_spec.ts b/packages/angular/build/src/utils/load-rolldown_spec.ts new file mode 100644 index 000000000000..0f7ea7cb4a80 --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +describe('loadRolldown', () => { + const originalEnvValue = process.env[MIMALLOC_PURGE_DELAY]; + + function getFreshLoadRolldown(): typeof import('./load-rolldown') { + delete require.cache[require.resolve('./load-rolldown')]; + + return require('./load-rolldown'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env[MIMALLOC_PURGE_DELAY] = originalEnvValue; + } else { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + delete require.cache[require.resolve('./load-rolldown')]; + }); + + it('loads rolldown and cleans up temporary MIMALLOC_PURGE_DELAY when unset', async () => { + delete process.env[MIMALLOC_PURGE_DELAY]; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise1 = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('0'); + + const promise2 = loadRolldown(); + expect(promise1).toBe(promise2); + + const rolldownModule = await promise1; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY)).toBeFalse(); + }); + + it('preserves user-configured MIMALLOC_PURGE_DELAY when already set', async () => { + process.env[MIMALLOC_PURGE_DELAY] = '1000'; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + + const rolldownModule = await promise; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + }); +}); diff --git a/packages/angular/build/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts new file mode 100644 index 000000000000..e202273dc73d --- /dev/null +++ b/packages/angular/build/src/utils/load-translations.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Diagnostics } from '@angular/localize/tools'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; + +export type TranslationLoader = (path: string) => { + translations: Record; + format: string; + locale?: string; + diagnostics: Diagnostics; + integrity: string; +}; + +export async function createTranslationLoader(): Promise { + const { parsers, diagnostics } = await importParsers(); + + return (path: string) => { + const content = fs.readFileSync(path, 'utf8'); + const unusedParsers = new Map(); + for (const [format, parser] of Object.entries(parsers)) { + const analysis = parser.analyze(path, content); + if (analysis.canParse) { + // Types don't overlap here so we need to use any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { locale, translations } = parser.parse(path, content, analysis.hint as any); + const integrity = 'sha256-' + createHash('sha256').update(content).digest('base64'); + + return { format, locale, translations, diagnostics, integrity }; + } else { + unusedParsers.set(parser, analysis); + } + } + + const messages: string[] = []; + for (const [parser, analysis] of unusedParsers.entries()) { + messages.push(analysis.diagnostics.formatDiagnostics(`*** ${parser.constructor.name} ***`)); + } + throw new Error( + `Unsupported translation file format in ${path}. The following parsers were tried:\n` + + messages.join('\n'), + ); + }; +} + +async function importParsers() { + try { + // Load ESM `@angular/localize/tools` 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. + const { + Diagnostics, + ArbTranslationParser, + SimpleJsonTranslationParser, + Xliff1TranslationParser, + Xliff2TranslationParser, + XtbTranslationParser, + } = await import('@angular/localize/tools'); + + const diagnostics = new Diagnostics(); + const parsers = { + arb: new ArbTranslationParser(), + json: new SimpleJsonTranslationParser(), + xlf: new Xliff1TranslationParser(), + xlf2: new Xliff2TranslationParser(), + // The name ('xmb') needs to match the AOT compiler option + xmb: new XtbTranslationParser(), + }; + + return { parsers, diagnostics }; + } catch { + throw new Error( + `Unable to load translation file parsers. Please ensure '@angular/localize' is installed.`, + ); + } +} diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts new file mode 100644 index 000000000000..a95d91829766 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { statSync } from 'node:fs'; +import path from 'node:path'; +import type { AssetPattern, AssetPatternClass } from '../builders/application/schema'; +import { isSubDirectory } from './path'; + +/** + * Asset pattern configuration normalized into its object form with a resolved `output` directory. + */ +export interface NormalizedAssetsPattern extends AssetPatternClass { + /** Relative output directory within the build output path. */ + output: string; +} + +/** + * Normalizes raw string and object asset patterns into {@link NormalizedAssetsPattern} objects + * and validates that all asset inputs and outputs stay within the workspace and output directories. + * + * @param assetPatterns Raw string or object asset patterns from builder options. + * @param workspaceRoot Workspace root directory path. + * @param projectRoot Project root directory path. + * @param projectSourceRoot Optional project source root directory path. + * @returns An array of normalized asset pattern configurations. + */ +export function normalizeAssetPatterns( + assetPatterns: AssetPattern[], + workspaceRoot: string, + projectRoot: string, + projectSourceRoot: string | undefined, +): NormalizedAssetsPattern[] { + if (assetPatterns.length === 0) { + return []; + } + + // When sourceRoot is not available, we default to ${projectRoot}/src. + const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src'); + const resolvedWorkspaceRoot = path.resolve(workspaceRoot); + const resolvedSourceRoot = path.resolve(resolvedWorkspaceRoot, sourceRoot); + const resolvedProjectRoot = path.resolve(resolvedWorkspaceRoot, projectRoot); + + return assetPatterns.map((assetPattern) => { + let normalized: NormalizedAssetsPattern; + + // Normalize string asset patterns to objects. + if (typeof assetPattern === 'string') { + const assetPath = path.normalize(assetPattern); + const resolvedAssetPath = path.resolve(resolvedWorkspaceRoot, assetPath); + let root: string; + + // Check if the string asset is within sourceRoot. + if (isSubDirectory(resolvedSourceRoot, resolvedAssetPath)) { + root = resolvedSourceRoot; + } else if (isSubDirectory(resolvedProjectRoot, resolvedAssetPath)) { + root = resolvedProjectRoot; + } else if (isSubDirectory(resolvedWorkspaceRoot, resolvedAssetPath)) { + root = resolvedWorkspaceRoot; + } else { + throw new Error(`The ${assetPattern} asset path must be within the workspace root.`); + } + + let isDirectory = true; + try { + isDirectory = statSync(resolvedAssetPath, { throwIfNoEntry: false })?.isDirectory() ?? true; + } catch {} + const glob = isDirectory ? '**/*' : path.basename(assetPath); + const input = isDirectory ? assetPath : path.dirname(assetPath); + + // Output directory for both is the relative path from the root to input. + const output = path.relative(root, path.resolve(resolvedWorkspaceRoot, input)); + + normalized = { glob, input, output }; + } else { + if (!isSubDirectory(resolvedWorkspaceRoot, assetPattern.input)) { + throw new Error(`The ${assetPattern.input} asset path must be within the workspace root.`); + } + + normalized = { + ...assetPattern, + output: path.join('.', assetPattern.output ?? ''), + }; + } + + if (normalized.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return normalized; + }); +} diff --git a/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts b/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts new file mode 100644 index 000000000000..d83a74fe0ee8 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path, { join } from 'node:path'; +import { normalizeAssetPatterns } from './normalize-asset-patterns'; +import { resolveAssets } from './resolve-assets'; + +describe('normalizeAssetPatterns', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-assets-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('should return an empty array when given no asset patterns', () => { + expect(normalizeAssetPatterns([], tempRoot, '', 'src')).toEqual([]); + }); + + it('should normalize a string asset within sourceRoot relative to sourceRoot', async () => { + await mkdir(join(tempRoot, 'src/assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['src/assets', 'src/assets/logo.png'], + tempRoot, + '', + 'src', + ); + + expect(result).toEqual([ + { glob: '**/*', input: 'src/assets', output: 'assets' }, + { glob: 'logo.png', input: 'src/assets', output: 'assets' }, + ]); + }); + + it('should normalize a string asset in a sibling directory sharing a prefix with sourceRoot (e.g. src-assets)', async () => { + await mkdir(join(tempRoot, 'src-assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src-assets/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['src-assets', 'src-assets/logo.png'], + tempRoot, + '', + 'src', + ); + + expect(result).toEqual([ + { glob: '**/*', input: 'src-assets', output: 'src-assets' }, + { glob: 'logo.png', input: 'src-assets', output: 'src-assets' }, + ]); + }); + + it('should normalize a library string asset in a sibling directory sharing a prefix with projectSourceRoot', async () => { + await mkdir(join(tempRoot, 'projects/lib/src-assets'), { recursive: true }); + await writeFile(join(tempRoot, 'projects/lib/src-assets/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['projects/lib/src-assets', 'projects/lib/src-assets/logo.png'], + tempRoot, + 'projects/lib', + 'projects/lib/src', + ); + + expect(result).toEqual([ + { + glob: '**/*', + input: path.normalize('projects/lib/src-assets'), + output: 'src-assets', + }, + { + glob: 'logo.png', + input: path.normalize('projects/lib/src-assets'), + output: 'src-assets', + }, + ]); + }); + + it('should normalize a string asset in a sibling directory sharing a prefix with projectRoot relative to workspaceRoot', async () => { + await mkdir(join(tempRoot, 'projects/lib-shared'), { recursive: true }); + await writeFile(join(tempRoot, 'projects/lib-shared/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['projects/lib-shared/logo.png'], + tempRoot, + 'projects/lib', + 'projects/lib/src', + ); + + expect(result).toEqual([ + { + glob: 'logo.png', + input: path.normalize('projects/lib-shared'), + output: path.normalize('projects/lib-shared'), + }, + ]); + }); + + it('should throw when a string asset is in a sibling directory sharing a prefix with workspaceRoot', () => { + const siblingDir = `../${path.basename(tempRoot)}-outside/logo.png`; + + expect(() => normalizeAssetPatterns([siblingDir], tempRoot, '', 'src')).toThrowError( + /asset path must be within the workspace root/, + ); + }); +}); + +describe('resolveAssets', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-resolve-assets-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('should ignore both root and nested .gitkeep files by default', async () => { + await mkdir(join(tempRoot, 'src/assets/nested/deep'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/nested/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/nested/deep/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/nested/deep/logo.png'), 'PNG'); + + const resolved = await resolveAssets( + [{ glob: '**/*', input: 'src/assets', output: 'assets' }], + tempRoot, + ); + + expect(resolved).toEqual([ + { + source: join(tempRoot, 'src/assets/nested/deep/logo.png'), + destination: join('assets', 'nested/deep/logo.png'), + }, + ]); + }); + + it('should throw when a resolved destination escapes the output root via ".." in glob', async () => { + await mkdir(join(tempRoot, 'src'), { recursive: true }); + await writeFile(join(tempRoot, 'outside.txt'), 'secret'); + + await expectAsync( + resolveAssets([{ glob: '../outside.txt', input: 'src', output: '.' }], tempRoot), + ).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.'); + + await expectAsync( + resolveAssets([{ glob: '../*.txt', input: 'src', output: '.' }], tempRoot), + ).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.'); + }); + + it('should not expand directory patterns in glob whether or not ignore is present', async () => { + await mkdir(join(tempRoot, 'src/assets/sub'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/sub/file.txt'), 'content'); + await writeFile(join(tempRoot, 'src/assets/sub/ignored.txt'), 'ignored'); + await writeFile(join(tempRoot, 'src/assets/root.txt'), 'root'); + + const resolved = await resolveAssets( + [ + { glob: 'sub/', input: 'src/assets', output: 'assets' }, + { glob: 'sub/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + { glob: 'sub', input: 'src/assets', output: 'assets' }, + { glob: 'sub', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + { glob: 'root.txt/', input: 'src/assets', output: 'assets' }, + { glob: 'root.txt/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + ], + tempRoot, + ); + + expect(resolved).toHaveSize(0); + }); + + it('should expand directory patterns in ignore while keeping non-ignored files', async () => { + await mkdir(join(tempRoot, 'src/assets/sub-a/nested'), { recursive: true }); + await mkdir(join(tempRoot, 'src/assets/sub-b/nested'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/sub-a/nested/a.txt'), 'a'); + await writeFile(join(tempRoot, 'src/assets/sub-b/nested/b.txt'), 'b'); + await writeFile(join(tempRoot, 'src/assets/keep.txt'), 'keep'); + + const resolved = await resolveAssets( + [{ glob: '**/*', input: 'src/assets', output: 'assets', ignore: ['sub-a/', 'sub-b'] }], + tempRoot, + ); + + expect(resolved).toEqual([ + { + source: join(tempRoot, 'src/assets/keep.txt'), + destination: join('assets', 'keep.txt'), + }, + ]); + }); + + it('should ignore default ignored files even when specified as a static file glob without ignore', async () => { + await mkdir(join(tempRoot, 'src/assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/.DS_Store'), ''); + await writeFile(join(tempRoot, 'src/assets/Thumbs.db'), ''); + + const resolved = await resolveAssets( + [ + { glob: '.gitkeep', input: 'src/assets', output: 'assets' }, + { glob: '.DS_Store', input: 'src/assets', output: 'assets' }, + { glob: 'Thumbs.db', input: 'src/assets', output: 'assets' }, + ], + tempRoot, + ); + + expect(resolved).toHaveSize(0); + }); +}); diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts new file mode 100644 index 000000000000..bb24f774f79b --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +/** Version placeholder is replaced during the build process with actual package version */ +const VERSION = '0.0.0-PLACEHOLDER'; + +export interface NormalizedCachedOptions { + /** Whether disk cache is enabled. */ + enabled: boolean; + + /** Disk cache path. Example: `/.angular/cache/v12.0.0`. */ + path: string; + + /** Disk cache base path. Example: `/.angular/cache`. */ + basePath: string; + + /** + * Workspace-local disk cache path. Example: `/.angular/cache/v12.0.0`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localPath?: string; + + /** + * Workspace-local disk cache base path. Example: `/.angular/cache`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localBasePath?: string; +} + +interface CacheMetadata { + enabled?: boolean; + environment?: 'local' | 'ci' | 'all'; + path?: string; +} + +function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadata } } { + return ( + !!value && + typeof value === 'object' && + 'cli' in value && + !!value['cli'] && + typeof value['cli'] === 'object' && + 'cache' in value['cli'] + ); +} + +function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string { + if (isAbsolute(cachePathSetting)) { + return cachePathSetting; + } + + try { + // Find the git directory, walking up from workspaceRoot if necessary + let currentDir = workspaceRoot; + while (true) { + const gitPath = join(currentDir, '.git'); + if (existsSync(gitPath)) { + const stat = statSync(gitPath); + if (stat.isFile()) { + // Could be a git worktree (or submodule) + const content = readFileSync(gitPath, 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (match) { + const gitdir = resolve(currentDir, match[1].trim()); + const commondirPath = join(gitdir, 'commondir'); + if (existsSync(commondirPath)) { + // It's a git worktree + const commondir = readFileSync(commondirPath, 'utf8').trim(); + const commonGitDir = resolve(gitdir, commondir); + const relativeWorkspacePath = relative(currentDir, workspaceRoot); + const mainWorkspaceRoot = resolve(dirname(commonGitDir), relativeWorkspacePath); + if (existsSync(mainWorkspaceRoot)) { + return resolve(mainWorkspaceRoot, cachePathSetting); + } + } + } + } + + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } catch {} + + return resolve(workspaceRoot, cachePathSetting); +} + +/** + * Normalizes the persistent disk cache configuration for a project or workspace. + * + * Resolves whether disk caching is enabled based on the CLI cache metadata, current runtime + * environment (local vs. CI, or WebContainers), and computes the versioned cache directory paths + * (resolving shared Git worktree paths when applicable). + * + * @param projectMetadata The project or workspace metadata object containing optional `cli.cache` settings. + * @param workspaceRoot The absolute path to the workspace root directory. + * @param projectName Optional name of the project used to scope the resolved cache path. + * @param builderName Optional name of the builder or tool used to further scope the resolved cache path. + * @returns The normalized disk cache options including enabled state and resolved directory paths. + */ +export function normalizeCacheOptions( + projectMetadata: unknown, + workspaceRoot: string, + projectName = '', + builderName = '', +): NormalizedCachedOptions { + const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; + + const { + // Webcontainers do not currently benefit from persistent disk caching and can lead to increased browser memory usage + enabled = !process.versions.webcontainer, + environment = 'local', + path = '.angular/cache', + } = cacheMetadata; + const isCI = process.env['CI'] === '1' || process.env['CI']?.toLowerCase() === 'true'; + + let cacheEnabled = enabled; + if (cacheEnabled) { + switch (environment) { + case 'ci': + cacheEnabled = isCI; + break; + case 'local': + cacheEnabled = !isCI; + break; + } + } + + const cacheBasePath = getCacheBasePath(workspaceRoot, path); + const localCacheBasePath = isAbsolute(path) ? path : resolve(workspaceRoot, path); + const normalizedBuilderName = builderName.replace('@', '').replaceAll(/[:/]/g, '-'); + + return { + enabled: cacheEnabled, + basePath: cacheBasePath, + path: join(cacheBasePath, VERSION, projectName, normalizedBuilderName), + localBasePath: localCacheBasePath, + localPath: join(localCacheBasePath, VERSION, projectName, normalizedBuilderName), + }; +} diff --git a/packages/angular/build/src/utils/normalize-cache_spec.ts b/packages/angular/build/src/utils/normalize-cache_spec.ts new file mode 100644 index 000000000000..1a0520c96a96 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache_spec.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +describe('normalizeCacheOptions', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-cache-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('should resolve cache path relative to workspace root in a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to corresponding nested workspace in main repository for a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const mainWorkspaceRoot = join(mainRepoRoot, 'Site1/ClientApp'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'Site1/ClientApp'); + + // Create main repo with a nested Angular workspace directory + await mkdir(mainGitDir, { recursive: true }); + await mkdir(mainWorkspaceRoot, { recursive: true }); + + // Create worktree with the same nested Angular workspace structure and a .git file at the worktree root + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Point the worktree metadata back to the main .git directory + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeWorkspaceRoot); + + expect(options.basePath).toBe(resolve(mainWorkspaceRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + }); + + it('should fall back to worktree workspace root when nested workspace does not exist in main repository', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'NewSite/ClientApp'); + + // Create main repo without the 'NewSite/ClientApp' subdirectory (e.g., added only on the worktree branch) + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree with the new nested Angular workspace and link it to the main repo's .git directory + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeWorkspaceRoot); + + expect(options.basePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + expect(options.localBasePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + }); + + it('should resolve local cache path relative to worktree root with custom relative path', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: 'custom-cache' } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(resolve(mainRepoRoot, 'custom-cache')); + expect(options.path).toBe(resolve(mainRepoRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, 'custom-cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + }); + + it('should preserve absolute cache path for both shared and local paths', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const absoluteCachePath = join(tempDir, 'absolute-cache'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: absoluteCachePath } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(absoluteCachePath); + expect(options.path).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(absoluteCachePath); + expect(options.localPath).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to workspace root in a git submodule', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const submoduleRoot = join(mainRepoRoot, 'submodule'); + + // Create main repo structure and submodule metadata folder + const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub'); + await mkdir(submoduleGitDir, { recursive: true }); + await mkdir(submoduleRoot, { recursive: true }); + + // Create .git file in submodule pointing to the metadata folder + await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`); + + // Submodules do NOT have a 'commondir' file. + const options = normalizeCacheOptions({}, submoduleRoot); + + expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(submoduleRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to workspace root when there is no git repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(workspaceRoot, { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should scope path and localPath to projectName when provided', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app'); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.path).toBe(resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app'), + ); + }); + + it('should scope path and localPath to projectName in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot, 'my-lib'); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-lib')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-lib'), + ); + }); + + it('should scope path and localPath to projectName and builderName when provided', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app', 'vite'); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + }); + + it('should scope path and localPath to projectName and builderName in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot, 'my-app', 'vite'); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(mainRepoRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + }); + + it('should normalize builderName to avoid invalid path characters', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app', '@angular/build:unit-test'); + + expect(options.path).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/angular-build-unit-test'), + ); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/angular-build-unit-test'), + ); + }); +}); diff --git a/packages/angular/build/src/utils/normalize-optimization.ts b/packages/angular/build/src/utils/normalize-optimization.ts new file mode 100644 index 000000000000..fcd5b556f27f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-optimization.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + FontsClass, + OptimizationClass, + OptimizationUnion, + StylesClass, +} from '../builders/application/schema'; + +export type NormalizedOptimizationOptions = Required< + Omit +> & { + fonts: FontsClass; + styles: StylesClass; +}; + +export function normalizeOptimization( + optimization: OptimizationUnion = true, +): NormalizedOptimizationOptions { + if (typeof optimization === 'object') { + const styleOptimization = !!optimization.styles; + + return { + scripts: !!optimization.scripts, + styles: + typeof optimization.styles === 'object' + ? optimization.styles + : { + minify: styleOptimization, + removeSpecialComments: styleOptimization, + inlineCritical: styleOptimization, + }, + fonts: + typeof optimization.fonts === 'object' + ? optimization.fonts + : { + inline: !!optimization.fonts, + }, + }; + } + + return { + scripts: optimization, + styles: { + minify: optimization, + inlineCritical: optimization, + removeSpecialComments: optimization, + }, + fonts: { + inline: optimization, + }, + }; +} diff --git a/packages/angular/build/src/utils/normalize-source-maps.ts b/packages/angular/build/src/utils/normalize-source-maps.ts new file mode 100644 index 000000000000..cf26ca236bae --- /dev/null +++ b/packages/angular/build/src/utils/normalize-source-maps.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { SourceMapClass, SourceMapUnion } from '../builders/application/schema'; + +export function normalizeSourceMaps(sourceMap: SourceMapUnion): SourceMapClass { + const scripts = typeof sourceMap === 'object' ? sourceMap.scripts : sourceMap; + const styles = typeof sourceMap === 'object' ? sourceMap.styles : sourceMap; + const hidden = (typeof sourceMap === 'object' && sourceMap.hidden) || false; + const vendor = (typeof sourceMap === 'object' && sourceMap.vendor) || false; + const sourcesContent = typeof sourceMap === 'object' ? sourceMap.sourcesContent : sourceMap; + + return { + vendor, + hidden, + scripts, + styles, + sourcesContent, + }; +} diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts new file mode 100644 index 000000000000..a236878d134e --- /dev/null +++ b/packages/angular/build/src/utils/path.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { isAbsolute, posix, relative, resolve } from 'node:path'; +import { platform } from 'node:process'; + +const WINDOWS_PATH_SEPERATOR_REGEXP = /\\/g; + +/** + * Converts a Windows-style file path to a POSIX-compliant path. + * + * This function replaces all backslashes (`\`) with forward slashes (`/`). + * It is a no-op on POSIX systems (e.g., Linux, macOS), as the conversion + * only runs on Windows (`win32`). + * + * @param path - The file path to convert. + * @returns The POSIX-compliant file path. + * + * @example + * ```ts + * // On a Windows system: + * toPosixPath('C:\\Users\\Test\\file.txt'); + * // => 'C:/Users/Test/file.txt' + * + * // On a POSIX system (Linux/macOS): + * toPosixPath('/home/user/file.txt'); + * // => '/home/user/file.txt' + * ``` + */ +export function toPosixPath(path: string): string { + return platform === 'win32' ? path.replace(WINDOWS_PATH_SEPERATOR_REGEXP, posix.sep) : path; +} + +/** + * Determines if a path is a subdirectory or file within a parent directory. + * + * @param parent - The parent directory path. + * @param child - The child path to check. + * @returns `true` if the child path is within the parent directory, `false` otherwise. + */ +export function isSubDirectory(parent: string, child: string): boolean { + const resolvedParent = resolve(parent); + const resolvedChild = resolve(parent, child); + const relativePath = toPosixPath(relative(resolvedParent, resolvedChild)); + + return relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath); +} + +/** + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase. + * + * @param pathString - The file path to canonicalize. + * @returns The canonicalized file path. + */ +export function canonicalizePath(pathString: string): string { + if (platform === 'win32' && /^[a-z]:/.test(pathString)) { + return pathString[0].toUpperCase() + pathString.slice(1); + } + + return pathString; +} diff --git a/packages/angular/build/src/utils/path_spec.ts b/packages/angular/build/src/utils/path_spec.ts new file mode 100644 index 000000000000..cd8ca3372433 --- /dev/null +++ b/packages/angular/build/src/utils/path_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { canonicalizePath, isSubDirectory } from './path'; + +describe('isSubDirectory', () => { + it('should return true for a direct child', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/baz')).toBeTrue(); + }); + + it('should return true for a nested child', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/baz/qux/file.txt')).toBeTrue(); + }); + + it('should return true if paths are identical', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar')).toBeTrue(); + }); + + it('should return false for a sibling directory starting with same prefix', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar-outside')).toBeFalse(); + expect(isSubDirectory('/foo/bar', '/foo/bar-outside/file.txt')).toBeFalse(); + }); + + it('should return false for directory outside parent via traversal', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/../outside')).toBeFalse(); + }); + + it('should return false for parent directory', () => { + expect(isSubDirectory('/foo/bar', '/foo')).toBeFalse(); + }); + + it('should return true for children containing directory names starting with double dots', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/..baz')).toBeTrue(); + expect(isSubDirectory('/foo/bar', '/foo/bar/..baz/qux')).toBeTrue(); + }); +}); + +describe('canonicalizePath', () => { + it('should return the path unmodified on POSIX systems', () => { + expect(canonicalizePath('/foo/bar/baz')).toBe('/foo/bar/baz'); + }); + + if (process.platform === 'win32') { + it('should uppercase Windows drive-letter casing', () => { + expect(canonicalizePath('c:/foo/bar')).toBe('C:/foo/bar'); + expect(canonicalizePath('d:\\foo\\bar')).toBe('D:\\foo\\bar'); + expect(canonicalizePath('C:/foo/bar')).toBe('C:/foo/bar'); + }); + } +}); diff --git a/packages/angular/build/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts new file mode 100644 index 000000000000..e5a4076232a0 --- /dev/null +++ b/packages/angular/build/src/utils/postcss-configuration.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join, relative } from 'node:path'; + +export interface PostcssConfiguration { + plugins: [name: string, options?: object | string][]; +} + +interface RawPostcssConfiguration { + plugins?: Record | (string | [string, object])[]; +} + +const postcssConfigurationFiles: string[] = ['postcss.config.json', '.postcssrc.json']; +const tailwindConfigFiles: string[] = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts', +]; + +export interface SearchDirectory { + root: string; + files: Set; +} + +export function generateSearchDirectories(roots: string[]): Promise { + return Promise.all( + roots.map((root) => + readdir(root, { withFileTypes: true }).then((entries) => ({ + root, + files: new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)), + })), + ), + ); +} + +function findFile( + searchDirectories: SearchDirectory[], + potentialFiles: string[], +): string | undefined { + for (const { root, files } of searchDirectories) { + for (const potential of potentialFiles) { + if (files.has(potential)) { + return join(root, potential); + } + } + } + + return undefined; +} + +export function findTailwindConfiguration( + searchDirectories: SearchDirectory[], +): string | undefined { + return findFile(searchDirectories, tailwindConfigFiles); +} + +export function getTailwindConfig( + searchDirectories: SearchDirectory[], + workspaceRoot: string, + logger?: { warn(message: string): void }, +): { file: string; package: string } | undefined { + const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories); + if (!tailwindConfigurationPath) { + return undefined; + } + + // Create a node resolver from the configuration file + const resolver = createRequire(tailwindConfigurationPath); + try { + return { + file: tailwindConfigurationPath, + package: resolver.resolve('tailwindcss'), + }; + } catch { + const relativeTailwindConfigPath = relative(workspaceRoot, tailwindConfigurationPath); + logger?.warn( + `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + + ` but the 'tailwindcss' package is not installed.` + + ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, + ); + } + + return undefined; +} + +async function readPostcssConfiguration( + configurationFile: string, +): Promise { + const data = await readFile(configurationFile, 'utf-8'); + const config = JSON.parse(data) as RawPostcssConfiguration; + + return config; +} + +export async function loadPostcssConfiguration(searchDirectories: SearchDirectory[]): Promise< + | { + configPath: string; + config: PostcssConfiguration; + } + | undefined +> { + const configPath = findFile(searchDirectories, postcssConfigurationFiles); + if (!configPath) { + return undefined; + } + + const raw = await readPostcssConfiguration(configPath); + + // If no plugins are defined, consider it equivalent to no configuration + if (!raw.plugins || typeof raw.plugins !== 'object') { + return undefined; + } + + // Normalize plugin array form + if (Array.isArray(raw.plugins)) { + if (raw.plugins.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const element of raw.plugins) { + if (typeof element === 'string') { + config.plugins.push([element]); + } else { + config.plugins.push(element); + } + } + + return { config, configPath }; + } + + // Normalize plugin object map form + const entries = Object.entries(raw.plugins); + if (entries.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const [name, options] of entries) { + if (!options || (typeof options !== 'object' && typeof options !== 'string')) { + continue; + } + + config.plugins.push([name, options]); + } + + return { config, configPath }; +} diff --git a/packages/angular/build/src/utils/profiling.ts b/packages/angular/build/src/utils/profiling.ts new file mode 100644 index 000000000000..762a739bd318 --- /dev/null +++ b/packages/angular/build/src/utils/profiling.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { debugPerformance } from './environment-options'; + +let cumulativeDurations: Map | undefined; + +export function resetCumulativeDurations(): void { + cumulativeDurations?.clear(); +} + +export function getAndClearCumulativeDurations(): Record | undefined { + if (!cumulativeDurations || cumulativeDurations.size === 0) { + return undefined; + } + + const data = Object.fromEntries(cumulativeDurations); + + cumulativeDurations.clear(); + + return data; +} + +export function mergeCumulativeDurations(data: Record): void { + cumulativeDurations ??= new Map(); + + for (const [name, durations] of Object.entries(data)) { + let existing = cumulativeDurations.get(name); + if (!existing) { + existing = []; + cumulativeDurations.set(name, existing); + } + + existing.push(...durations); + } +} + +export function logCumulativeDurations(): void { + if (!debugPerformance || !cumulativeDurations) { + return; + } + + for (const [name, durations] of cumulativeDurations) { + let total = 0; + let min; + let max; + for (const duration of durations) { + total += duration; + if (min === undefined || duration < min) { + min = duration; + } + if (max === undefined || duration > max) { + max = duration; + } + } + const average = total / durations.length; + // eslint-disable-next-line no-console + console.log( + `DURATION[${name}]: ${total.toFixed(9)}s [count: ${durations.length}; avg: ${average.toFixed( + 9, + )}s; min: ${min?.toFixed(9)}s; max: ${max?.toFixed(9)}s]`, + ); + } +} + +function recordDuration(name: string, startTime: bigint, cumulative?: boolean): void { + const duration = Number(process.hrtime.bigint() - startTime) / 10 ** 9; + if (cumulative) { + cumulativeDurations ??= new Map(); + const durations = cumulativeDurations.get(name) ?? []; + durations.push(duration); + cumulativeDurations.set(name, durations); + } else { + // eslint-disable-next-line no-console + console.log(`DURATION[${name}]: ${duration.toFixed(9)}s`); + } +} + +export async function profileAsync( + name: string, + action: () => Promise, + cumulative?: boolean, +): Promise { + if (!debugPerformance) { + return action(); + } + + const startTime = process.hrtime.bigint(); + try { + return await action(); + } finally { + recordDuration(name, startTime, cumulative); + } +} + +export function profileSync(name: string, action: () => T, cumulative?: boolean): T { + if (!debugPerformance) { + return action(); + } + + const startTime = process.hrtime.bigint(); + try { + return action(); + } finally { + recordDuration(name, startTime, cumulative); + } +} diff --git a/packages/angular/build/src/utils/project-metadata.ts b/packages/angular/build/src/utils/project-metadata.ts new file mode 100644 index 000000000000..31912d5e9905 --- /dev/null +++ b/packages/angular/build/src/utils/project-metadata.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { join } from 'node:path'; + +/** + * Normalize a directory path string. + * Currently only removes a trailing slash if present. + * @param path A path string. + * @returns A normalized path string. + */ +export function normalizeDirectoryPath(path: string): string { + const last = path.at(-1); + if (last === '/' || last === '\\') { + return path.slice(0, -1); + } + + return path; +} + +export function getProjectRootPaths( + workspaceRoot: string, + projectMetadata: { root?: string; sourceRoot?: string }, +) { + const projectRoot = normalizeDirectoryPath(join(workspaceRoot, projectMetadata.root ?? '')); + const rawSourceRoot = projectMetadata.sourceRoot; + const projectSourceRoot = normalizeDirectoryPath( + rawSourceRoot === undefined ? join(projectRoot, 'src') : join(workspaceRoot, rawSourceRoot), + ); + + return { projectRoot, projectSourceRoot }; +} diff --git a/packages/angular/build/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts new file mode 100644 index 000000000000..545d2dd97bba --- /dev/null +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +/** Delete stale cache directories used by previous versions of build-angular. */ +export async function purgeStaleBuildCache(context: BuilderContext): Promise { + const projectName = context.target?.project; + if (!projectName) { + return; + } + + const metadata = await context.getProjectMetadata(projectName); + const { basePath, path, localBasePath, localPath, enabled } = normalizeCacheOptions( + metadata, + context.workspaceRoot, + ); + + if (!enabled) { + return; + } + + const basePaths = new Set([basePath]); + if (localBasePath) { + basePaths.add(localBasePath); + } + + for (const base of basePaths) { + let baseEntries; + try { + baseEntries = await readdir(base, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + continue; + } + + const currentPath = base === localBasePath ? localPath : path; + if (!currentPath) { + // Avoid purging if current path is unavailable to prevent deleting the active cache + continue; + } + + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(base, d.name)) + .filter((cachePath) => cachePath !== currentPath) + .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + + await Promise.allSettled(entriesToDelete); + } +} diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts new file mode 100644 index 000000000000..d7990288b603 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { lstat, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { glob, isDynamicPattern } from 'tinyglobby'; +import { MAX_CONCURRENT_READS, mapConcurrent } from './concurrency'; +import { isSubDirectory } from './path'; + +const DEFAULT_ASSET_IGNORE_FILES: ReadonlySet = new Set([ + '.gitkeep', + '.DS_Store', + 'Thumbs.db', +]); +const DEFAULT_ASSET_IGNORE_PATTERNS: readonly string[] = Array.from( + DEFAULT_ASSET_IGNORE_FILES, + (file) => `**/${file}`, +); + +/** + * Normalizes an ignore glob pattern to match directory-exclusion semantics by stripping + * trailing slashes, normalizing POSIX segments, and expanding patterns with `/**` + * (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes + * because `picomatch` does not make trailing `/**` optional after `*`). + * + * @param pattern The raw ignore glob pattern string. + * @returns The normalized POSIX glob patterns. + */ +function normalizeIgnorePattern(pattern: string): string[] { + if (!pattern) { + return []; + } + + const posixNormalized = path.posix.normalize(pattern); + const normalized = + posixNormalized.length > 1 && posixNormalized.endsWith('/') + ? posixNormalized.slice(0, -1) + : posixNormalized; + + if (!normalized.endsWith('*')) { + return [`${normalized}/**`]; + } + + if (normalized === '**' || normalized.endsWith('/**')) { + return [normalized]; + } + + return [normalized, `${normalized}/**`]; +} + +/** + * Combines default asset ignore patterns with user-provided ignore patterns and normalizes + * them so directory patterns (such as `'sub'` or `'sub/'`) exclude all nested files. + * + * @param ignore Optional user-provided ignore glob patterns. + * @returns The normalized ignore glob patterns. + */ +export function normalizeAssetIgnorePatterns(ignore?: readonly string[]): string[] { + if (!ignore?.length) { + return [...DEFAULT_ASSET_IGNORE_PATTERNS]; + } + + return [ + ...DEFAULT_ASSET_IGNORE_PATTERNS, + ...ignore.flatMap((pattern) => normalizeIgnorePattern(pattern)), + ]; +} + +export async function resolveAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + root: string, +): Promise<{ source: string; destination: string }[]> { + const resolvedEntries = await mapConcurrent(entries, MAX_CONCURRENT_READS, async (entry) => { + if (!isSubDirectory(root, entry.input)) { + throw new Error(`The ${entry.input} asset path must be within the workspace root.`); + } + + if (!entry.glob || entry.glob.endsWith('/')) { + return []; + } + + const cwd = path.resolve(root, entry.input); + + if (!entry.ignore?.length && !isDynamicPattern(entry.glob)) { + const src = path.join(cwd, entry.glob); + if (isSubDirectory(cwd, src)) { + if (DEFAULT_ASSET_IGNORE_FILES.has(path.basename(src))) { + return []; + } + + try { + const stats = await (entry.followSymlinks ? stat(src) : lstat(src)); + if (stats.isFile()) { + const filePath = entry.flatten ? path.basename(src) : path.relative(cwd, src); + + return [{ source: src, destination: path.join(entry.output, filePath) }]; + } + + return []; + } catch { + // File does not exist or cannot be accessed. + return []; + } + } + } + + const files = await glob(entry.glob, { + cwd, + dot: true, + expandDirectories: false, + ignore: normalizeAssetIgnorePatterns(entry.ignore), + followSymbolicLinks: entry.followSymlinks ?? false, + }); + + return files.map((file) => { + const destination = path.join(entry.output, entry.flatten ? path.basename(file) : file); + if (destination === '..' || destination.startsWith(`..${path.sep}`)) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return { + source: path.join(cwd, file), + destination, + }; + }); + }); + + return resolvedEntries.flat(); +} diff --git a/packages/angular/build/src/utils/resolve-project.ts b/packages/angular/build/src/utils/resolve-project.ts new file mode 100644 index 000000000000..ad31825683c4 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-project.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +/** + * Creates a module resolver function that is strictly scoped to the project root. + * This prevents module resolution from leaking the parent module context when executing inside virtual stores (like pnpm). + * + * @param projectRoot The root directory of the project. + * @returns A resolver function that takes a package name/path and returns its resolved path. + */ +export function createProjectResolver(projectRoot: string): (packageName: string) => string { + const projectRequire = createRequire(join(projectRoot, 'package.json')); + + return (packageName: string) => projectRequire.resolve(packageName, { paths: [projectRoot] }); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts new file mode 100644 index 000000000000..6ff4c8f8191c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. + */ +const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;'); +const UTF8_DECODER = new TextDecoder(); + +/** + * Node.js ESM loader to redirect imports to in memory files. + * @see: https://nodejs.org/api/esm.html#loaders for more information about loaders. + */ + +const MEMORY_URL_SCHEME = 'memory://'; + +export interface ESMInMemoryFileLoaderWorkerData { + outputFiles: Record; + workspaceRoot: string; +} + +let memoryVirtualRootUrl: string; +let outputFiles: Record; + +export function initialize(data: ESMInMemoryFileLoaderWorkerData) { + // This path does not actually exist but is used to overlay the in memory files with the + // actual filesystem for resolution purposes. + // A custom URL schema (such as `memory://`) cannot be used for the resolve output because + // the in-memory files may use `import.meta.url` in ways that assume a file URL. + // `createRequire` is one example of this usage. + memoryVirtualRootUrl = pathToFileURL( + join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), + ).href; + outputFiles = data.outputFiles; +} + +export function resolve( + specifier: string, + context: { parentURL: undefined | string }, + nextResolve: Function, +) { + // In-memory files loaded from external code will contain a memory scheme + if (specifier.startsWith(MEMORY_URL_SCHEME)) { + let memoryUrl; + try { + memoryUrl = new URL(specifier); + } catch { + assert.fail('External code attempted to use malformed memory scheme: ' + specifier); + } + + // Resolve with a URL based from the virtual filesystem root + return { + format: 'module', + shortCircuit: true, + url: new URL(memoryUrl.pathname.slice(1), memoryVirtualRootUrl).href, + }; + } + + // Use next/default resolve if the parent is not from the virtual root + if (!context.parentURL?.startsWith(memoryVirtualRootUrl)) { + return nextResolve(specifier, context); + } + + // Check for `./` and `../` relative specifiers + const isRelative = + specifier[0] === '.' && + (specifier[1] === '/' || (specifier[1] === '.' && specifier[2] === '/')); + + // Relative specifiers from memory file should be based from the parent memory location + if (isRelative) { + let specifierUrl; + try { + specifierUrl = new URL(specifier, context.parentURL); + } catch {} + + if ( + specifierUrl?.href.startsWith(memoryVirtualRootUrl) && + Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) + ) { + return { + format: 'module', + shortCircuit: true, + url: specifierUrl.href, + }; + } + + assert.fail( + `In-memory ESM relative file should always exist: '${context.parentURL}' --> '${specifier}'`, + ); + } + + // Update the parent URL to allow for module resolution for the workspace. + // This handles bare specifiers (npm packages) and absolute paths. + // Defer to the next hook in the chain, which would be the + // Node.js default resolve if this is the last user-specified loader. + return nextResolve(specifier, { + ...context, + parentURL: new URL('index.js', memoryVirtualRootUrl).href, + }); +} + +export function load(url: string, context: { format?: string | null }, nextLoad: Function) { + const { format } = context; + + // Load the file from memory if the URL is based in the virtual root + if (url.startsWith(memoryVirtualRootUrl)) { + const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource); + + // In-memory files have already been transformer during bundling and can be returned directly + return { + format: format ?? 'module', + shortCircuit: true, + source, + }; + } + + // Only module files potentially require transformation. Angular libraries that would + // need linking are ESM only. + if (format === 'module' && isFileProtocol(url)) { + // Check url instead of filePath so the check is robust across Windows and POSIX path separators. + if (!url.includes('/@angular/')) { + return nextLoad(url, context); + } + + const filePath = fileURLToPath(url); + const fileBytes = readFileSync(filePath); + const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]); + + return { + format, + shortCircuit: true, + source, + }; + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} + +function isFileProtocol(url: string): boolean { + return url.startsWith('file://'); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts new file mode 100644 index 000000000000..0b32c2248b10 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { initialize, load, resolve } from './loader-hooks'; +import { createSharedServerFiles } from './utils'; + +describe('esm-in-memory-loader loader-hooks', () => { + const workspaceRoot = '/mock/workspace/root'; + const sharedFiles = createSharedServerFiles({ + 'main.server.mjs': 'export const main = true;', + 'chunk-abc.mjs': 'export const chunk = "abc";', + 'nested/chunk-sub.mjs': 'export const sub = "sub";', + 'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";', + 'empty.mjs': '', + }); + + beforeEach(() => { + initialize({ + workspaceRoot, + outputFiles: sharedFiles, + }); + }); + + describe('resolve', () => { + it('should resolve memory:// URLs into virtual filesystem URLs', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/.angular/prerender-root/'); + expect(result.url).toContain('/main.server.mjs'); + }); + + it('should fail when memory:// URL is malformed', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('memory://::invalid', { parentURL: undefined }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('External code attempted to use malformed memory scheme'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should resolve relative specifiers within in-memory files', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should resolve relative specifiers navigating parent directories within in-memory files', () => { + const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should fail when relative specifier from in-memory file does not exist', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('./non-existent.mjs', { parentURL }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('In-memory ESM relative file should always exist'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' }); + const result = resolve('@angular/core', { parentURL }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith( + '@angular/core', + jasmine.objectContaining({ + parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/), + }), + ); + expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js'); + }); + + it('should delegate to nextResolve for external non-memory URLs', () => { + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/ext/pkg' }); + const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith('some-pkg', { + parentURL: 'file:///some/ext/file.js', + }); + expect(result.url).toBe('file:///some/ext/pkg'); + }); + }); + + describe('load', () => { + it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const main = true;'); + }); + + it('should load in-memory file with multi-byte UTF-8 characters', () => { + const memoryUrl = new URL('./utf8.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";'); + }); + + it('should load in-memory file with empty content', () => { + const memoryUrl = new URL('./empty.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(''); + }); + + it('should load in-memory file source with non-zero byteOffset in Uint8Array', () => { + const target = 'export const sliced = 42;'; + const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`); + const offset = Buffer.byteLength('__PADDING__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + initialize({ + workspaceRoot, + outputFiles: { + 'sliced.mjs': subView, + }, + }); + + const memoryUrl = new URL('./sliced.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(target); + }); + + it('should load in-memory file source when outputFiles contain string values', () => { + initialize({ + workspaceRoot, + outputFiles: { + 'string-file.mjs': 'export const fromString = 1;', + }, + }); + + const memoryUrl = new URL('./string-file.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const fromString = 1;'); + }); + + it('should throw when in-memory file does not exist in outputFiles', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs'); + const nextLoad = jasmine.createSpy('nextLoad'); + + expect(() => load(nonExistentVirtualUrl, { format: 'module' }, nextLoad)).toThrowError( + /Resolved in-memory ESM file should always exist/, + ); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + it('should prepend ngServerMode initialization for @angular/ file URLs', async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + const tmpDir = await mkdtemp(join(baseTmpDir, 'loader-hooks-test-')); + + try { + const angularDir = join(tmpDir, '@angular/core'); + await mkdir(angularDir, { recursive: true }); + const filePath = join(angularDir, 'index.mjs'); + await writeFile(filePath, 'export const VERSION = "1.0.0";'); + + const nextLoad = jasmine.createSpy('nextLoad'); + const result = load(pathToFileURL(filePath).href, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(new TextDecoder().decode(result.source)).toBe( + 'var ngServerMode=true;export const VERSION = "1.0.0";', + ); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should delegate to nextLoad for non-angular file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'module' }); + const result = load( + 'file:///workspace/node_modules/rxjs/index.js', + { format: 'module' }, + nextLoad, + ); + + expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', { + format: 'module', + }); + expect(result.format).toBe('module'); + }); + + it('should delegate to nextLoad for non-memory non-file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'builtin' }); + const result = load('node:fs', { format: 'builtin' }, nextLoad); + + expect(nextLoad).toHaveBeenCalledWith('node:fs'); + expect(result.format).toBe('builtin'); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts new file mode 100644 index 000000000000..56747e17bd03 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { registerHooks } from 'node:module'; +import { workerData } from 'node:worker_threads'; +import { initialize, load, resolve } from './loader-hooks'; + +initialize(workerData); +registerHooks({ resolve, load }); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts new file mode 100644 index 000000000000..7eb2bd1f5c49 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const IMPORT_EXEC_ARGV = + '--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href; + +/** + * Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content. + */ +export function createSharedFile(content: string | Uint8Array): Uint8Array { + if (typeof content === 'string') { + const byteLength = Buffer.byteLength(content, 'utf-8'); + const sab = new SharedArrayBuffer(byteLength); + Buffer.from(sab).write(content, 'utf-8'); + + return new Uint8Array(sab); + } + + if (content.buffer instanceof SharedArrayBuffer) { + return content; + } + + const sab = new SharedArrayBuffer(content.byteLength); + const view = new Uint8Array(sab); + view.set(content); + + return view; +} + +/** + * Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files. + */ +export function createSharedServerFiles( + outputFiles: Record, +): Record { + const sharedFiles: Record = {}; + + for (const [key, value] of Object.entries(outputFiles)) { + sharedFiles[key] = createSharedFile(value); + } + + return sharedFiles; +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts new file mode 100644 index 000000000000..b7bb2844b66e --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createSharedFile, createSharedServerFiles } from './utils'; + +describe('server-rendering shared file utilities', () => { + describe('createSharedFile', () => { + it('should create a Uint8Array backed by SharedArrayBuffer from string', () => { + const text = 'console.log("hello world");'; + const shared = createSharedFile(text); + + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(Buffer.byteLength(text, 'utf-8')); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should correctly handle multi-byte UTF-8 characters', () => { + const text = 'export const greeting = "🚀 Привет, мир! 🌍";'; + const shared = createSharedFile(text); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should handle empty string', () => { + const shared = createSharedFile(''); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(''); + }); + + it('should handle empty Uint8Array', () => { + const shared = createSharedFile(new Uint8Array(0)); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + }); + + it('should return the same Uint8Array if already backed by SharedArrayBuffer', () => { + const text = 'export default 42;'; + const shared1 = createSharedFile(text); + const shared2 = createSharedFile(shared1); + + expect(shared2).toBe(shared1); + expect(shared2.buffer).toBe(shared1.buffer); + }); + + it('should return existing Uint8Array view if already backed by SharedArrayBuffer with non-zero byteOffset', () => { + const text = '0123456789abcdef'; + const shared1 = createSharedFile(text); + const subView = new Uint8Array(shared1.buffer, 4, 8); + const shared2 = createSharedFile(subView); + + expect(shared2).toBe(subView); + expect(shared2.byteOffset).toBe(4); + expect(shared2.byteLength).toBe(8); + + const decoded = Buffer.from(shared2.buffer, shared2.byteOffset, shared2.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('456789ab'); + }); + + it('should convert standard Uint8Array to SharedArrayBuffer backed Uint8Array', () => { + const regularBuffer = Buffer.from('export const test = true;'); + expect(regularBuffer.buffer instanceof SharedArrayBuffer).toBeFalse(); + + const shared = createSharedFile(regularBuffer); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('export const test = true;'); + }); + + it('should handle Uint8Array slice with non-zero byteOffset from standard ArrayBuffer', () => { + const target = 'export const data = 123;'; + const fullBuffer = Buffer.from(`__PREFIX__${target}__SUFFIX__`); + const offset = Buffer.byteLength('__PREFIX__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + const shared = createSharedFile(subView); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(length); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(target); + }); + }); + + describe('createSharedServerFiles', () => { + it('should convert all entries in a record to SharedArrayBuffer backed Uint8Arrays', () => { + const files: Record = { + 'main.server.mjs': 'export default function() {}', + 'chunk-1.mjs': 'export const x = 1;', + 'render-utils.mjs': Buffer.from('export const helper = () => true;'), + }; + + const sharedFiles = createSharedServerFiles(files); + + expect(Object.keys(sharedFiles)).toEqual([ + 'main.server.mjs', + 'chunk-1.mjs', + 'render-utils.mjs', + ]); + + for (const [key, shared] of Object.entries(sharedFiles)) { + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const expectedText = + typeof files[key] === 'string' ? files[key] : (files[key] as Buffer).toString('utf-8'); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(expectedText); + } + }); + + it('should handle empty file map', () => { + const sharedFiles = createSharedServerFiles({}); + expect(Object.keys(sharedFiles).length).toBe(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts new file mode 100644 index 000000000000..c099d7dd902c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { workerData } from 'node:worker_threads'; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { assetFiles } = workerData as { + assetFiles: Record; +}; + +const assetsCache: Map; content: Buffer }> = + new Map(); + +export function patchFetchToLoadInMemoryAssets(baseURL: URL): void { + const originalFetch = globalThis.fetch; + const patchedFetch: typeof fetch = async (input, init) => { + let url: URL; + if (input instanceof URL) { + url = input; + } else if (typeof input === 'string') { + url = new URL(input, baseURL); + } else if (typeof input === 'object' && 'url' in input) { + url = new URL(input.url, baseURL); + } else { + return originalFetch(input, init); + } + + const { hostname } = url; + const pathname = decodeURIComponent(url.pathname); + + if (hostname !== baseURL.hostname || !assetFiles[pathname]) { + // Only handle relative requests or files that are in assets. + return originalFetch(input, init); + } + + const cachedAsset = assetsCache.get(pathname); + if (cachedAsset) { + const { content, headers } = cachedAsset; + + return new Response(content, { + headers, + }); + } + + const extension = extname(pathname); + const mimeType = lookupMimeType(extension); + const content = await readFile(assetFiles[pathname]); + const headers = mimeType + ? { + 'Content-Type': mimeType, + } + : undefined; + + assetsCache.set(pathname, { headers, content }); + + return new Response(content, { + headers, + }); + }; + + globalThis.fetch = patchedFetch; +} diff --git a/packages/angular/build/src/utils/server-rendering/launch-server.ts b/packages/angular/build/src/utils/server-rendering/launch-server.ts new file mode 100644 index 000000000000..95b2784c6f63 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/launch-server.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import { createServer } from 'node:http'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { isSsrNodeRequestHandler, isSsrRequestHandler } from './utils'; + +export const DEFAULT_URL = new URL('http://ng-localhost/'); + +/** + * Launches a server that handles local requests. + * + * @returns A promise that resolves to the URL of the running server. + */ +export async function launchServer(): Promise { + const { reqHandler } = await loadEsmModuleFromMemory('./server.mjs'); + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + if (!isSsrNodeRequestHandler(reqHandler) && !isSsrRequestHandler(reqHandler)) { + return DEFAULT_URL; + } + + const server = createServer((req, res) => { + (async () => { + // handle request + if (isSsrNodeRequestHandler(reqHandler)) { + await reqHandler(req, res, (e) => { + throw e ?? new Error(`Unable to handle request: '${req.url}'.`); + }); + } else { + const webRes = await reqHandler(createWebRequestFromNodeRequest(req)); + if (webRes) { + await writeResponseToNodeResponse(webRes, res); + } else { + res.statusCode = 501; + res.end('Not Implemented.'); + } + } + })().catch((e) => { + res.statusCode = 500; + res.end('Internal Server Error.'); + // eslint-disable-next-line no-console + console.error(e); + }); + }); + + server.unref(); + + await new Promise((resolve) => server.listen(0, 'localhost', resolve)); + + const serverAddress = server.address(); + assert(serverAddress, 'Server address should be defined.'); + assert(typeof serverAddress !== 'string', 'Server address should not be a string.'); + + return new URL(`http://localhost:${serverAddress.port}/`); +} diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts new file mode 100644 index 000000000000..87ca9928a86f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ApplicationRef, Type } from '@angular/core'; +import type { BootstrapContext } from '@angular/platform-browser'; +import type { ɵextractRoutesAndCreateRouteTree, ɵgetOrCreateAngularServerApp } from '@angular/ssr'; +import { assertIsError } from '../error'; +import { loadEsmModule } from '../load-esm'; + +/** + * Represents the exports available from the main server bundle. + */ +interface MainServerBundleExports { + default: ((context: BootstrapContext) => Promise) | Type; + ɵextractRoutesAndCreateRouteTree: typeof ɵextractRoutesAndCreateRouteTree; + ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp; +} + +/** + * Represents the exports available from the server bundle. + */ +interface ServerBundleExports { + reqHandler?: unknown; +} + +export function loadEsmModuleFromMemory( + path: './main.server.mjs', +): Promise; +export function loadEsmModuleFromMemory(path: './server.mjs'): Promise; +export function loadEsmModuleFromMemory( + path: './main.server.mjs' | './server.mjs', +): Promise { + return loadEsmModule(new URL(path, 'memory://')).catch((e) => { + assertIsError(e); + + // While the error is an 'instanceof Error', it is extended with non transferable properties + // and cannot be transferred from a worker when using `--import`. This results in the error object + // displaying as '[Object object]' when read outside of the worker. Therefore, we reconstruct the error message here. + const error: Error & { code?: string } = new Error(e.message); + error.stack = e.stack; + error.name = e.name; + error.code = e.code; + + throw error; + }) as Promise; +} diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts new file mode 100644 index 000000000000..6877107a0b96 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { Buffer } from 'node:buffer'; +import { extname } from 'node:path'; +import { + INDEX_HTML_SERVER, + NormalizedApplicationBuildOptions, +} from '../../builders/application/options'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { findNonce } from '../index-file/nonce'; +import { joinUrlParts } from '../url'; + +export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; +export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; + +/** + * A set of server-generated dependencies that are treated as external. + * + * These dependencies are marked as external because they are produced by a + * separate bundling process and are not included in the primary bundle. This + * ensures that these generated files are resolved from an external source rather + * than being part of the main bundle. + */ +export const SERVER_GENERATED_EXTERNALS = new Set([ + './polyfills.server.mjs', + './' + SERVER_APP_MANIFEST_FILENAME, + './' + SERVER_APP_ENGINE_MANIFEST_FILENAME, +]); + +const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; + +/** + * A mapping of unsafe characters to their escaped Unicode equivalents. + */ +const UNSAFE_CHAR_MAP: Record = { + '`': '\\`', + '$': '\\$', + '\\': '\\\\', +}; + +/** + * Escapes unsafe characters in a given string by replacing them with + * their Unicode escape sequences. + * + * @param str - The string to be escaped. + * @returns The escaped string where unsafe characters are replaced. + */ +function escapeUnsafeChars(str: string): string { + return str.replace(/[$`\\]/g, (c) => UNSAFE_CHAR_MAP[c]); +} + +/** + * Generates the server manifest for the App Engine environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when deployed to Google App Engine. It includes the entry points + * for different locales and the base HREF for the application. + * + * @param i18nOptions - The internationalization options for the application build. This + * includes settings for inlining locales and determining the output structure. + * @param allowedHosts - A list of hosts that are allowed to access the server-side application. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. + */ +export function generateAngularServerAppEngineManifest( + i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], + allowedHosts: string[], + baseHref: string | undefined, +): string { + const entryPoints: Record = {}; + const supportedLocales: Record = {}; + + if (i18nOptions.shouldInline && !i18nOptions.flatOutput) { + for (const locale of i18nOptions.inlineLocales) { + const { subPath } = i18nOptions.locales[locale]; + const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; + entryPoints[subPath] = `() => import(${JSON.stringify(`./${importPath}`)})`; + supportedLocales[locale] = subPath; + } + } else { + entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`; + supportedLocales[i18nOptions.sourceLocale] = ''; + } + + // Remove trailing slash but retain leading slash. + let basePath = baseHref || '/'; + if (basePath.length > 1 && basePath.at(-1) === '/') { + basePath = basePath.slice(0, -1); + } + + const manifestContent = ` +export default { + basePath: ${JSON.stringify(basePath)}, + allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)}, + supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, + entryPoints: { + ${Object.entries(entryPoints) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) + .join(',\n ')} + }, +}; +`; + + return manifestContent; +} + +/** + * Generates the server manifest for the standard Node.js environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when running in a standard Node.js environment. It includes + * information about the bootstrap module, whether to inline critical CSS, and any + * additional HTML and CSS output files. + * + * @param additionalHtmlOutputFiles - A map of additional HTML output files generated + * during the build process, keyed by their file paths. + * @param outputFiles - An array of all output files from the build process, including + * JavaScript and CSS files. + * @param inlineCriticalCss - A boolean indicating whether critical CSS should be inlined + * in the server-side rendered pages. + * @param routes - An optional array of route definitions for the application, used for + * server-side rendering and routing. + * @param locale - An optional string representing the locale or language code to be used for + * the application, helping with localization and rendering content specific to the locale. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. + * @param initialFiles - A list of initial files that preload tags have already been added for. + * @param metafile - An esbuild metafile object. + * @param publicPath - The configured public path. + * + * @returns An object containing: + * - `manifestContent`: A string of the SSR manifest content. + * - `serverAssetsChunks`: An array of build output files containing the generated assets for the server. + */ +export async function generateAngularServerAppManifest( + additionalHtmlOutputFiles: Map, + outputFiles: BuildOutputFile[], + inlineCriticalCss: boolean, + routes: readonly unknown[] | undefined, + locale: string | undefined, + baseHref: string, + initialFiles: Set, + metafile: Metafile, + publicPath: string | undefined, +): Promise<{ + manifestContent: string; + serverAssetsChunks: BuildOutputFile[]; +}> { + const serverAssetsChunks: BuildOutputFile[] = []; + const serverAssets: Record = {}; + const criticalCssPlans: unknown[] = []; + let nonce: string | undefined; + + // TODO(alanagius): This is done here as we do not use module resolution bundler/node16 + const { compileSheet, encodePlan } = (await import( + 'beasties/compiler' as string + )) as typeof import('beasties/compiler', { with: { 'resolution-mode': 'import' } }); + + for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { + const extension = extname(file.path); + if (extension === '.html') { + const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; + const escapedContent = escapeUnsafeChars(file.text); + + serverAssetsChunks.push( + createOutputFile( + jsChunkFilePath, + `export default \`${escapedContent}\`;`, + BuildOutputFileType.ServerApplication, + ), + ); + + // JavaScript engine script parsers normalize `\r\n` (2 bytes in UTF-8) to `\n` (1 byte in UTF-8) in template literals. + // Subtracting the count of `\r\n` occurrences avoids allocating any temporary strings or match arrays for large assets. + let size = Buffer.byteLength(file.text); + let pos = file.text.indexOf('\r\n'); + while (pos !== -1) { + size--; + pos = file.text.indexOf('\r\n', pos + 2); + } + + // Asset paths are derived from route paths and can contain arbitrary characters, so they are + // serialized rather than interpolated into the generated executable manifest. + serverAssets[file.path] = + `{size: ${size}, hash: ${JSON.stringify(file.hash)}, ` + + `text: () => import(${JSON.stringify(`./${jsChunkFilePath}`)}).then(m => m.default)}`; + } else if (inlineCriticalCss && extension === '.css') { + const sheet = compileSheet(file.text, { + href: joinUrlParts(publicPath ?? '', file.path), + }); + criticalCssPlans.push(encodePlan(sheet)); + } + } + + const indexHtml = additionalHtmlOutputFiles.get(INDEX_HTML_SERVER)?.text; + if (indexHtml) { + nonce = (await findNonce(indexHtml)) ?? undefined; + } + + // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. + const entryPointToBrowserMapping = routes?.length + ? undefined + : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); + + const manifestContent = ` +export default { + bootstrap: () => import('./main.server.mjs').then(m => m.default), + baseHref: ${JSON.stringify(baseHref)}, +${criticalCssPlans.length ? ` criticalCssPlans: ${JSON.stringify(criticalCssPlans)},\n` : ''}${ + nonce ? ` nonce: ${JSON.stringify(nonce)},\n` : '' + } locale: ${JSON.stringify(locale)}, + routes: ${JSON.stringify(routes, undefined, 2)}, + entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, + assets: { + ${Object.entries(serverAssets) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) + .join(',\n ')} + }, +}; +`; + + return { manifestContent, serverAssetsChunks }; +} + +/** + * Maps entry points to their corresponding browser bundles for lazy loading. + * + * This function processes a metafile's outputs to generate a mapping between browser-side entry points + * and the associated JavaScript files that should be loaded in the browser. It includes the entry-point's + * own path and any valid imports while excluding initial files or external resources. + */ +function generateLazyLoadedFilesMappings( + metafile: Metafile, + initialFiles: Set, + publicPath = '', +): Record { + const entryPointToBundles: Record = {}; + for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) { + // Skip files that don't have an entryPoint, no exports, or are not .js + if (!entryPoint || exports?.length < 1 || !fileName.endsWith('.js')) { + continue; + } + + const importedPaths: string[] = [`${publicPath}${fileName}`]; + + for (const { kind, external, path } of imports) { + if (external || initialFiles.has(path) || kind !== 'import-statement') { + continue; + } + + importedPaths.push(`${publicPath}${path}`); + } + + entryPointToBundles[entryPoint] = importedPaths; + } + + return entryPointToBundles; +} diff --git a/packages/angular/build/src/utils/server-rendering/manifest_spec.ts b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts new file mode 100644 index 000000000000..4d9ae0da32f6 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files'; +import { initializeHash } from '../hash'; +import { generateAngularServerAppManifest } from './manifest'; + +describe('generateAngularServerAppManifest', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile; + + /** + * Evaluates a generated manifest, which both asserts that it is syntactically valid JavaScript and + * gives access to the values it declares. The dynamic imports it contains are never invoked. + */ + function evaluateManifest(manifestContent: string): unknown { + return new Function(manifestContent.replace('export default', 'return'))(); + } + + function generateManifest( + htmlOutputFiles: Record, + baseHref = '/', + ): ReturnType { + const additionalHtmlOutputFiles = new Map( + Object.entries(htmlOutputFiles).map(([path, content]) => [ + path, + createOutputFile(path, content, BuildOutputFileType.Browser), + ]), + ); + + return generateAngularServerAppManifest( + additionalHtmlOutputFiles, + [], + false, + undefined, + undefined, + baseHref, + new Set(), + dummyMetafile, + undefined, + ); + } + + it('should include criticalCssPlans when inlineCriticalCss is true', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('criticalCssPlans: ['); + expect(manifestContent).toContain('styles.css'); + expect(manifestContent).not.toContain('nonce:'); + }); + + it('should not include criticalCssPlans when inlineCriticalCss is false', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + false, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).not.toContain('criticalCssPlans:'); + }); + + it('should extract template nonce from index HTML when present', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('nonce: "{% nonce %}"'); + }); + + it('should not include css files in serverAssetsChunks or assets', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(serverAssetsChunks.some((chunk) => chunk.path.includes('styles'))).toBeFalse(); + expect(manifestContent).not.toContain('"styles.css":'); + }); + + it('should serialize asset paths which contain JavaScript string delimiters', async () => { + const assetPath = "catalog/customer's-choice/index.html"; + const { manifestContent } = await generateManifest({ + [assetPath]: '
Featured
', + }); + + expect(evaluateManifest(manifestContent)).toEqual( + jasmine.objectContaining({ + assets: { + [assetPath]: jasmine.objectContaining({ + size: 21, + hash: jasmine.any(String), + text: jasmine.any(Function), + }), + }, + }), + ); + }); + + it('should serialize a base href which contains JavaScript string delimiters', async () => { + const { manifestContent } = await generateManifest( + { 'index.html': '
' }, + "/o'brien/", + ); + + expect(evaluateManifest(manifestContent)).toEqual( + jasmine.objectContaining({ + baseHref: "/o'brien/", + }), + ); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/models.ts b/packages/angular/build/src/utils/server-rendering/models.ts new file mode 100644 index 000000000000..9a9020d2db7f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/models.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { RenderMode, ɵextractRoutesAndCreateRouteTree } from '@angular/ssr'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; + +type Writeable = T extends readonly (infer U)[] ? U[] : never; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; +} + +export type SerializableRouteTreeNode = ReturnType< + Awaited>['routeTree']['toObject'] +>; + +export type WritableSerializableRouteTreeNode = Writeable; + +export interface RoutersExtractorWorkerResult { + serializedRouteTree: SerializableRouteTreeNode; + appShellRoute?: string; + errors: string[]; +} + +/** + * Local copy of `RenderMode` exported from `@angular/ssr`. + * This constant is needed to handle interop between CommonJS (CJS) and ES Modules (ESM) formats. + * + * It maps `RenderMode` enum values to their corresponding numeric identifiers. + */ +export const RouteRenderMode: Record = { + Server: 0, + Client: 1, + Prerender: 2, +}; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts new file mode 100644 index 000000000000..ec687be0e011 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -0,0 +1,477 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { extname, posix } from 'node:path'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode, Format as PrerenderFormat } from '../../builders/application/schema'; +import { + BuildOutputAsset, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { assertIsError } from '../error'; +import { toPosixPath } from '../path'; +import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; +import { WorkerPool } from '../worker-pool'; +import { + IMPORT_EXEC_ARGV, + createSharedFile, + createSharedServerFiles, +} from './esm-in-memory-loader/utils'; +import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; +import { + RouteRenderMode, + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, + SerializableRouteTreeNode, + WritableSerializableRouteTreeNode, +} from './models'; +import type { RenderResult, RenderWorkerData } from './render-worker'; +import { generateRedirectStaticPage } from './utils'; + +type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; +type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions']; + +/** + * Represents the output of a prerendering process. + * + * The key is the file path, and the value is an object containing the following properties: + * + * - `content`: The HTML content or output generated for the corresponding file path. + * - `appShellRoute`: A boolean flag indicating whether the content is an app shell. + * + * @example + * { + * '/index.html': { content: '...', appShell: false }, + * '/shell/index.html': { content: '...', appShellRoute: true } + * } + */ +type PrerenderOutput = Record; + +export async function prerenderPages( + workspaceRoot: string, + baseHref: string, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, + outputFiles: Readonly, + assets: Readonly, + outputMode: OutputMode | undefined, + sourcemap = false, + maxThreads = 1, +): Promise<{ + output: PrerenderOutput; + warnings: string[]; + errors: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + serializableRouteTreeNode: SerializableRouteTreeNode; +}> { + const rawOutputFiles: Record = {}; + const serverBundlesSourceMaps = new Map(); + const warnings: string[] = []; + const errors: string[] = []; + + for (const { text, path, type } of outputFiles) { + if (type !== BuildOutputFileType.ServerApplication && type !== BuildOutputFileType.ServerRoot) { + continue; + } + + // Contains the server runnable application code + if (extname(path) === '.map') { + serverBundlesSourceMaps.set(path.slice(0, -4), text); + } else { + rawOutputFiles[path] = text; + } + } + + // Inline sourcemap into JS file. This is needed to make Node.js resolve sourcemaps + // when using `--enable-source-maps` when using in memory files. + for (const [filePath, map] of serverBundlesSourceMaps) { + const jsContent = rawOutputFiles[filePath]; + if (jsContent) { + rawOutputFiles[filePath] = + jsContent + + '\n//# sourceMappingURL=' + + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; + } + } + serverBundlesSourceMaps.clear(); + + const outputFilesForWorker = createSharedServerFiles(rawOutputFiles); + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + // Assets are not stored with baseHref when using i18n, + // we append the base href so that requests are resolved correctly. + assetsReversed[joinUrlParts(baseHref, toPosixPath(destination))] = source; + } + + // Get routes to prerender + const { + errors: extractionErrors, + serializedRouteTree: serializableRouteTreeNode, + appShellRoute, + } = await getAllRoutes( + workspaceRoot, + baseHref, + outputFilesForWorker, + assetsReversed, + appShellOptions, + prerenderOptions, + sourcemap, + outputMode, + ).catch((err) => { + assertIsError(err); + + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ], + serializedRouteTree: [], + appShellRoute: undefined, + }; + }); + + errors.push(...extractionErrors); + + const serializableRouteTreeNodeForPrerender: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + if (outputMode !== OutputMode.Static && metadata.redirectTo) { + // Skip redirects if output mode is not static. + continue; + } + + if (metadata.route.includes('*')) { + // Skip catch all routes from prerender. + continue; + } + + switch (metadata.renderMode) { + case undefined: /* Legacy building mode */ + case RouteRenderMode.Prerender: + serializableRouteTreeNodeForPrerender.push(metadata); + break; + case RouteRenderMode.Server: + if (outputMode === OutputMode.Static) { + errors.push( + `Route '${metadata.route}' is configured with server render mode, but the build 'outputMode' is set to 'static'.`, + ); + } + break; + } + } + + if (!serializableRouteTreeNodeForPrerender.length || errors.length > 0) { + return { + errors, + warnings, + output: {}, + prerenderedRoutes: {}, + serializableRouteTreeNode, + }; + } + + // Add the extracted routes to the manifest file. + // We could re-generate it from the start, but that would require a number of options to be passed down. + const manifest = outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME]; + if (manifest) { + const manifestText = new TextDecoder().decode(manifest); + + outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = createSharedFile( + manifestText.replace( + 'routes: undefined,', + `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + ), + ); + } + + // Render routes + const { errors: renderingErrors, output } = await renderPages( + baseHref, + sourcemap, + serializableRouteTreeNodeForPrerender, + maxThreads, + workspaceRoot, + outputFilesForWorker, + assetsReversed, + outputMode, + prerenderOptions?.format, + appShellRoute ?? appShellOptions?.route, + ); + + errors.push(...renderingErrors); + + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + + for (const metadata of serializableRouteTreeNodeForPrerender) { + const outPath = getRouteOutPath( + metadata.route, + baseHrefPathnameWithLeadingSlash, + prerenderOptions?.format, + ); + + if (output[outPath]) { + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + } + } + + return { + errors, + warnings, + output, + prerenderedRoutes, + serializableRouteTreeNode, + }; +} + +async function renderPages( + baseHref: string, + sourcemap: boolean, + serializableRouteTreeNode: SerializableRouteTreeNode, + maxThreads: number, + workspaceRoot: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + outputMode: OutputMode | undefined, + format: PrerenderFormat | undefined, + appShellRoute: string | undefined, +): Promise<{ + output: PrerenderOutput; + errors: string[]; +}> { + const output: PrerenderOutput = {}; + const errors: string[] = []; + + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + const appShellRouteWithoutBaseHref = appShellRoute + ? addLeadingSlash(getRouteWithoutBaseHref(appShellRoute, baseHrefPathnameWithLeadingSlash)) + : undefined; + + const routesToRender: { route: string; outPath: string; isAppShell: boolean }[] = []; + + for (const { route, redirectTo } of serializableRouteTreeNode) { + // Remove the base href from the file output path. + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash); + const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash, format); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + routesToRender.push({ + route, + outPath, + isAppShell: appShellRouteWithoutBaseHref === routeWithoutBaseHref, + }); + } + + if (routesToRender.length === 0) { + return { + errors, + output, + }; + } + + // Batch routes to reduce IPC overhead while ensuring enough batches exist for load balancing across worker threads. + const batchSize = Math.max(1, Math.min(50, Math.ceil(routesToRender.length / (maxThreads * 4)))); + const numBatches = Math.ceil(routesToRender.length / batchSize); + const effectiveMaxThreads = Math.min(numBatches, maxThreads); + + const workerExecArgv = [IMPORT_EXEC_ARGV]; + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./render-worker'), + maxThreads: effectiveMaxThreads, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RenderWorkerData, + execArgv: workerExecArgv, + env: { + ...process.env, + 'NG_ALLOWED_HOSTS': 'localhost', + }, + }); + + try { + const routeOutPathMap = new Map(); + for (const item of routesToRender) { + routeOutPathMap.set(item.route, item); + } + + const renderingPromises: Promise[] = []; + + for (let i = 0; i < routesToRender.length; i += batchSize) { + const batch = routesToRender.slice(i, i + batchSize); + const urls = batch.map((item) => item.route); + const renderBatchPromise: Promise = renderWorker.run(urls); + const batchResultPromise = renderBatchPromise + .then((results) => { + for (const result of results) { + if ('error' in result) { + errors.push( + `An error occurred while prerendering route '${result.url}'.\n\n${result.error}`, + ); + continue; + } + + const routeInfo = routeOutPathMap.get(result.url); + if (routeInfo) { + output[routeInfo.outPath] = { + content: result.content, + appShellRoute: routeInfo.isAppShell, + }; + } + } + }) + .catch((err) => { + assertIsError(err); + for (const url of urls) { + errors.push( + `An error occurred while prerendering route '${url}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ); + } + }); + + renderingPromises.push(batchResultPromise); + } + + await Promise.all(renderingPromises); + } finally { + void renderWorker.destroy(); + } + + return { + errors, + output, + }; +} + +async function getAllRoutes( + workspaceRoot: string, + baseHref: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, + sourcemap: boolean, + outputMode: OutputMode | undefined, +): Promise<{ + serializedRouteTree: SerializableRouteTreeNode; + appShellRoute?: string; + errors: string[]; +}> { + const { routesFile, discoverRoutes } = prerenderOptions ?? {}; + const routes: WritableSerializableRouteTreeNode = []; + let appShellRoute: string | undefined; + + if (appShellOptions) { + appShellRoute = joinUrlParts(baseHref, appShellOptions.route); + + routes.push({ + renderMode: RouteRenderMode.Prerender, + route: appShellRoute, + }); + } + + if (routesFile) { + const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); + for (const route of routesFromFile) { + routes.push({ + renderMode: RouteRenderMode.Prerender, + route: joinUrlParts(baseHref, route.trim()), + }); + } + } + + if (!discoverRoutes) { + return { errors: [], appShellRoute, serializedRouteTree: routes }; + } + + const workerExecArgv = [IMPORT_EXEC_ARGV]; + + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./routes-extractor-worker'), + maxThreads: 1, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RoutesExtractorWorkerData, + execArgv: workerExecArgv, + env: { + ...process.env, + 'NG_ALLOWED_HOSTS': 'localhost', + }, + }); + + try { + const { serializedRouteTree, appShellRoute, errors }: RoutersExtractorWorkerResult = + await renderWorker.run({}); + + if (!routes.length) { + return { errors, appShellRoute, serializedRouteTree }; + } + + // Merge the routing trees + const uniqueRoutes = new Map(); + for (const item of [...routes, ...serializedRouteTree]) { + if (!uniqueRoutes.has(item.route)) { + uniqueRoutes.set(item.route, item); + } + } + + return { errors, serializedRouteTree: Array.from(uniqueRoutes.values()) }; + } catch (err) { + assertIsError(err); + + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ], + serializedRouteTree: [], + }; + } finally { + void renderWorker.destroy(); + } +} + +function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): string { + return addTrailingSlash(route).startsWith(baseHrefPathname) + ? addLeadingSlash(route.slice(baseHrefPathname.length)) + : route; +} + +function getRouteOutPath( + route: string, + baseHrefPathname: string, + format: PrerenderFormat | undefined, +): string { + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname); + + if (format === PrerenderFormat.File && routeWithoutBaseHref !== '/') { + return stripLeadingSlash(`${routeWithoutBaseHref}.html`); + } + + return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); +} diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts new file mode 100644 index 000000000000..df1fdb4ea103 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import type { OutputMode } from '../../builders/application/schema'; +import { assertIsError } from '../error'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { generateRedirectStaticPage } from './utils'; + +export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +} + +export type RenderResultItem = + | { + url: string; + content: string; + } + | { + url: string; + error: string; + }; + +export type RenderResult = RenderResultItem[]; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +let serverURL = DEFAULT_URL; + +/** + * Renders a single route URL. + */ +async function renderPage( + url: string, + angularServerApp: { handle: (request: Request) => Promise }, +): Promise { + const response = await angularServerApp.handle( + new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), + ); + + if (!response) { + return null; + } + + const location = response.headers.get('Location'); + + return location ? generateRedirectStaticPage(location) : response.text(); +} + +/** + * Renders routes in batch or individual URL. + */ +async function renderPages(urls: string[]): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const results: RenderResult = []; + for (const currentUrl of urls) { + try { + const content = await renderPage(currentUrl, angularServerApp); + + if (content === null) { + throw new Error('The content returned was empty.'); + } + + results.push({ url: currentUrl, content }); + } catch (err) { + assertIsError(err); + results.push({ + url: currentUrl, + error: err.stack ?? err.message ?? err.code ?? `${err}`, + }); + } + } + + return results; +} + +async function initialize() { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + if (outputMode !== undefined && hasSsrEntry) { + serverURL = await launchServer(); + } + + patchFetchToLoadInMemoryAssets(serverURL); + + return renderPages; +} + +export default initialize(); diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts new file mode 100644 index 000000000000..423a71e83ba5 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import { OutputMode } from '../../builders/application/schema'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { RoutersExtractorWorkerResult } from './models'; + +export interface ExtractRoutesWorkerData extends ESMInMemoryFileLoaderWorkerData { + outputMode: OutputMode | undefined; +} + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +/** Renders an application based on a provided options. */ +async function extractRoutes(): Promise { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + const serverURL = outputMode !== undefined && hasSsrEntry ? await launchServer() : DEFAULT_URL; + + patchFetchToLoadInMemoryAssets(serverURL); + + const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const { routeTree, appShellRoute, errors } = await extractRoutesAndCreateRouteTree({ + url: serverURL, + invokeGetPrerenderParams: outputMode !== undefined, + includePrerenderFallbackRoutes: outputMode === OutputMode.Server, + signal: AbortSignal.timeout(30_000), + }); + + return { + errors, + appShellRoute, + serializedRouteTree: routeTree.toObject(), + }; +} + +export default extractRoutes; diff --git a/packages/angular/build/src/utils/server-rendering/utils.ts b/packages/angular/build/src/utils/server-rendering/utils.ts new file mode 100644 index 000000000000..18ab7def9ec1 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { createRequestHandler } from '@angular/ssr'; +import type { createNodeRequestHandler } from '@angular/ssr/node' with { + 'resolution-mode': 'import', +}; + +export function isSsrNodeRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_node_request_handler__' in value; +} +export function isSsrRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_request_handler__' in value; +} + +/** + * A mapping of the characters which have to be escaped to be interpolated into HTML, + * to their entity equivalents. + */ +const HTML_ESCAPE_CHARACTER_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** + * Escapes the characters of a value which is interpolated into HTML text or into a quoted + * attribute value. + * + * @param text - The value to escape. + * @returns The escaped value. + */ +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTER_MAP[character]); +} + +/** + * Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL. + * + * This function creates a simple HTML page that performs a redirect using a meta tag. + * It includes a fallback link in case the meta-refresh doesn't work. + * + * The provided URL is HTML-escaped before being interpolated. + * + * @param url - The URL to which the page should redirect. + * @returns The HTML content of the static redirect page. + */ +export function generateRedirectStaticPage(url: string): string { + const escapedUrl = escapeHtml(url); + + return ` + + + + + Redirecting + + + +
Redirecting to ${escapedUrl}
+ + +`.trim(); +} diff --git a/packages/angular/build/src/utils/server-rendering/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/utils_spec.ts new file mode 100644 index 000000000000..c8d738f8eec1 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils_spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { generateRedirectStaticPage } from './utils'; + +describe('generateRedirectStaticPage', () => { + it('escapes the ampersands of a redirect target', () => { + const page = generateRedirectStaticPage('https://example.com/docs?from=ssg&next=/ssg'); + + expect(page).toContain( + '', + ); + expect(page).toContain( + '' + + 'https://example.com/docs?from=ssg&next=/ssg', + ); + }); + + it('escapes characters which would break out of the attribute or the tag', () => { + const page = generateRedirectStaticPage(`/">`); + + expect(page).not.toContain('`, + ); + }); + + it('should update script src and link href attributes when option is set to absolute URL', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + deployUrl: 'https://example.com/some/path/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toEqual( + `` + + `` + + ``, + ); + }); + + it('should update resources component stylesheets to reference deployURL', async () => { + await harness.writeFile('src/app/test.svg', ''); + await harness.writeFile( + 'src/app/app.component.css', + `* { background-image: url('./test.svg'); }`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + deployUrl: 'https://example.com/some/path/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/main.js') + .content.toContain('background-image: url("https://example.com/some/path/media/test.svg")'); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts new file mode 100644 index 000000000000..1111ed01554a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/options/main_spec.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildEsbuildBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "main"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/index.html').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/main.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.js', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/index.html').toExist(); + + harness.expectFile('dist/main.js').content.toContain('console.log("main")'); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/missing.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve "') }), + ); + + harness.expectFile('dist/main.js').toNotExist(); + harness.expectFile('dist/index.html').toNotExist(); + }); + + it('throws an error when given an empty string', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: '', + }); + + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + expect(result).toBeUndefined(); + + expect(error?.message).toContain('cannot be an empty string'); + }); + + it('resolves an absolute path as relative inside the workspace root', async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + main: '/file.mjs', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Always uses the name `main.js` for the `main` option. + harness.expectFile('dist/main.js').toExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/setup.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/setup.ts new file mode 100644 index 000000000000..446e5a5546d1 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/setup.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Schema } from '../schema'; + +export { describeBuilder } from '../../../testing'; + +export const BROWSER_BUILDER_INFO = Object.freeze({ + name: '@angular-devkit/build-angular:browser-esbuild', + schemaPath: __dirname + '/../schema.json', +}); + +/** + * Contains all required browser builder fields. + * Also disables progress reporting to minimize logging output. + */ +export const BASE_OPTIONS = Object.freeze({ + index: 'src/index.html', + main: 'src/main.ts', + outputPath: 'dist', + tsConfig: 'src/tsconfig.app.json', + progress: false, + + // Disable optimizations + optimization: false, + buildOptimizer: false, +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/index.ts b/packages/angular_devkit/build_angular/src/builders/browser/index.ts new file mode 100644 index 000000000000..e8fd07c82830 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/index.ts @@ -0,0 +1,465 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/* eslint-disable max-lines-per-function */ + +import { + BudgetCalculatorResult, + FileInfo, + IndexHtmlGenerator, + IndexHtmlTransform, + ThresholdSeverity, + assertCompatibleAngularVersion, + augmentAppWithServiceWorker, + checkBudgets, + purgeStaleBuildCache, +} from '@angular/build/private'; +import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; +import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack'; +import { imageDomains } from '@ngtools/webpack'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Observable, concatMap, from, map, switchMap } from 'rxjs'; +import webpack, { StatsCompilation } from 'webpack'; +import { getCommonConfig, getStylesConfig } from '../../tools/webpack/configs'; +import { markAsyncChunksNonInitial } from '../../tools/webpack/utils/async-chunks'; +import { normalizeExtraEntryPoints } from '../../tools/webpack/utils/helpers'; +import { + BuildEventStats, + generateBuildEventStats, + statsErrorsToString, + statsHasErrors, + statsHasWarnings, + statsWarningsToString, + webpackStatsLogger, +} from '../../tools/webpack/utils/stats'; +import { ExecutionTransformer } from '../../transforms'; +import { + deleteOutputDir, + normalizeAssetPatterns, + normalizeOptimization, + urlJoin, +} from '../../utils'; +import { copyAssets } from '../../utils/copy-assets'; +import { assertIsError } from '../../utils/error'; +import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining'; +import { I18nOptions } from '../../utils/i18n-webpack'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { ensureOutputPaths } from '../../utils/output-paths'; +import { generateEntryPoints } from '../../utils/package-chunk-sort'; +import { Spinner } from '../../utils/spinner'; +import { + generateI18nBrowserWebpackConfigFromContext, + getIndexInputFile, + getIndexOutputFile, +} from '../../utils/webpack-browser-config'; +import { Schema as BrowserBuilderSchema } from './schema'; + +/** + * @experimental Direct usage of this type is considered experimental. + */ +export type BrowserBuilderOutput = BuilderOutput & { + stats: BuildEventStats; + baseOutputPath: string; + outputs: { + locale?: string; + path: string; + baseHref?: string; + }[]; +}; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +async function initialize( + options: BrowserBuilderSchema, + context: BuilderContext, + webpackConfigurationTransform?: ExecutionTransformer, +): Promise<{ + config: webpack.Configuration; + projectRoot: string; + projectSourceRoot?: string; + i18n: I18nOptions; +}> { + const originalOutputPath = options.outputPath; + + // Assets are processed directly by the builder except when watching + const adjustedOptions = options.watch ? options : { ...options, assets: [] }; + + const { config, projectRoot, projectSourceRoot, i18n } = + await generateI18nBrowserWebpackConfigFromContext(adjustedOptions, context, (wco) => [ + getCommonConfig(wco), + getStylesConfig(wco), + ]); + + let transformedConfig; + if (webpackConfigurationTransform) { + transformedConfig = await webpackConfigurationTransform(config); + } + + if (options.deleteOutputPath) { + await deleteOutputDir(context.workspaceRoot, originalOutputPath); + } + + return { config: transformedConfig || config, projectRoot, projectSourceRoot, i18n }; +} + +/** + * @experimental Direct usage of this function is considered experimental. + */ +export function buildWebpackBrowser( + options: BrowserBuilderSchema, + context: BuilderContext, + transforms: { + webpackConfiguration?: ExecutionTransformer; + logging?: WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; + } = {}, +): Observable { + const projectName = context.target?.project; + if (!projectName) { + throw new Error('The builder requires a target.'); + } + + context.logger.warn( + 'The "@angular-devkit/build-angular:browser" builder is deprecated as part of Angular\'s Webpack support deprecation. ' + + 'Use "@angular/build:application" instead. For more information, see https://angular.dev/tools/cli/build-system-migration.', + ); + + const baseOutputPath = path.resolve(context.workspaceRoot, options.outputPath); + let outputPaths: undefined | Map; + + // Check Angular version. + assertCompatibleAngularVersion(context.workspaceRoot); + + return from(context.getProjectMetadata(projectName)).pipe( + switchMap(async (projectMetadata) => { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + // Initialize builder + const initialization = await initialize(options, context, transforms.webpackConfiguration); + + // Add index file to watched files. + if (options.watch) { + const indexInputFile = path.join(context.workspaceRoot, getIndexInputFile(options.index)); + initialization.config.plugins ??= []; + initialization.config.plugins.push({ + apply: (compiler: webpack.Compiler) => { + compiler.hooks.thisCompilation.tap('build-angular', (compilation) => { + compilation.fileDependencies.add(indexInputFile); + }); + }, + }); + } + + return { + ...initialization, + cacheOptions: normalizeCacheOptions(projectMetadata, context.workspaceRoot), + }; + }), + switchMap(({ config, projectRoot, projectSourceRoot, i18n, cacheOptions }) => { + const normalizedOptimization = normalizeOptimization(options.optimization); + + return runWebpack(config, context, { + webpackFactory: require('webpack') as typeof webpack, + logging: + transforms.logging || + ((stats, config) => { + if (options.verbose && config.stats !== false) { + const statsOptions = config.stats === true ? undefined : config.stats; + context.logger.info(stats.toString(statsOptions)); + } + }), + }).pipe( + concatMap( + async ( + buildEvent, + ): Promise<{ output: BuilderOutput; webpackStats: StatsCompilation }> => { + const spinner = new Spinner(); + spinner.enabled = options.progress !== false; + + const { success, emittedFiles = [], outputPath: webpackOutputPath } = buildEvent; + const webpackRawStats = buildEvent.webpackStats; + if (!webpackRawStats) { + throw new Error('Webpack stats build result is required.'); + } + + // Fix incorrectly set `initial` value on chunks. + const extraEntryPoints = [ + ...normalizeExtraEntryPoints(options.styles || [], 'styles'), + ...normalizeExtraEntryPoints(options.scripts || [], 'scripts'), + ]; + + const webpackStats = { + ...webpackRawStats, + chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints), + }; + + if (!success) { + // If using bundle downleveling then there is only one build + // If it fails show any diagnostic messages and bail + if (statsHasWarnings(webpackStats)) { + context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); + } + if (statsHasErrors(webpackStats)) { + context.logger.error(statsErrorsToString(webpackStats, { colors: true })); + } + + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + outputPaths = ensureOutputPaths(baseOutputPath, i18n); + + const scriptsEntryPointName = normalizeExtraEntryPoints( + options.scripts || [], + 'scripts', + ).map((x) => x.bundleName); + + if (i18n.shouldInline) { + const success = await i18nInlineEmittedFiles( + context, + emittedFiles, + i18n, + baseOutputPath, + Array.from(outputPaths.values()), + scriptsEntryPointName, + webpackOutputPath, + options.i18nMissingTranslation, + ); + if (!success) { + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } + } + + // Check for budget errors and display them to the user. + const budgets = options.budgets; + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (budgets?.length) { + budgetFailures = [...checkBudgets(budgets, webpackStats)]; + for (const { severity, message } of budgetFailures) { + switch (severity) { + case ThresholdSeverity.Warning: + webpackStats.warnings?.push({ message }); + break; + case ThresholdSeverity.Error: + webpackStats.errors?.push({ message }); + break; + default: + assertNever(severity); + } + } + } + + const buildSuccess = success && !statsHasErrors(webpackStats); + if (buildSuccess) { + // Copy assets + if (!options.watch && options.assets?.length) { + spinner.start('Copying assets...'); + try { + await copyAssets( + normalizeAssetPatterns( + options.assets, + context.workspaceRoot, + projectRoot, + projectSourceRoot, + ), + Array.from(outputPaths.values()), + context.workspaceRoot, + ); + spinner.succeed('Copying assets complete.'); + } catch (err) { + spinner.fail('Copying of assets failed.'); + assertIsError(err); + + return { + output: { + success: false, + error: 'Unable to copy assets: ' + err.message, + }, + webpackStats: webpackRawStats, + }; + } + } + + if (options.index) { + spinner.start('Generating index html...'); + + const entrypoints = generateEntryPoints({ + scripts: options.scripts ?? [], + styles: options.styles ?? [], + }); + + let hasErrors = false; + for (const [locale, outputPath] of outputPaths.entries()) { + try { + const indexHtmlGenerator = new IndexHtmlGenerator({ + cache: cacheOptions, + indexPath: path.join( + context.workspaceRoot, + getIndexInputFile(options.index), + ), + entrypoints, + outputPath, + deployUrl: options.deployUrl, + sri: options.subresourceIntegrity, + optimization: normalizedOptimization, + crossOrigin: options.crossOrigin, + postTransform: transforms.indexHtml, + imageDomains: Array.from(imageDomains), + }); + + const { + csrContent: content, + warnings, + errors, + } = await indexHtmlGenerator.process({ + baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref, + // i18nLocale is used when Ivy is disabled + lang: locale || undefined, + files: mapEmittedFilesToFileInfo(emittedFiles), + }); + + if (warnings.length || errors.length) { + spinner.stop(); + warnings.forEach((m) => context.logger.warn(m)); + errors.forEach((m) => { + context.logger.error(m); + hasErrors = true; + }); + spinner.start(); + } + + const indexOutput = path.join(outputPath, getIndexOutputFile(options.index)); + await fs.promises.mkdir(path.dirname(indexOutput), { recursive: true }); + await fs.promises.writeFile(indexOutput, content); + } catch (error) { + spinner.fail('Index html generation failed.'); + assertIsError(error); + + return { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } + } + + if (hasErrors) { + spinner.fail('Index html generation failed.'); + + return { + webpackStats: webpackRawStats, + output: { success: false }, + }; + } else { + spinner.succeed('Index html generation complete.'); + } + } + + if (options.serviceWorker) { + spinner.start('Generating service worker...'); + for (const [locale, outputPath] of outputPaths.entries()) { + try { + await augmentAppWithServiceWorker( + projectRoot, + context.workspaceRoot, + outputPath, + getLocaleBaseHref(i18n, locale) ?? options.baseHref ?? '/', + options.ngswConfigPath, + ); + } catch (error) { + spinner.fail('Service worker generation failed.'); + assertIsError(error); + + return { + webpackStats: webpackRawStats, + output: { success: false, error: error.message }, + }; + } + } + + spinner.succeed('Service worker generation complete.'); + } + } + + webpackStatsLogger(context.logger, webpackStats, config, budgetFailures); + + return { + webpackStats: webpackRawStats, + output: { success: buildSuccess }, + }; + } + }, + ), + map( + ({ output: event, webpackStats }) => + ({ + ...event, + stats: generateBuildEventStats(webpackStats, options), + baseOutputPath, + outputs: (outputPaths && + [...outputPaths.entries()].map(([locale, path]) => ({ + locale, + path, + baseHref: getLocaleBaseHref(i18n, locale) ?? options.baseHref, + }))) || { + path: baseOutputPath, + baseHref: options.baseHref, + }, + }) as BrowserBuilderOutput, + ), + ); + }), + ); + + function getLocaleBaseHref(i18n: I18nOptions, locale: string): string | undefined { + if (i18n.flatOutput) { + return undefined; + } + + const localeData = i18n.locales[locale]; + if (!localeData) { + return undefined; + } + + const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/'; + + return baseHrefSuffix !== '' ? urlJoin(options.baseHref || '', baseHrefSuffix) : undefined; + } +} + +function assertNever(input: never): never { + throw new Error( + `Unexpected call to assertNever() with input: ${JSON.stringify( + input, + null /* replacer */, + 4 /* tabSize */, + )}`, + ); +} + +function mapEmittedFilesToFileInfo(files: EmittedFiles[] = []): FileInfo[] { + const filteredFiles: FileInfo[] = []; + for (const { file, name, extension, initial } of files) { + if (name && initial) { + filteredFiles.push({ file, extension, name }); + } + } + + return filteredFiles; +} + +export default createBuilder(buildWebpackBrowser); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json new file mode 100644 index 000000000000..301eeafcc4f1 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -0,0 +1,548 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Webpack browser schema for Build Facade.", + "description": "Browser target options", + "type": "object", + "properties": { + "assets": { + "type": "array", + "description": "Define the assets to be copied to the output directory. These assets are copied as-is without any further processing or hashing.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "main": { + "type": "string", + "description": "The full path for the main entry point to the app, relative to the current workspace." + }, + "polyfills": { + "description": "Polyfills to be included in the build.", + "oneOf": [ + { + "type": "array", + "description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'.", + "items": { + "type": "string", + "uniqueItems": true + }, + "default": [] + }, + { + "type": "string", + "description": "The full path for the polyfills file, relative to the current workspace or a module specifier. Example: 'zone.js'." + } + ] + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "scripts": { + "description": "Global scripts to be included in the build.", + "type": "array", + "default": [], + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include.", + "pattern": "\\.[cm]?jsx?$" + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include.", + "pattern": "\\.[cm]?jsx?$" + } + ] + } + }, + "styles": { + "description": "Global styles to be included in the build.", + "type": "array", + "default": [], + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include.", + "pattern": "\\.(?:css|scss|sass|less)$" + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include.", + "pattern": "\\.(?:css|scss|sass|less)$" + } + ] + } + }, + "inlineStyleLanguage": { + "description": "The stylesheet language to use for the application's inline component styles.", + "type": "string", + "default": "css", + "enum": ["css", "less", "sass", "scss"] + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "optimization": { + "description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.dev/reference/configs/workspace-config#optimization-configuration.", + "default": true, + "x-user-analytics": "ep.ng_optimization", + "oneOf": [ + { + "type": "object", + "properties": { + "scripts": { + "type": "boolean", + "description": "Enables optimization of the scripts output.", + "default": true + }, + "styles": { + "description": "Enables optimization of the styles output.", + "default": true, + "oneOf": [ + { + "type": "object", + "properties": { + "minify": { + "type": "boolean", + "description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.", + "default": true + }, + "inlineCritical": { + "type": "boolean", + "description": "Extract and inline critical CSS definitions to improve first paint time.", + "default": true + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "fonts": { + "description": "Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.", + "default": true, + "oneOf": [ + { + "type": "object", + "properties": { + "inline": { + "type": "boolean", + "description": "Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.", + "default": true + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "fileReplacements": { + "description": "Replace compilation source files with other compilation source files in the build.", + "type": "array", + "items": { + "$ref": "#/definitions/fileReplacement" + }, + "default": [] + }, + "outputPath": { + "type": "string", + "description": "The full path for the new output directory, relative to the current workspace." + }, + "resourcesOutputPath": { + "type": "string", + "description": "The path where style resources will be placed, relative to outputPath." + }, + "aot": { + "type": "boolean", + "description": "Build using Ahead of Time compilation.", + "x-user-analytics": "ep.ng_aot", + "default": true + }, + "sourceMap": { + "description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.", + "default": false, + "oneOf": [ + { + "type": "object", + "properties": { + "scripts": { + "type": "boolean", + "description": "Output source maps for all scripts.", + "default": true + }, + "styles": { + "type": "boolean", + "description": "Output source maps for all styles.", + "default": true + }, + "hidden": { + "type": "boolean", + "description": "Output source maps used for error reporting tools.", + "default": false + }, + "vendor": { + "type": "boolean", + "description": "Resolve vendor packages source maps.", + "default": false + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "vendorChunk": { + "type": "boolean", + "description": "Generate a seperate bundle containing only vendor libraries. This option should only be used for development to reduce the incremental compilation time.", + "default": false + }, + "commonChunk": { + "type": "boolean", + "description": "Generate a seperate bundle containing code used across multiple bundles.", + "default": true + }, + "baseHref": { + "type": "string", + "description": "Base url for the application being built." + }, + "deployUrl": { + "type": "string", + "description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations." + }, + "verbose": { + "type": "boolean", + "description": "Adds more details to output logging.", + "default": false + }, + "progress": { + "type": "boolean", + "description": "Log progress to the console while building.", + "default": true + }, + "i18nMissingTranslation": { + "type": "string", + "description": "How to handle missing translations for i18n.", + "enum": ["warning", "error", "ignore"], + "default": "warning" + }, + "i18nDuplicateTranslation": { + "type": "string", + "description": "How to handle duplicate translations for i18n.", + "enum": ["warning", "error", "ignore"], + "default": "warning" + }, + "localize": { + "description": "Translate the bundles in one or more locales.", + "oneOf": [ + { + "type": "boolean", + "description": "Translate all locales." + }, + { + "type": "array", + "description": "List of locales ID's to translate.", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" + } + } + ] + }, + "watch": { + "type": "boolean", + "description": "Run build when files change.", + "default": false + }, + "outputHashing": { + "type": "string", + "description": "Define the output filename cache-busting hashing mode.\n\n- `none`: No hashing.\n- `all`: Hash for all output bundles. \n- `media`: Hash for all output media (e.g., images, fonts, etc. that are referenced in CSS files).\n- `bundles`: Hash for output of lazy and main bundles.", + "default": "none", + "enum": ["none", "all", "media", "bundles"] + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + }, + "deleteOutputPath": { + "type": "boolean", + "description": "Delete the output path before building.", + "default": true + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set." + }, + "extractLicenses": { + "type": "boolean", + "description": "Extract all licenses in a separate file.", + "default": true + }, + "buildOptimizer": { + "type": "boolean", + "description": "Enables advanced build optimizations when using the 'aot' option.", + "default": true + }, + "namedChunks": { + "type": "boolean", + "description": "Use file name for lazy loaded chunks.", + "default": false + }, + "subresourceIntegrity": { + "type": "boolean", + "description": "Enables the use of subresource integrity validation.", + "default": false + }, + "serviceWorker": { + "type": "boolean", + "description": "Generates a service worker config for production builds.", + "default": false + }, + "ngswConfigPath": { + "type": "string", + "description": "Path to ngsw-config.json." + }, + "index": { + "description": "Configures the generation of the application's HTML index.", + "oneOf": [ + { + "type": "string", + "description": "The path of a file to use for the application's HTML index. The filename of the specified path will be used for the generated file and will be created in the root of the application's configured output path." + }, + { + "type": "object", + "description": "", + "properties": { + "input": { + "type": "string", + "minLength": 1, + "description": "The path of a file to use for the application's generated HTML index." + }, + "output": { + "type": "string", + "minLength": 1, + "default": "index.html", + "description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path." + } + }, + "required": ["input"] + } + ] + }, + "statsJson": { + "type": "boolean", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "default": false + }, + "budgets": { + "description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.", + "type": "array", + "items": { + "$ref": "#/definitions/budget" + }, + "default": [] + }, + "webWorkerTsConfig": { + "type": "string", + "description": "TypeScript configuration for Web Worker modules." + }, + "crossOrigin": { + "type": "string", + "description": "Define the crossorigin attribute setting of elements that provide CORS support.", + "default": "none", + "enum": ["none", "anonymous", "use-credentials"] + }, + "allowedCommonJsDependencies": { + "description": "A list of CommonJS or AMD packages that are allowed to be used without a build time warning. Use `'*'` to allow all.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": false, + "required": ["outputPath", "index", "main", "tsConfig"], + "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "default": "", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input"] + }, + { + "type": "string" + } + ] + }, + "fileReplacement": { + "oneOf": [ + { + "type": "object", + "properties": { + "src": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + }, + "replaceWith": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + } + }, + "additionalProperties": false, + "required": ["src", "replaceWith"] + }, + { + "type": "object", + "properties": { + "replace": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + }, + "with": { + "type": "string", + "pattern": "\\.(([cm]?[jt])sx?|json)$" + } + }, + "additionalProperties": false, + "required": ["replace", "with"] + } + ] + }, + "budget": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of budget.", + "enum": ["all", "allScript", "any", "anyScript", "anyComponentStyle", "bundle", "initial"] + }, + "name": { + "type": "string", + "description": "The name of the bundle." + }, + "baseline": { + "type": "string", + "description": "The baseline size for comparison." + }, + "maximumWarning": { + "type": "string", + "description": "The maximum threshold for warning relative to the baseline." + }, + "maximumError": { + "type": "string", + "description": "The maximum threshold for error relative to the baseline." + }, + "minimumWarning": { + "type": "string", + "description": "The minimum threshold for warning relative to the baseline." + }, + "minimumError": { + "type": "string", + "description": "The minimum threshold for error relative to the baseline." + }, + "warning": { + "type": "string", + "description": "The threshold for warning relative to the baseline (min & max)." + }, + "error": { + "type": "string", + "description": "The threshold for error relative to the baseline (min & max)." + } + }, + "additionalProperties": false, + "required": ["type"] + } + } +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts new file mode 100644 index 000000000000..2596ecd94c39 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; + +import { join, normalize, relative } from '@angular-devkit/core'; +import { Observable, lastValueFrom, take, tap } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder allow js', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + + const content = new TextDecoder().decode( + await lastValueFrom(host.read(join(normalize(output.outputs[0].path), 'main.js'))), + ); + + expect(content).toContain('const a = 2'); + + await run.stop(); + }); + + it('works with aot', async () => { + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + const overrides = { aot: true }; + + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + + const content = new TextDecoder().decode( + await lastValueFrom(host.read(join(normalize(output.outputs[0].path), 'main.js'))), + ); + + expect(content).toContain('const a = 2'); + + await run.stop(); + }); + + it('works with watch', async () => { + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + const overrides = { watch: true }; + + let buildCount = 1; + const run = await architect.scheduleTarget(targetSpec, overrides); + + await lastValueFrom( + run.output.pipe( + tap((output) => { + const path = relative(host.root(), join(normalize(output.outputs[0].path), 'main.js')); + const content = new TextDecoder().decode(host.scopedSync().read(path)); + + switch (buildCount) { + case 1: + expect(content).toContain('const a = 2'); + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 1;`, + }); + break; + case 2: + expect(content).toContain('const a = 1'); + break; + } + + buildCount++; + }), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + take(2), + ), + ); + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/base-href_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/base-href_spec.ts new file mode 100644 index 000000000000..038d35f3003e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/base-href_spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize, tags } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder base href', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "allowJs": true', + ); + + host.writeMultipleFiles({ + 'src/my-js-file.js': `console.log(1); export const a = 2;`, + 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, + }); + + const overrides = { baseHref: '/myUrl' }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(fileName))); + expect(content).toMatch(//); + + await run.stop(); + }); + + it('should not override base href in HTML when option is not set', async () => { + host.writeMultipleFiles({ + 'src/index.html': ` + + + + + `, + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + + expect(output.success).toBeTrue(); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(fileName))); + expect(content).toContain(``); + + await run.stop(); + }); + + it('should insert base href in the correct position', async () => { + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + + + `, + }); + + const overrides = { baseHref: '/myUrl' }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toContain(''); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/build-optimizer_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/build-optimizer_spec.ts new file mode 100644 index 000000000000..b6c7be1fd931 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/build-optimizer_spec.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder build optimizer', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { aot: true, buildOptimizer: true }; + const { files } = await browserBuild(architect, host, targetSpec, overrides); + expect(await files['main.js']).not.toMatch(/\.decorators =/); + }); + + it('fails if AOT is disabled', async () => { + const overrides = { aot: false, buildOptimizer: true }; + const run = await architect.scheduleTarget(targetSpec, overrides); + await expectAsync(run.result).toBeRejectedWithError(); + await run.stop(); + }); + + it('reduces bundle size', async () => { + const noBoOverrides = { aot: true, optimization: true, vendorChunk: false }; + const boOverrides = { ...noBoOverrides, buildOptimizer: true }; + + const run = await architect.scheduleTarget(targetSpec, noBoOverrides); + const output = await run.result; + + expect(output.success).toBe(true); + + const noBoStats = await lastValueFrom( + host.stat(join(normalize(output.outputs[0].path), 'main.js')), + ); + if (!noBoStats) { + throw new Error('Main file has no stats'); + } + const noBoSize = noBoStats.size; + await run.stop(); + + const boRun = await architect.scheduleTarget(targetSpec, boOverrides); + const boOutput = await run.result; + expect(boOutput.success).toBe(true); + + const boStats = await lastValueFrom( + host.stat(join(normalize(output.outputs[0].path), 'main.js')), + ); + if (!boStats) { + throw new Error('Main file has no stats'); + } + const boSize = boStats.size; + await boRun.stop(); + + const sizeDiff = Math.round(((boSize - noBoSize) / noBoSize) * 10000) / 100; + if (sizeDiff > -1 && sizeDiff < 0) { + throw new Error( + 'Total size difference is too small, ' + + 'build optimizer does not seem to have made any optimizations.', + ); + } + + if (sizeDiff > 1) { + throw new Error( + 'Total size difference is positive, ' + 'build optimizer made the bundle bigger.', + ); + } + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/cross-origin_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/cross-origin_spec.ts new file mode 100644 index 000000000000..4054b822d797 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/cross-origin_spec.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; +import { CrossOrigin } from '../schema'; + +describe('Browser Builder crossOrigin', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf8', + ), + }); + }); + + afterEach(async () => host.restore().toPromise()); + + it('works with use-credentials', async () => { + const overrides = { crossOrigin: CrossOrigin.UseCredentials }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works with anonymous', async () => { + const overrides = { crossOrigin: CrossOrigin.Anonymous }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works with none', async () => { + const overrides = { crossOrigin: CrossOrigin.None }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('works for lazy chunks', async () => { + host.writeMultipleFiles({ + 'src/lazy-module.ts': 'export const value = 100;', + 'src/main.ts': `import('./lazy-module');`, + }); + + const overrides = { crossOrigin: CrossOrigin.UseCredentials }; + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + + const fileName = join(normalize(output.outputs[0].path), 'runtime.js'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toContain('script.crossOrigin = "use-credentials"'); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/deploy-url_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/deploy-url_spec.ts new file mode 100644 index 000000000000..fdb9494034a7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/deploy-url_spec.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder deploy url', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('uses deploy url for bundles urls and runtime', async () => { + const overrides = { deployUrl: 'deployUrl/' }; + const overrides2 = { deployUrl: 'http://example.com/some/path/' }; + + // Add lazy loaded chunk to provide a usage of the deploy URL + // Webpack 5+ will not include the deploy URL in the code unless needed + host.appendToFile('src/main.ts', '\nimport("./lazy");'); + host.writeMultipleFiles({ + 'src/lazy.ts': 'export const foo = "bar";', + }); + + const run = await architect.scheduleTarget(targetSpec, overrides); + const output = await run.result; + expect(output.success).toBe(true); + expect(output.outputs[0].path).not.toBeUndefined(); + const outputPath = normalize(output.outputs[0].path); + + const fileName = join(outputPath, 'index.html'); + const runtimeFileName = join(outputPath, 'runtime.js'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toContain('deployUrl/main.js'); + const runtimeContent = new TextDecoder().decode( + await lastValueFrom(host.read(normalize(runtimeFileName))), + ); + expect(runtimeContent).toContain('deployUrl/'); + + const run2 = await architect.scheduleTarget(targetSpec, overrides2); + const output2 = await run2.result; + expect(output2.outputs[0].path).toEqual(outputPath); // These should be the same. + + const content2 = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content2).toContain('http://example.com/some/path/main.js'); + + await run.stop(); + await run2.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/errors_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/errors_spec.ts new file mode 100644 index 000000000000..cc6f54f07017 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/errors_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { logging } from '@angular-devkit/core'; +import { createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder errors', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('shows error when files are not part of the compilation', async () => { + host.replaceInFile('src/tsconfig.app.json', /,\r?\n?\s*"polyfills\.ts"/, ''); + host.replaceInFile('src/tsconfig.app.json', '"**/*.ts"', '"**/*.d.ts"'); + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, {}, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + expect(logs.join()).toContain('polyfills.ts is missing from the TypeScript'); + await run.stop(); + }); + + it('shows TS syntax errors', async () => { + host.appendToFile('src/app/app.component.ts', ']]]'); + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, {}, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + expect(logs.join()).toContain('Declaration or statement expected'); + await run.stop(); + }); + + it('shows static analysis errors', async () => { + host.replaceInFile('src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`); + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, { aot: true }, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + + // Wait for the builder to complete + await run.stop(); + + expect(logs.join()).toContain('selector must be a string'); + }); + + it('shows missing export errors', async () => { + host.writeMultipleFiles({ + 'src/not-main.js': ` + import { missingExport } from 'rxjs'; + console.log(missingExport); + `, + }); + const overrides = { main: 'src/not-main.js' }; + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, overrides, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + + // Wait for the builder to complete + await run.stop(); + + expect(logs.join()).toContain( + `export 'missingExport' (imported as 'missingExport') was not found in 'rxjs'`, + ); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/font-optimization_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/font-optimization_spec.ts new file mode 100644 index 000000000000..5791aca09ec9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/font-optimization_spec.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder font optimization', () => { + const target = { project: 'app', target: 'build' }; + const overrides = { + optimization: { + styles: false, + fonts: true, + }, + }; + + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + host.replaceInFile( + '/src/index.html', + '', + ``, + ); + }); + + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const { files } = await browserBuild(architect, host, target, overrides); + const html = await files['index.html']; + expect(html).not.toContain('href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"'); + expect(html).toContain(`font-family: 'Roboto'`); + }); + + it('should not add woff', async () => { + const { files } = await browserBuild(architect, host, target, overrides); + const html = await files['index.html']; + expect(html).toContain(`format('woff2');`); + expect(html).not.toContain(`format('woff');`); + }); + + it('should remove comments and line breaks when styles optimization is true', async () => { + const { files } = await browserBuild(architect, host, target, { + optimization: { + styles: true, + fonts: true, + }, + }); + const html = await files['index.html']; + expect(html).not.toContain('/*'); + expect(html).toContain(';font-style:normal;'); + }); + + it('should not remove comments and line breaks when styles optimization is false', async () => { + const { files } = await browserBuild(architect, host, target, { + optimization: { + styles: false, + fonts: true, + }, + }); + + const html = await files['index.html']; + expect(html).toContain('/*'); + expect(html).toContain(' font-style: normal;\n'); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/index_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/index_spec.ts new file mode 100644 index 000000000000..8a373394f47a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/index_spec.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize, tags, workspaces } from '@angular-devkit/core'; +import { lastValueFrom } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder index HTML processing', () => { + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works with UTF-8 BOM', async () => { + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf8', + ), + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + // todo: enable when utf16 is supported + xit('works with UTF16 LE BOM', async () => { + host.writeMultipleFiles({ + 'src/index.html': Buffer.from( + '\ufeff', + 'utf16le', + ), + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `` + + `` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('keeps escaped charaters', async () => { + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + í + + `, + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `í ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('keeps custom template charaters', async () => { + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + <%= csrf_meta_tags %> + + `, + }); + + const run = await architect.scheduleTarget(targetSpec); + const output = await run.result; + expect(output.success).toBe(true); + const fileName = join(normalize(output.outputs[0].path), 'index.html'); + const content = new TextDecoder().decode(await lastValueFrom(host.read(normalize(fileName)))); + expect(content).toBe( + `<%= csrf_meta_tags %> ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('uses the input value from the index option longform', async () => { + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.index = { input: 'src/index-2.html' }; + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + host.writeMultipleFiles({ + 'src/index-2.html': tags.oneLine` + <%= csrf_meta_tags %> + + `, + }); + await host.delete(join(host.root(), normalize('src/index.html'))).toPromise(); + + // Recreate architect to use update angular.json + architect = (await createArchitect(host.root())).architect; + + const run = await architect.scheduleTarget(targetSpec); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const outputIndexPath = join(host.root(), 'dist', 'index.html'); + const content = await lastValueFrom(host.read(normalize(outputIndexPath))); + expect(new TextDecoder().decode(content)).toBe( + `<%= csrf_meta_tags %> ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('uses the output value from the index option longform', async () => { + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.index = { input: 'src/index.html', output: 'main.html' }; + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + + + `, + }); + + // Recreate architect to use update angular.json + architect = (await createArchitect(host.root())).architect; + + const run = await architect.scheduleTarget(targetSpec); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const outputIndexPath = join(host.root(), 'dist', 'main.html'); + const content = await lastValueFrom(host.read(normalize(outputIndexPath))); + expect(new TextDecoder().decode(content)).toBe( + ` ` + + `` + + `` + + ``, + ); + await run.stop(); + }); + + it('creates subdirectories for output value from the index option longform', async () => { + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.index = { input: 'src/index.html', output: 'extra/main.html' }; + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + host.writeMultipleFiles({ + 'src/index.html': tags.oneLine` + + + `, + }); + + // Recreate architect to use update angular.json + architect = (await createArchitect(host.root())).architect; + + const run = await architect.scheduleTarget(targetSpec); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const outputIndexPath = join(host.root(), 'dist', 'extra', 'main.html'); + const content = await lastValueFrom(host.read(normalize(outputIndexPath))); + expect(new TextDecoder().decode(content)).toBe( + ` ` + + `` + + `` + + ``, + ); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts new file mode 100644 index 000000000000..a68936a65036 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { logging } from '@angular-devkit/core'; +import { debounceTime, take, tap } from 'rxjs'; +import { + browserBuild, + createArchitect, + host, + lazyModuleFiles, + lazyModuleFnImport, +} from '../../../testing/test-utils'; + +describe('Browser Builder lazy modules', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + function addLazyLoadedModulesInTsConfig( + host: TestProjectHost, + lazyModuleFiles: Record, + ) { + const files = [...Object.keys(lazyModuleFiles), 'main.ts'] + .map((f) => '"' + f.replace('src/', '') + '"') + .join(', '); + + host.replaceInFile('src/tsconfig.app.json', '"main.ts"', `${files}`); + } + + function hasMissingModuleError(logs: string): boolean { + // TS type error when using import(). + return ( + logs.includes('Cannot find module') || + // Webpack error when using import() on a rebuild. + // There is no TS error because the type checker is forked on rebuilds. + logs.includes('Module not found') + ); + } + + describe(`Load children syntax`, () => { + it('supports lazy bundle for lazy routes with JIT', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + + const { files } = await browserBuild(architect, host, target); + expect('src_app_lazy_lazy_module_ts.js' in files).toBe(true); + }); + + it('supports lazy bundle for lazy routes with AOT', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + addLazyLoadedModulesInTsConfig(host, lazyModuleFiles); + + const { files } = await browserBuild(architect, host, target, { aot: true }); + const data = await files['src_app_lazy_lazy_module_ts.js']; + expect(data).toContain('static ɵmod'); + }); + }); + + // Errors for missing lazy routes are only supported with function syntax. + // `ngProgram.listLazyRoutes` will ignore invalid lazy routes in the route map. + describe(`Load children errors with function syntax`, () => { + it('should show error when lazy route is invalid', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + host.replaceInFile('src/app/app.module.ts', 'lazy.module', 'invalid.module'); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(target, {}, { logger }); + const output = await run.result; + expect(output.success).toBe(false); + expect(hasMissingModuleError(logs.join())).toBeTrue(); + await run.stop(); + }); + + it('should show error when lazy route is invalid on watch mode AOT', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + + let buildNumber = 0; + + const overrides = { watch: true, aot: true }; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + debounceTime(1000), + tap((buildEvent) => { + buildNumber++; + switch (buildNumber) { + case 1: + expect(buildEvent.success).toBe(true); + host.replaceInFile('src/app/app.module.ts', 'lazy.module', 'invalid.module'); + break; + case 2: + expect(buildEvent.success).toBe(false); + break; + } + }), + take(2), + ) + .toPromise(); + await run.stop(); + }); + }); + + it(`supports lazy bundle for import() calls`, async () => { + host.writeMultipleFiles({ + 'src/lazy-module.ts': 'export const value = 42;', + 'src/main.ts': `import('./lazy-module');`, + }); + + const { files } = await browserBuild(architect, host, target); + expect(files['src_lazy-module_ts.js']).toBeDefined(); + }); + + it(`supports lazy bundle for dynamic import() calls`, async () => { + host.writeMultipleFiles({ + 'src/lazy-module.ts': 'export const value = 42;', + 'src/main.ts': ` + const lazyFileName = 'module'; + import('./lazy-' + lazyFileName); + `, + }); + host.replaceInFile('src/tsconfig.app.json', '"main.ts"', `"main.ts","lazy-module.ts"`); + + const { files } = await browserBuild(architect, host, target); + expect(files['src_lazy-module_ts.js']).toBeDefined(); + }); + + it(`supports making a common bundle for shared lazy modules`, async () => { + host.writeMultipleFiles({ + 'src/one.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/two.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/main.ts': `import('./one'); import('./two');`, + }); + + const { files } = await browserBuild(architect, host, target); + expect(files['src_one_ts.js']).toBeDefined(); + expect(files['src_two_ts.js']).toBeDefined(); + expect(Object.keys(files)).toContain(jasmine.stringContaining('_angular_common_')); + }); + + it(`supports disabling the common bundle`, async () => { + host.writeMultipleFiles({ + 'src/one.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/two.ts': `import * as http from '@angular/common/http'; console.log(http);`, + 'src/main.ts': `import('./one'); import('./two');`, + }); + + const { files } = await browserBuild(architect, host, target, { commonChunk: false }); + expect(files['src_one_ts.js']).toBeDefined(); + expect(files['src_two_ts.js']).toBeDefined(); + expect(files['default-node_modules_angular_common_fesm2022_http_mjs.js']).toBeUndefined(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/optimization-level_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/optimization-level_spec.ts new file mode 100644 index 000000000000..a75cc8b7aeb4 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/optimization-level_spec.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder optimization level', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { optimization: true }; + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).not.toContain('AppComponent'); + }); + + it('supports styles only optimizations', async () => { + const overrides = { + optimization: { + styles: true, + scripts: false, + }, + aot: true, + styles: ['src/styles.css'], + }; + + host.appendToFile('src/main.ts', '/** js comment should not be dropped */'); + host.appendToFile('src/app/app.component.css', 'div { color: white }'); + host.writeMultipleFiles({ + 'src/styles.css': `div { color: white }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).toContain('js comment should not be dropped'); + expect(await files['main.js']).toContain('color:#fff'); + expect(await files['styles.css']).toContain('color:#fff'); + }); + + it('supports scripts only optimizations', async () => { + const overrides = { + optimization: { + styles: false, + scripts: true, + }, + aot: true, + styles: ['src/styles.css'], + }; + + host.appendToFile('src/main.ts', '/** js comment should be dropped */'); + host.appendToFile('src/app/app.component.css', 'div { color: white }'); + host.writeMultipleFiles({ + 'src/styles.css': `div { color: white }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).not.toContain('js comment should be dropped'); + expect(await files['main.js']).toContain('color: white'); + expect(await files['styles.css']).toContain('color: white'); + }); + + it('outputs ASCII only content', async () => { + const overrides = { aot: true, optimization: true }; + + host.writeMultipleFiles({ + 'src/app/app.component.html': `

€€€

`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).not.toContain('ɵ'); + expect(await files['main.js']).not.toContain('€€€'); + expect(await files['main.js']).toContain('\\u20ac\\u20ac\\u20ac'); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/output-path_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/output-path_spec.ts new file mode 100644 index 000000000000..d949ac65dc5a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/output-path_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { getSystemPath, join } from '@angular-devkit/core'; +import * as fs from 'node:fs'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder output path', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('deletes output path content', async () => { + // Write a file to the output path to later verify it was deleted. + await host + .write(join(host.root(), 'dist/file.txt'), new TextEncoder().encode('file').buffer) + .toPromise(); + + // Delete an app file to force a failed compilation. + // Failed compilations still delete files, but don't output any. + await host.delete(join(host.root(), 'src', 'app', 'app.component.ts')).toPromise(); + + const run = await architect.scheduleTarget(target); + const output = await run.result; + expect(output.success).toBe(false); + expect(await host.exists(join(host.root(), 'dist/file.txt')).toPromise()).toBe(false); + await run.stop(); + }); + + it('deletes output path content and unlink symbolic link', async () => { + // Write a file to the output path to later verify it was deleted. + host.writeMultipleFiles({ + 'src-link/a.txt': '', + 'dist/file.txt': new TextEncoder().encode('file').buffer, + }); + + const distLinked = join(host.root(), 'dist', 'linked'); + + // create symbolic link + fs.symlinkSync( + getSystemPath(join(host.root(), 'src-link')), + getSystemPath(distLinked), + 'junction', + ); + + expect(await host.exists(distLinked).toPromise()).toBe(true); + + // Delete an app file to force a failed compilation. + // Failed compilations still delete files, but don't output any. + await host.delete(join(host.root(), 'src', 'app', 'app.component.ts')).toPromise(); + const run = await architect.scheduleTarget(target); + const output = await run.result; + expect(output.success).toBe(false); + + expect(await host.exists(join(host.root(), 'dist/file.txt')).toPromise()).toBe(false); + expect(await host.exists(join(host.root(), 'src-link/a.txt')).toPromise()).toBe(true); + await run.stop(); + }); + + it('does not allow output path to be project root', async () => { + const overrides = { outputPath: './' }; + const run = await architect.scheduleTarget(target, overrides); + try { + await run.result; + expect('THE ABOVE LINE SHOULD THROW').toBe(''); + } catch {} + await run.stop(); + }); + + it('works with absolute outputPath', async () => { + const overrides = { + outputPath: getSystemPath(join(host.root(), 'dist')), + }; + const { files } = await browserBuild(architect, host, target, overrides); + expect('main.js' in files).toBe(true); + expect('index.html' in files).toBe(true); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/poll_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/poll_spec.ts new file mode 100644 index 000000000000..438e5530d144 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/poll_spec.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { debounceTime, take, tap, timeout } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BUILD_TIMEOUT } from '../index'; + +describe('Browser Builder poll', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { watch: true, poll: 4000 }; + const intervals: number[] = []; + let startTime: number | undefined; + + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + // Debounce 1s, otherwise changes are too close together and polling doesn't work. + debounceTime(1000), + tap((buildEvent) => { + expect(buildEvent.success).toBe(true); + if (startTime != undefined) { + intervals.push(Date.now() - startTime - 1000); + } + startTime = Date.now(); + host.appendToFile('src/main.ts', 'console.log(1);'); + }), + take(4), + ) + .toPromise(); + + intervals.sort(); + const median = intervals[Math.trunc(intervals.length / 2)]; + expect(median).toBeGreaterThan(2950); + expect(median).toBeLessThan(12000); + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/rebuild_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/rebuild_spec.ts new file mode 100644 index 000000000000..46113a05275e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/rebuild_spec.ts @@ -0,0 +1,649 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, logging, normalize } from '@angular-devkit/core'; +import { debounceTime, take, takeWhile, tap, timeout } from 'rxjs'; +import { + createArchitect, + host, + lazyModuleFiles, + lazyModuleFnImport, + outputPath, +} from '../../../testing/test-utils'; +import { BUILD_TIMEOUT } from '../index'; + +describe('Browser Builder rebuilds', () => { + const target = { project: 'app', target: 'build' }; + // Rebuild tests are especially sensitive to time between writes due to file watcher + // behaviour. Give them a while. + const rebuildDebounceTime = 1000; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('rebuilds on TS file changes', async () => { + const goldenValueFiles: { [path: string]: string } = { + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + + console.log('$$_E2E_GOLDEN_VALUE_1'); + export let X = '$$_E2E_GOLDEN_VALUE_2'; + `, + 'src/main.ts': ` + import { platformBrowser } from '@angular/platform-browser'; + import { AppModule } from './app/app.module'; + + platformBrowser().bootstrapModule(AppModule); + + import * as m from './app/app.module'; + console.log(m.X); + console.log('$$_E2E_GOLDEN_VALUE_3'); + `, + }; + + const overrides = { watch: true }; + + let phase = 1; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((result) => { + expect(result.success).toBeTrue(); + const hasLazyChunk = host + .scopedSync() + .exists(normalize('dist/src_app_lazy_lazy_module_ts.js')); + switch (phase) { + case 1: + // No lazy chunk should exist. + if (!hasLazyChunk) { + phase = 2; + host.writeMultipleFiles({ ...lazyModuleFiles, ...lazyModuleFnImport }); + } + break; + + case 2: + // A lazy chunk should have been with the filename. + if (hasLazyChunk) { + phase = 3; + host.writeMultipleFiles(goldenValueFiles); + } + break; + + case 3: + // The golden values should be present and in the right order. + const re = new RegExp( + /\$\$_E2E_GOLDEN_VALUE_1(.|\n|\r)*/.source + + /\$\$_E2E_GOLDEN_VALUE_2(.|\n|\r)*/.source + + /\$\$_E2E_GOLDEN_VALUE_3/.source, + ); + const fileName = './dist/main.js'; + const content = new TextDecoder().decode(host.scopedSync().read(normalize(fileName))); + + if (re.test(content)) { + phase = 4; + } + break; + } + }), + takeWhile(() => phase < 4), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds on CSS changes', async () => { + const overrides = { watch: true }; + + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => host.appendToFile('src/app/app.component.css', ':host { color: blue; }')), + take(2), + ) + .toPromise(); + await run.stop(); + }); + + it('type checks on rebuilds', async () => { + host.writeMultipleFiles({ + 'src/funky2.ts': `export const funky2 = (value: string) => value + 'hello';`, + 'src/funky.ts': `export * from './funky2';`, + }); + host.appendToFile( + 'src/main.ts', + ` + import { funky2 } from './funky'; + console.log(funky2('town')); + `, + ); + + const overrides = { watch: true }; + const logger = new logging.Logger(''); + let logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const typeError = `is not assignable to parameter of type 'number'`; + let buildNumber = 0; + + const run = await architect.scheduleTarget(target, overrides, { logger }); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => { + buildNumber += 1; + switch (buildNumber) { + case 1: + expect(buildEvent.success).toBe(true); + // Make an invalid version of the file. + // Should trigger a rebuild, this time an error is expected. + host.writeMultipleFiles({ + 'src/funky2.ts': `export const funky2 = (value: number) => value + 1;`, + }); + break; + + case 2: + // The second build should error out with a type error on the type of an argument. + expect(buildEvent.success).toBe(false); + expect(logs.join().includes(typeError)).toBe(true); + logs = []; + // Change an UNRELATED file and the error should still happen. + // Should trigger a rebuild, this time an error is also expected. + host.appendToFile('src/app/app.module.ts', `console.log(1);`); + break; + + case 3: + // The third build should have the same error as the first. + expect(buildEvent.success).toBe(false); + expect(logs.join().includes(typeError)).toBe(true); + logs = []; + // Fix the error. + host.writeMultipleFiles({ + 'src/funky2.ts': `export const funky2 = (value: string) => value + 'hello';`, + }); + break; + + default: + expect(buildEvent.success).toBe(true); + break; + } + }), + take(4), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds on type changes', async () => { + host.writeMultipleFiles({ 'src/type.ts': `export type MyType = number;` }); + host.appendToFile('src/main.ts', `import { MyType } from './type';`); + + const overrides = { watch: true }; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => host.writeMultipleFiles({ 'src/type.ts': `export type MyType = string;` })), + take(2), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds on transitive type-only file changes', async () => { + host.writeMultipleFiles({ + 'src/interface1.ts': ` + import { Interface2 } from './interface2'; + export interface Interface1 extends Interface2 { } + `, + 'src/interface2.ts': ` + import { Interface3 } from './interface3'; + export interface Interface2 extends Interface3 { } + `, + 'src/interface3.ts': `export interface Interface3 { nbr: number; }`, + }); + host.appendToFile( + 'src/main.ts', + ` + import { Interface1 } from './interface1'; + const something: Interface1 = { nbr: 43 }; + `, + ); + + const overrides = { watch: true }; + const run = await architect.scheduleTarget(target, overrides); + let buildNumber = 0; + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + // NOTE: this only works for transitive type deps after the first build, and only if the + // typedep file was there on the previous build. + // Make sure the first rebuild is triggered on a direct dep (typedep or not). + buildNumber++; + if (buildNumber < 4) { + host.appendToFile(`src/interface${buildNumber}.ts`, `export type MyType = string;`); + } else { + host.appendToFile(`src/typings.d.ts`, `export type MyType = string;`); + } + }), + take(5), + ) + .toPromise(); + + await run.stop(); + }); + + it('rebuilds on transitive non node package DTS file changes', async () => { + host.writeMultipleFiles({ + 'src/interface1.d.ts': ` + import { Interface2 } from './interface2'; + export interface Interface1 extends Interface2 { } + `, + 'src/interface2.d.ts': ` + import { Interface3 } from './interface3'; + export interface Interface2 extends Interface3 { } + `, + 'src/interface3.d.ts': `export interface Interface3 { nbr: number; }`, + }); + host.appendToFile( + 'src/main.ts', + ` + import { Interface1 } from './interface1'; + const something: Interface1 = { nbr: 43 }; + `, + ); + + const overrides = { watch: true }; + const run = await architect.scheduleTarget(target, overrides); + let buildNumber = 0; + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + tap(() => { + buildNumber++; + if (buildNumber === 1) { + host.appendToFile('src/interface3.d.ts', 'export declare type MyType = string;'); + } + }), + take(2), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds after errors in JIT', async () => { + const origContent = new TextDecoder().decode( + host.scopedSync().read(normalize('src/app/app.component.ts')), + ); + host.appendToFile('./src/app/app.component.ts', `]]]]`); + + const overrides = { watch: true, aot: false }; + let buildNumber = 0; + + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => { + buildNumber++; + switch (buildNumber) { + case 1: + // The first build should fail. + expect(buildEvent.success).toBe(false); + // Fix the error. + host.writeMultipleFiles({ 'src/app/app.component.ts': origContent }); + break; + + case 2: + // The second build should have everything fixed. + expect(buildEvent.success).toBe(true); + break; + } + }), + take(2), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds after errors in AOT', async () => { + // Save the original contents of `./src/app/app.component.ts`. + const origContent = new TextDecoder().decode( + host.scopedSync().read(normalize('src/app/app.component.ts')), + ); + // Add a major static analysis error on a non-main file to the initial build. + host.replaceInFile('./src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`); + + // `selector must be a string` errors on VE are part of the emit result, but on Ivy they only + // show up in getNgSemanticDiagnostics. Since getNgSemanticDiagnostics is only called on the + // type checker, we must disable it to get a failing fourth build with Ivy. + const overrides = { watch: true, aot: true }; + const logger = new logging.Logger(''); + let logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + const staticAnalysisError = 'selector must be a string'; + const syntaxError = 'Declaration or statement expected.'; + let buildNumber = 0; + + const run = await architect.scheduleTarget(target, overrides, { logger }); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => { + buildNumber += 1; + switch (buildNumber) { + case 1: + // The first build should error out with a static analysis error. + expect(buildEvent.success).toBe(false, 'First build should not succeed.'); + expect(logs.join().includes(staticAnalysisError)).toBe( + true, + 'First build should have static analysis error.', + ); + logs = []; + // Fix the static analysis error. + host.writeMultipleFiles({ 'src/app/app.component.ts': origContent }); + break; + + case 2: + expect(buildEvent.success).toBe(true, 'Second build should succeed.'); + expect(logs.join().includes(staticAnalysisError)).toBe( + false, + 'Second build should not have static analysis error.', + ); + logs = []; + // Add an syntax error to a non-main file. + host.appendToFile('src/app/app.component.ts', `]]]`); + break; + + case 3: + // The third build should have TS syntax error. + expect(buildEvent.success).toBe(false, 'Third build should not succeed.'); + expect(logs.join().includes(syntaxError)).toBe( + true, + 'Third build should have syntax analysis error.', + ); + expect(logs.join().includes(staticAnalysisError)).toBe( + false, + 'Third build should not have static analysis error.', + ); + logs = []; + // Fix the syntax error, but add the static analysis error again. + host.writeMultipleFiles({ + 'src/app/app.component.ts': origContent.replace( + `'app-root'`, + `(() => 'app-root')()`, + ), + }); + break; + + case 4: + expect(buildEvent.success).toBe(false, 'Fourth build should not succeed.'); + expect(logs.join().includes(syntaxError)).toBe( + false, + 'Fourth build should not have syntax analysis error.', + ); + expect(logs.join().includes(staticAnalysisError)).toBe( + true, + 'Fourth build should have static analysis error.', + ); + logs = []; + // Restore the file to a error-less state. + host.writeMultipleFiles({ 'src/app/app.component.ts': origContent }); + break; + + case 5: + // The fifth build should have everything fixed.. + expect(buildEvent.success).toBe(true, 'Fifth build should succeed.'); + expect(logs.join().includes(syntaxError)).toBe( + false, + 'Fifth build should not have syntax analysis error.', + ); + expect(logs.join().includes(staticAnalysisError)).toBe( + false, + 'Fifth build should not have static analysis error.', + ); + break; + } + }), + take(5), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds AOT factories', async () => { + host.writeMultipleFiles({ + 'src/app/app.component.css': ` + @import './imported-styles.css'; + body {background-color: #00f;} + `, + 'src/app/imported-styles.css': 'p {color: #f00;}', + }); + + const overrides = { watch: true, aot: true }; + let buildNumber = 0; + + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => { + buildNumber += 1; + const fileName = './dist/main.js'; + let content; + switch (buildNumber) { + case 1: + // Trigger a few rebuilds first. + // The AOT compiler is still optimizing rebuilds on the first rebuilds. + expect(buildEvent.success).toBe(true); + host.appendToFile('src/main.ts', 'console.log(1);'); + break; + + case 2: + expect(buildEvent.success).toBe(true); + host.appendToFile('src/main.ts', 'console.log(1);'); + break; + + case 3: + // Change the component html. + expect(buildEvent.success).toBe(true); + host.appendToFile('src/app/app.component.html', '

HTML_REBUILD_STRING

'); + break; + + case 4: + // Check if html changes are added to factories. + expect(buildEvent.success).toBe(true); + content = new TextDecoder().decode(host.scopedSync().read(normalize(fileName))); + expect(content).toContain('HTML_REBUILD_STRING'); + // Change the component css. + host.appendToFile('src/app/app.component.css', 'CSS_REBUILD_STRING {color: #f00;}'); + break; + + case 5: + // Check if css changes are added to factories. + expect(buildEvent.success).toBe(true); + content = new TextDecoder().decode(host.scopedSync().read(normalize(fileName))); + expect(content).toContain('CSS_REBUILD_STRING'); + // Change the component css import. + host.appendToFile( + 'src/app/imported-styles.css', + 'CSS_DEP_REBUILD_STRING {color: #f00;}', + ); + break; + + case 6: + // Check if css import changes are added to factories. + expect(buildEvent.success).toBe(true); + content = new TextDecoder().decode(host.scopedSync().read(normalize(fileName))); + expect(content).toContain('CSS_DEP_REBUILD_STRING'); + // Change the component itself. + host.replaceInFile( + 'src/app/app.component.ts', + 'app-root', + 'app-root-FACTORY_REBUILD_STRING', + ); + break; + + case 7: + // Check if component changes are added to factories. + expect(buildEvent.success).toBe(true); + content = new TextDecoder().decode(host.scopedSync().read(normalize(fileName))); + expect(content).toContain('FACTORY_REBUILD_STRING'); + break; + } + }), + take(7), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds on changes in barrel file dependency', async () => { + host.writeMultipleFiles({ + 'src/index.ts': `export * from './interface'`, + 'src/interface.ts': `export interface Foo { bar: boolean };`, + }); + host.appendToFile( + 'src/main.ts', + ` + import { Foo } from './index'; + const x: Foo = { bar: true }; + `, + ); + + const overrides = { watch: true, aot: false }; + let buildNumber = 0; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap((buildEvent) => { + buildNumber += 1; + switch (buildNumber) { + case 1: + expect(buildEvent.success).toBe(true); + host.writeMultipleFiles({ + 'src/interface.ts': `export interface Foo { + bar: boolean; + baz?: string; + };`, + }); + break; + + case 2: + expect(buildEvent.success).toBe(true); + break; + } + }), + take(2), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds AOT on CSS changes', async () => { + const overrides = { watch: true, aot: true }; + + let buildCount = 1; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + debounceTime(rebuildDebounceTime), + tap(() => { + const content = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, 'main.js')), + ); + + switch (buildCount) { + case 1: + expect(content).not.toContain('color: green'); + host.appendToFile('src/app/app.component.css', 'h1 { color: green; }'); + break; + case 2: + expect(content).toContain('color: green'); + break; + } + + buildCount++; + }), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + take(2), + ) + .toPromise(); + await run.stop(); + }); + + it('rebuilds AOT on HTML changes', async () => { + const overrides = { watch: true, aot: true }; + + let buildCount = 1; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(BUILD_TIMEOUT), + debounceTime(rebuildDebounceTime), + tap(() => { + const content = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, 'main.js')), + ); + + switch (buildCount) { + case 1: + expect(content).not.toContain('New Updated Content'); + host.appendToFile('src/app/app.component.html', 'New Updated Content'); + break; + case 2: + expect(content).toContain('New Updated Content'); + break; + } + + buildCount++; + }), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + take(2), + ) + .toPromise(); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/replacements_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/replacements_spec.ts new file mode 100644 index 000000000000..eebb48550748 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/replacements_spec.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { logging, normalize } from '@angular-devkit/core'; +import { delay, filter, map, of, race, take, takeUntil, takeWhile, tap, timeout } from 'rxjs'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder file replacements', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + beforeEach(() => + host.writeMultipleFiles({ + 'src/meaning-too.ts': 'export var meaning = 42;', + 'src/meaning.ts': `export var meaning = 10;`, + + 'src/main.ts': ` + import { meaning } from './meaning'; + + console.log(meaning); + `, + }), + ); + + it('allows file replacements', async () => { + const overrides = { + fileReplacements: [ + { + replace: normalize('/src/meaning.ts'), + with: normalize('/src/meaning-too.ts'), + }, + ], + }; + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).toMatch(/meaning\s*=\s*42/); + expect(await files['main.js']).not.toMatch(/meaning\s*=\s*10/); + }); + + it(`allows file replacements with deprecated format`, async () => { + const overrides = { + fileReplacements: [ + { + src: normalize('/src/meaning.ts'), + replaceWith: normalize('/src/meaning-too.ts'), + }, + ], + }; + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js']).toMatch(/meaning\s*=\s*42/); + expect(await files['main.js']).not.toMatch(/meaning\s*=\s*10/); + }); + + it(`fails compilation with missing 'replace' file`, async () => { + const overrides = { + fileReplacements: [ + { + replace: normalize('/src/meaning.ts'), + with: normalize('/src/meaning-three.ts'), + }, + ], + }; + + const run = await architect.scheduleTarget(target, overrides); + await expectAsync(run.result).toBeRejectedWithError(); + await run.stop(); + }); + + it(`fails compilation with missing 'with' file`, async () => { + const overrides = { + fileReplacements: [ + { + replace: normalize('/src/meaning-three.ts'), + with: normalize('/src/meaning-too.ts'), + }, + ], + }; + + const run = await architect.scheduleTarget(target, overrides); + await expectAsync(run.result).toBeRejectedWithError(); + await run.stop(); + }); + + it('file replacements work with watch mode', async () => { + const overrides = { + fileReplacements: [ + { + replace: normalize('/src/meaning.ts'), + with: normalize('/src/meaning-too.ts'), + }, + ], + watch: true, + }; + + let buildCount = 0; + let phase = 1; + + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + timeout(30000), + tap((result) => { + expect(result.success).toBe(true, 'build should succeed'); + + const fileName = normalize('dist/main.js'); + const content = new TextDecoder().decode(host.scopedSync().read(fileName)); + const has42 = /meaning\s*=\s*42/.test(content); + buildCount++; + switch (phase) { + case 1: + const has10 = /meaning\s*=\s*10/.test(content); + + if (has42 && !has10) { + phase = 2; + host.writeMultipleFiles({ + 'src/meaning-too.ts': 'export var meaning = 84;', + }); + } + break; + + case 2: + const has84 = /meaning\s*=\s*84/.test(content); + + if (has84 && !has42) { + phase = 3; + } else { + // try triggering a rebuild again + host.writeMultipleFiles({ + 'src/meaning-too.ts': 'export var meaning = 84;', + }); + } + break; + } + }), + takeWhile(() => phase < 3), + ) + .toPromise() + .catch(() => { + throw new Error(`stuck at phase ${phase} [builds: ${buildCount}]`); + }); + + await run.stop(); + }); + + it('file replacements work with forked type checker on watch mode', async () => { + host.writeMultipleFiles({ + 'src/file-replaced.ts': 'export var obj = { one: 1, two: 2 };', + 'src/file.ts': `export var obj = { one: 1 };`, + 'src/main.ts': ` + import { obj } from './file'; + console.log(obj.two); + `, + }); + + const overrides = { + fileReplacements: [ + { + replace: normalize('/src/file.ts'), + with: normalize('/src/file-replaced.ts'), + }, + ], + watch: true, + }; + + const unexpectedError = `Property 'two' does not exist on type '{ one: number; }'`; + const expectedError = `Property 'prop' does not exist on type '{}'`; + + const logger = new logging.Logger(''); + + // Race between a timeout and the expected log entry. + const stop$ = race( + of(null).pipe(delay((45000 * 2) / 3)), + logger.pipe( + filter((entry) => entry.message.includes(expectedError)), + map((entry) => entry.message), + take(1), + ), + ); + + let errorAdded = false; + const run = await architect.scheduleTarget(target, overrides, { logger }); + run.output + .pipe( + tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')), + tap(() => { + // Introduce a known type error to detect in the logger filter. + if (!errorAdded) { + host.appendToFile('src/main.ts', 'console.log({}.prop);'); + errorAdded = true; + } + }), + takeUntil(stop$), + ) + .subscribe(); + + const res = await stop$.toPromise(); + expect(res).toBeDefined(); + expect(res).not.toContain(unexpectedError); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts new file mode 100644 index 000000000000..379dea990e5e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join } from '@angular-devkit/core'; +import { take, tap } from 'rxjs'; +import { createArchitect, host, outputPath } from '../../../testing/test-utils'; + +describe('Browser Builder resolve json module', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works with watch', async () => { + host.writeMultipleFiles({ + 'src/my-json-file.json': `{"foo": "1"}`, + 'src/main.ts': `import * as a from './my-json-file.json'; console.log(a);`, + }); + + host.replaceInFile( + 'tsconfig.json', + '"target": "es2022"', + '"target": "es2022", "resolveJsonModule": true', + ); + + const overrides = { watch: true }; + + let buildCount = 1; + const run = await architect.scheduleTarget(target, overrides); + await run.output + .pipe( + tap(() => { + const content = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, 'main.js')), + ); + + switch (buildCount) { + case 1: + expect(content).toContain('"foo":"1"'); + host.writeMultipleFiles({ + 'src/my-json-file.json': `{"foo": "2"}`, + }); + break; + case 2: + expect(content).toContain('"foo":"2"'); + break; + } + + buildCount++; + }), + tap((buildEvent) => expect(buildEvent.success).toBe(true)), + take(2), + ) + .toPromise(); + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/resources-output-path_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/resources-output-path_spec.ts new file mode 100644 index 000000000000..92f68dbd541d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/resources-output-path_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { normalize } from '@angular-devkit/core'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder styles resources output path', () => { + const imgSvg = ` + + + + `; + + function writeFiles() { + // Use a large image for the relative ref so it cannot be inlined. + host.copyFile('src/spectrum.png', './src/assets/global-img-relative.png'); + host.copyFile('src/spectrum.png', './src/assets/component-img-relative.png'); + host.writeMultipleFiles({ + 'src/styles.css': ` + h1 { background: url('/assets/global-img-absolute.svg'); } + h2 { background: url('./assets/global-img-relative.png'); } + `, + 'src/app/app.component.css': ` + h3 { background: url('/assets/component-img-absolute.svg'); } + h4 { background: url('../assets/component-img-relative.png'); } + `, + 'src/assets/global-img-absolute.svg': imgSvg, + 'src/assets/component-img-absolute.svg': imgSvg, + }); + } + + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it(`supports resourcesOutputPath in resource urls`, async () => { + writeFiles(); + // Check base paths are correctly generated. + const overrides = { + aot: true, + resourcesOutputPath: 'out-assets', + }; + + const { files } = await browserBuild(architect, host, target, overrides); + const styles = await files['styles.css']; + const main = await files['main.js']; + + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(styles).toContain(`url('out-assets/global-img-relative.png')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + expect(main).toContain(`url('out-assets/component-img-relative.png')`); + + expect(host.scopedSync().exists(normalize('dist/assets/global-img-absolute.svg'))).toBe(true); + expect(host.scopedSync().exists(normalize('dist/out-assets/global-img-relative.png'))).toBe( + true, + ); + expect(host.scopedSync().exists(normalize('dist/assets/component-img-absolute.svg'))).toBe( + true, + ); + expect(host.scopedSync().exists(normalize('dist/out-assets/component-img-relative.png'))).toBe( + true, + ); + }); + + it(`supports blank resourcesOutputPath`, async () => { + writeFiles(); + + // Check base paths are correctly generated. + const overrides = { aot: true }; + const { files } = await browserBuild(architect, host, target, overrides); + const styles = await files['styles.css']; + const main = await files['main.js']; + + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(styles).toContain(`url('global-img-relative.png')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + expect(main).toContain(`url('component-img-relative.png')`); + expect(host.scopedSync().exists(normalize('dist/assets/global-img-absolute.svg'))).toBe(true); + expect(host.scopedSync().exists(normalize('dist/global-img-relative.png'))).toBe(true); + expect(host.scopedSync().exists(normalize('dist/assets/component-img-absolute.svg'))).toBe( + true, + ); + expect(host.scopedSync().exists(normalize('dist/component-img-relative.png'))).toBe(true); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/scripts-array_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/scripts-array_spec.ts new file mode 100644 index 000000000000..95294b595baf --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/scripts-array_spec.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { logging } from '@angular-devkit/core'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder scripts array', () => { + const scripts: { [path: string]: string } = { + 'src/input-script.js': "console.log('input-script'); var number = 1+1;", + 'src/zinput-script.js': "console.log('zinput-script');", + 'src/finput-script.js': "console.log('finput-script');", + 'src/uinput-script.js': "console.log('uinput-script');", + 'src/binput-script.js': "console.log('binput-script');", + 'src/ainput-script.js': "console.log('ainput-script');", + 'src/cinput-script.js': "console.log('cinput-script');", + 'src/lazy-script.js': "console.log('lazy-script');", + 'src/pre-rename-script.js': "console.log('pre-rename-script');", + 'src/pre-rename-lazy-script.js': "console.log('pre-rename-lazy-script');", + }; + const getScriptsOption = () => [ + 'src/input-script.js', + 'src/zinput-script.js', + 'src/finput-script.js', + 'src/uinput-script.js', + 'src/binput-script.js', + 'src/ainput-script.js', + 'src/cinput-script.js', + { input: 'src/lazy-script.js', bundleName: 'lazy-script', inject: false }, + { input: 'src/pre-rename-script.js', bundleName: 'renamed-script' }, + { input: 'src/pre-rename-lazy-script.js', bundleName: 'renamed-lazy-script', inject: false }, + ]; + + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const matches: Record = { + 'scripts.js': 'input-script', + 'lazy-script.js': 'lazy-script', + 'renamed-script.js': 'pre-rename-script', + 'renamed-lazy-script.js': 'pre-rename-lazy-script', + 'main.js': 'input-script', + 'index.html': + '' + + '' + + '' + + '' + + '' + + '', + }; + + host.writeMultipleFiles(scripts); + host.appendToFile('src/main.ts', "\nimport './input-script.js';"); + + // Remove styles so we don't have to account for them in the index.html order check. + const { files } = await browserBuild(architect, host, target, { + styles: [], + scripts: getScriptsOption(), + } as {}); + + for (const [fileName, content] of Object.entries(matches)) { + expect(await files[fileName]).toMatch(content); + } + }); + + it('uglifies, uses sourcemaps, and adds hashes', async () => { + host.writeMultipleFiles(scripts); + + const { files } = await browserBuild(architect, host, target, { + optimization: true, + sourceMap: true, + outputHashing: 'all', + scripts: getScriptsOption(), + } as {}); + + const fileNames = Object.keys(files); + const scriptsBundle = fileNames.find((n) => /scripts\.[0-9a-f]{16}\.js/.test(n)); + expect(scriptsBundle).toBeTruthy(); + expect(await files[scriptsBundle || '']).toMatch('var number=2;'); + + expect(fileNames.some((n) => /scripts\.[0-9a-f]{16}\.js\.map/.test(n))).toBeTruthy(); + expect(fileNames.some((n) => /renamed-script\.[0-9a-f]{16}\.js/.test(n))).toBeTruthy(); + expect(fileNames.some((n) => /renamed-script\.[0-9a-f]{16}\.js\.map/.test(n))).toBeTruthy(); + expect(fileNames.some((n) => /script\.[0-9a-f]{16}\.js/.test(n))).toBeTruthy(); + expect(await files['lazy-script.js']).not.toBeUndefined(); + expect(await files['lazy-script.js.map']).not.toBeUndefined(); + expect(await files['renamed-lazy-script.js']).not.toBeUndefined(); + expect(await files['renamed-lazy-script.js.map']).not.toBeUndefined(); + }); + + it('preserves script order', async () => { + host.writeMultipleFiles(scripts); + + const { files } = await browserBuild(architect, host, target, { + scripts: getScriptsOption(), + } as {}); + + expect(await files['scripts.js']).toMatch( + new RegExp( + /.*['"]input-script['"](.|\n|\r)*/.source + + /['"]zinput-script['"](.|\n|\r)*/.source + + /['"]finput-script['"](.|\n|\r)*/.source + + /['"]uinput-script['"](.|\n|\r)*/.source + + /['"]binput-script['"](.|\n|\r)*/.source + + /['"]ainput-script['"](.|\n|\r)*/.source + + /['"]cinput-script['"]/.source, + ), + ); + }); + + it('chunk in entry', async () => { + host.writeMultipleFiles(scripts); + + const logger = new logging.Logger('build-script-chunk-entry'); + const logs: string[] = []; + logger.subscribe(({ message }) => { + logs.push(message); + }); + + await browserBuild( + architect, + host, + target, + { + scripts: getScriptsOption(), + } as {}, + { logger }, + ); + + const joinedLogs = logs.join('\n'); + expect(joinedLogs).toMatch(/lazy-script.+\d+ bytes/); + expect(joinedLogs).toMatch(/renamed-script.+\d+ bytes/); + expect(joinedLogs).toMatch(/renamed-lazy-script.+\d+ bytes/); + expect(joinedLogs).not.toContain('Lazy chunks'); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/service-worker_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/service-worker_spec.ts new file mode 100644 index 000000000000..4cd6b6b6e7d9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/service-worker_spec.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { debounceTime, take, tap } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder service worker', () => { + const manifest = { + index: '/index.html', + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + resources: { + files: ['/favicon.ico', '/index.html', '/*.bundle.css', '/*.bundle.js', '/*.chunk.js'], + }, + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + resources: { + files: ['/assets/**', '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)'], + }, + }, + ], + }; + + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + + afterEach(async () => host.restore().toPromise()); + + it('errors if no ngsw-config.json is present', async () => { + const overrides = { serviceWorker: true }; + await host.delete(join(host.root(), 'src/ngsw-config.json')).toPromise(); + + const run = await architect.scheduleTarget(target, overrides); + + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: false })); + + await run.stop(); + }); + + it('supports specifying a custom service worker configuration file', async () => { + host.writeMultipleFiles({ + 'src/configs/ngsw.json': JSON.stringify(manifest), + 'src/assets/folder-asset.txt': 'folder-asset.txt', + 'src/styles.css': `body { background: url(./spectrum.png); }`, + }); + + const overrides = { serviceWorker: true, ngswConfigPath: 'src/configs/ngsw.json' }; + + const run = await architect.scheduleTarget(target, overrides); + + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + await run.stop(); + + expect(host.scopedSync().exists(normalize('dist/ngsw.json'))).toBeTrue(); + }); + + it('works with service worker', async () => { + host.writeMultipleFiles({ + 'src/ngsw-config.json': JSON.stringify(manifest), + 'src/assets/folder-asset.txt': 'folder-asset.txt', + 'src/styles.css': `body { background: url(./spectrum.png); }`, + }); + + const overrides = { serviceWorker: true }; + const run = await architect.scheduleTarget(target, overrides); + + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + expect(host.scopedSync().exists(normalize('dist/ngsw.json'))).toBeTrue(); + const ngswJson = JSON.parse( + new TextDecoder().decode(host.scopedSync().read(normalize('dist/ngsw.json'))), + ); + // Verify index and assets are there. + expect(ngswJson).toEqual( + jasmine.objectContaining({ + configVersion: 1, + index: '/index.html', + navigationUrls: [ + { positive: true, regex: '^\\/.*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$' }, + ], + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + updateMode: 'prefetch', + urls: ['/favicon.ico', '/index.html'], + cacheQueryOptions: { + ignoreVary: true, + }, + patterns: [], + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + urls: ['/assets/folder-asset.txt', '/spectrum.png'], + cacheQueryOptions: { + ignoreVary: true, + }, + patterns: [], + }, + ], + dataGroups: [], + hashTable: { + '/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01', + '/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4', + '/index.html': '8964a35a8b850942f8d18ba919f248762ff3154d', + '/spectrum.png': '8d048ece46c0f3af4b598a95fd8e4709b631c3c0', + }, + }), + ); + + await run.stop(); + }); + + it('works in watch mode', async () => { + host.writeMultipleFiles({ + 'src/ngsw-config.json': JSON.stringify(manifest), + 'src/assets/folder-asset.txt': 'folder-asset.txt', + 'src/styles.css': `body { background: url(./spectrum.png); }`, + }); + + const overrides = { serviceWorker: true, watch: true }; + const run = await architect.scheduleTarget(target, overrides); + let buildNumber = 0; + + await run.output + .pipe( + debounceTime(1000), + tap((buildEvent) => { + expect(buildEvent.success).toBeTrue(); + + const ngswJsonPath = normalize('dist/ngsw.json'); + expect(host.scopedSync().exists(ngswJsonPath)).toBeTrue(); + const ngswJson = JSON.parse( + new TextDecoder().decode(host.scopedSync().read(ngswJsonPath)), + ); + + const hashTableEntries = Object.keys(ngswJson.hashTable); + + buildNumber += 1; + switch (buildNumber) { + case 1: + expect(hashTableEntries).toEqual([ + '/assets/folder-asset.txt', + '/favicon.ico', + '/index.html', + '/spectrum.png', + ]); + + host.copyFile('src/assets/folder-asset.txt', 'src/assets/folder-new-asset.txt'); + break; + + case 2: + expect(hashTableEntries).toEqual([ + '/assets/folder-asset.txt', + '/assets/folder-new-asset.txt', + '/favicon.ico', + '/index.html', + '/spectrum.png', + ]); + break; + } + }), + take(2), + ) + .toPromise(); + + await run.stop(); + }); + + it('works with service worker and baseHref', async () => { + host.writeMultipleFiles({ + 'src/ngsw-config.json': JSON.stringify(manifest), + 'src/assets/folder-asset.txt': 'folder-asset.txt', + }); + + const overrides = { serviceWorker: true, baseHref: '/foo/bar' }; + const run = await architect.scheduleTarget(target, overrides); + + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + expect(host.scopedSync().exists(normalize('dist/ngsw.json'))).toBeTrue(); + const ngswJson = JSON.parse( + new TextDecoder().decode(host.scopedSync().read(normalize('dist/ngsw.json'))), + ); + // Verify index and assets include the base href. + expect(ngswJson).toEqual( + jasmine.objectContaining({ + configVersion: 1, + index: '/foo/bar/index.html', + navigationUrls: [ + { positive: true, regex: '^\\/.*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$' }, + { positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$' }, + ], + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + updateMode: 'prefetch', + urls: ['/foo/bar/favicon.ico', '/foo/bar/index.html'], + patterns: [], + cacheQueryOptions: { + ignoreVary: true, + }, + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + urls: ['/foo/bar/assets/folder-asset.txt'], + patterns: [], + cacheQueryOptions: { + ignoreVary: true, + }, + }, + ], + dataGroups: [], + hashTable: { + '/foo/bar/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01', + '/foo/bar/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4', + '/foo/bar/index.html': '5c99755c1e7cfd1c8aba34ad1155afc72a288fec', + }, + }), + ); + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/source-map_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/source-map_spec.ts new file mode 100644 index 000000000000..6acae8d40f16 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/source-map_spec.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; +import { OutputHashing } from '../schema'; + +describe('Browser Builder source map', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { + sourceMap: true, + styles: ['src/styles.css'], + }; + + host.writeMultipleFiles({ + 'src/styles.css': `div { flex: 1 }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['main.js.map']).not.toBeUndefined(); + expect(await files['styles.css.map']).not.toBeUndefined(); + }); + + it(`sourcemaps sources should not start with '/'`, async () => { + // If sourcemaps sources start with a '/' it will break VS code breakpoints + // Unless 'sourceMapPathOverrides' are provided + const overrides = { + sourceMap: true, + }; + + const { files } = await browserBuild(architect, host, target, overrides); + const mainJSMap = await files['main.js.map']; + expect(mainJSMap).not.toBeUndefined(); + + const sources: string[] = JSON.parse(mainJSMap).sources; + for (const source of sources) { + expect(source.startsWith('/')).toBe(false, `${source} started with an '/'.`); + } + }); + + it('works with outputHashing', async () => { + const { files } = await browserBuild(architect, host, target, { + sourceMap: true, + outputHashing: OutputHashing.All, + }); + + expect(Object.keys(files).some((name) => /main\.[0-9a-f]{16}\.js.map/.test(name))).toBeTruthy(); + }); + + it('does not output source map when disabled', async () => { + const { files } = await browserBuild(architect, host, target, { + sourceMap: false, + }); + + expect(files['main.js.map']).toBeUndefined(); + }); + + it('supports hidden sourcemaps', async () => { + const overrides = { + sourceMap: { + hidden: true, + styles: true, + scripts: true, + }, + styles: ['src/styles.scss'], + }; + + host.writeMultipleFiles({ + 'src/styles.scss': `div { flex: 1 }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect('main.js.map' in files).toBe(true); + expect('styles.css.map' in files).toBe(true); + expect(await files['main.js']).not.toContain('sourceMappingURL=main.js.map'); + expect(await files['styles.css']).not.toContain('sourceMappingURL=styles.css.map'); + }); + + it('supports styles only sourcemaps', async () => { + const overrides = { + sourceMap: { + styles: true, + scripts: false, + }, + styles: ['src/styles.scss'], + }; + + host.writeMultipleFiles({ + 'src/styles.scss': `div { flex: 1 }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect('main.js.map' in files).toBe(false); + expect('styles.css.map' in files).toBe(true); + expect(await files['main.js']).not.toContain('sourceMappingURL=main.js.map'); + expect(await files['styles.css']).toContain('sourceMappingURL=styles.css.map'); + }); + + it('supports scripts only sourcemaps', async () => { + const overrides = { + sourceMap: { + styles: false, + scripts: true, + }, + styles: ['src/styles.scss'], + }; + + host.writeMultipleFiles({ + 'src/styles.scss': `div { flex: 1 }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect('main.js.map' in files).toBe(true); + expect('styles.css.map' in files).toBe(false); + expect(await files['main.js']).toContain('sourceMappingURL=main.js.map'); + expect(await files['styles.css']).not.toContain('sourceMappingURL=styles.css.map'); + }); + + it('should not inline component styles sourcemaps when hidden', async () => { + const overrides = { + sourceMap: { + hidden: true, + styles: true, + scripts: true, + }, + styles: ['src/styles.scss'], + }; + + host.writeMultipleFiles({ + 'src/styles.scss': `div { flex: 1 }`, + 'src/app/app.component.css': `p { color: red; }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect('main.js.map' in files).toBe(true); + expect('styles.css.map' in files).toBe(true); + expect(await files['main.js']).not.toContain('sourceMappingURL=main.js.map'); + expect(await files['main.js']).not.toContain('sourceMappingURL=data:application/json'); + expect(await files['styles.css']).not.toContain('sourceMappingURL=styles.css.map'); + expect(await files['styles.css']).not.toContain('sourceMappingURL=data:application/json'); + }); + + it('should resolve sources to partial SCSS files', async () => { + const overrides = { + sourceMap: true, + styles: ['src/styles.scss'], + }; + + host.writeMultipleFiles({ + 'src/styles.scss': `@import './partial';`, + 'src/_partial.scss': `p { color: red; }`, + }); + + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['styles.css.map']).toContain('_partial.scss'); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts new file mode 100644 index 000000000000..175b61d7ca12 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder stats json', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const { files } = await browserBuild(architect, host, target, { statsJson: true }); + expect('stats.json' in files).toBe(true); + }); + + it('works with profile flag', async () => { + const { files } = await browserBuild(architect, host, target, { statsJson: true }); + expect('stats.json' in files).toBe(true); + const stats = JSON.parse(await files['stats.json']); + expect(stats.chunks[0].modules[0].profile.building).toBeDefined(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts new file mode 100644 index 000000000000..b91062b85f4d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/styles_spec.ts @@ -0,0 +1,714 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { normalize, tags } from '@angular-devkit/core'; +import { dirname } from 'node:path'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder styles', () => { + const extensionsWithImportSupport = ['css', 'scss', 'less']; + const extensionsWithVariableSupport = ['scss', 'less']; + const imgSvg = ` + + + + `; + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('supports global styles', async () => { + const styles = [ + 'src/input-style.css', + { input: 'src/lazy-style.css', bundleName: 'lazy-style', inject: false }, + { input: 'src/pre-rename-style.css', bundleName: 'renamed-style' }, + { input: 'src/pre-rename-lazy-style.css', bundleName: 'renamed-lazy-style', inject: false }, + ] as {}; + const cssMatches: { [path: string]: string } = { + 'styles.css': '.input-style', + 'lazy-style.css': '.lazy-style', + 'renamed-style.css': '.pre-rename-style', + 'renamed-lazy-style.css': '.pre-rename-lazy-style', + }; + const cssIndexMatches: { [path: string]: string } = { + 'index.html': + '' + + '', + }; + host.writeMultipleFiles({ + 'src/string-style.css': '.string-style { color: red }', + 'src/input-style.css': '.input-style { color: red }', + 'src/lazy-style.css': '.lazy-style { color: red }', + 'src/pre-rename-style.css': '.pre-rename-style { color: red }', + 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', + }); + + const { files } = await browserBuild(architect, host, target, { styles }); + // Check css files were created. + for (const cssFileName of Object.keys(cssMatches)) { + expect(await files[cssFileName]).toMatch(cssMatches[cssFileName]); + } + + // Check check index has styles in the right order. + for (const cssIndexFileName of Object.keys(cssIndexMatches)) { + expect(await files[cssIndexFileName]).toMatch(cssIndexMatches[cssIndexFileName]); + } + }); + + it('supports empty styleUrls in components', async () => { + host.writeMultipleFiles({ + './src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styleUrls: [] + }) + export class AppComponent { + title = 'app'; + } + `, + }); + + await browserBuild(architect, host, target); + }); + + it('supports autoprefixer with inline component styles in JIT mode', async () => { + host.writeMultipleFiles({ + './src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styles: ['div { mask-composite: add; }'], + }) + export class AppComponent { + title = 'app'; + } + `, + '.browserslistrc': ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + }); + + const { files } = await browserBuild(architect, host, target, { aot: false }); + + expect(await files['main.js']).toContain('-webkit-mask-composite'); + }); + + it('supports autoprefixer with inline component styles in AOT mode', async () => { + host.writeMultipleFiles({ + './src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styles: ['div { mask-composite: add; }'], + }) + export class AppComponent { + title = 'app'; + } + `, + '.browserslistrc': ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + }); + + const { files } = await browserBuild(architect, host, target, { aot: true }); + + expect(await files['main.js']).toContain('-webkit-mask-composite'); + }); + + it('supports autoprefixer with inline template styles in AOT mode', async () => { + host.writeMultipleFiles({ + './src/app/app.component.html': ` + +

{{ title }}
+ `, + './src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styles: 'div { font-weight: 500; }', + }) + export class AppComponent { + title = 'app'; + } + `, + '.browserslistrc': ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + }); + + const { files } = await browserBuild(architect, host, target, { aot: true }); + + expect(await files['main.js']).toContain('-webkit-mask-composite'); + }); + + extensionsWithImportSupport.forEach((ext) => { + it(`supports imports in ${ext} files`, async () => { + host.writeMultipleFiles({ + [`src/styles.${ext}`]: ` + @import './imported-styles.${ext}'; + body { background-color: #00f; } + `, + [`src/imported-styles.${ext}`]: 'p { background-color: #f00; }', + [`src/app/app.component.${ext}`]: ` + @import './imported-component-styles.${ext}'; + .outer { + .inner { + background: #fff; + } + } + `, + [`src/app/imported-component-styles.${ext}`]: 'h1 { background: #000; }', + }); + + const matches: { [path: string]: RegExp } = { + 'styles.css': new RegExp( + // The global style should be there + /p\s*{\s*background-color: #f00;\s*}(.|\n|\r)*/.source + + // The global style via import should be there + /body\s*{\s*background-color: #00f;\s*}/.source, + ), + 'styles.css.map': /"mappings":".+"/, + 'main.js': new RegExp( + // The component style should be there + /h1(.|\n|\r)*background:\s*#000(.|\n|\r)*/.source + + // The component style via import should be there + /.outer(.|\n|\r)*.inner(.|\n|\r)*background:\s*#[fF]+/.source, + ), + }; + + const overrides = { + aot: true, + sourceMap: true, + styles: [`src/styles.${ext}`], + }; + + host.replaceInFile( + 'src/app/app.component.ts', + './app.component.css', + `./app.component.${ext}`, + ); + + const { files } = await browserBuild(architect, host, target, overrides); + for (const fileName of Object.keys(matches)) { + expect(await files[fileName]).toMatch(matches[fileName]); + } + }); + }); + + extensionsWithImportSupport.forEach((ext) => { + it(`supports material imports in ${ext} files`, async () => { + host.writeMultipleFiles({ + [`src/styles.${ext}`]: ` + @import "@angular/material/prebuilt-themes/indigo-pink.css"; + `, + [`src/app/app.component.${ext}`]: ` + @import "@angular/material/prebuilt-themes/indigo-pink.css"; + `, + }); + host.replaceInFile( + 'src/app/app.component.ts', + './app.component.css', + `./app.component.${ext}`, + ); + + const overrides = { + styles: [{ input: `src/styles.${ext}` }], + }; + await browserBuild(architect, host, target, overrides); + }); + }); + + extensionsWithVariableSupport.forEach((ext) => { + it(`supports ${ext} includePaths`, async () => { + let variableAssignment = ''; + let variablereference = ''; + if (ext === 'scss') { + variableAssignment = '$primary-color:'; + variablereference = '$primary-color'; + } else if (ext === 'less') { + variableAssignment = '@primary-color:'; + variablereference = '@primary-color'; + } + + host.writeMultipleFiles({ + [`src/style-paths/variables.${ext}`]: `${variableAssignment} #f00;`, + [`src/styles.${ext}`]: ` + @import 'variables'; + h1 { color: ${variablereference}; } + `, + [`src/app/app.component.${ext}`]: ` + @import 'variables'; + h2 { color: ${variablereference}; } + `, + }); + + const matches: { [path: string]: RegExp } = { + 'styles.css': /h1\s*{\s*color: #f00;\s*}/, + 'main.js': /h2\s*{\s*color: #f00;\s*}/, + }; + + host.replaceInFile( + 'src/app/app.component.ts', + './app.component.css', + `./app.component.${ext}`, + ); + + const overrides = { + styles: [`src/styles.${ext}`], + stylePreprocessorOptions: { + includePaths: ['src/style-paths'], + }, + }; + + const { files } = await browserBuild(architect, host, target, overrides); + for (const [fileName, match] of Object.entries(matches)) { + expect(await files[fileName]).toMatch(match); + } + }); + }); + + it(`supports font-awesome imports`, async () => { + // font-awesome mock to avoid having an extra dependency. + host.writeMultipleFiles({ + 'node_modules/font-awesome/scss/font-awesome.scss': ` + * { color: red } + `, + }); + + host.writeMultipleFiles({ + 'src/styles.scss': ` + @import "font-awesome/scss/font-awesome"; + `, + }); + + const overrides = { styles: [`src/styles.scss`] }; + await browserBuild(architect, host, target, overrides); + }); + + it(`uses autoprefixer`, async () => { + host.writeMultipleFiles({ + 'src/styles.css': tags.stripIndents` + @import url(imported-styles.css); + /* normal-comment */ + /*! important-comment */ + div { mask-composite: add; }`, + 'src/imported-styles.css': tags.stripIndents` + /* normal-comment */ + /*! important-comment */ + section { mask-composite: add; }`, + '.browserslistrc': ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + }); + + const overrides = { optimization: false }; + const { files } = await browserBuild(architect, host, target, overrides); + const content = await files['styles.css']; + expect(content).toContain(tags.stripIndents` + /* normal-comment */ + /*! important-comment */ + section { -webkit-mask-composite: source-over; mask-composite: add; }`); + // Check separately because Webpack may add source file comments inbetween the rules + expect(content).toContain(tags.stripIndents` + /* normal-comment */ + /*! important-comment */ + div { -webkit-mask-composite: source-over; mask-composite: add; }`); + }); + + it(`minimizes css`, async () => { + host.writeMultipleFiles({ + 'src/styles.css': tags.stripIndents` + /* normal-comment */ + /*! important-comment */ + div { flex: 1 }`, + }); + + const overrides = { optimization: true }; + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['styles.css']).toContain('/*! important-comment */'); + }); + + // TODO: consider making this a unit test in the url processing plugins. + it( + `supports baseHref/deployUrl in resource urls`, + async () => { + // Use a large image for the relative ref so it cannot be inlined. + host.copyFile('src/spectrum.png', './src/assets/global-img-relative.png'); + host.copyFile('src/spectrum.png', './src/assets/component-img-relative.png'); + host.writeMultipleFiles({ + 'src/styles.css': ` + h1 { background: url('/assets/global-img-absolute.svg'); } + h2 { background: url('./assets/global-img-relative.png'); } + `, + 'src/app/app.component.css': ` + h3 { background: url('/assets/component-img-absolute.svg'); } + h4 { background: url('../assets/component-img-relative.png'); } + `, + 'src/assets/global-img-absolute.svg': imgSvg, + 'src/assets/component-img-absolute.svg': imgSvg, + }); + + let { files } = await browserBuild(architect, host, target, { aot: true }); + + // Check base paths are correctly generated. + let styles = await files['styles.css']; + let main = await files['main.js']; + + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(styles).toContain(`url('global-img-relative.png')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + expect(main).toContain(`url('component-img-relative.png')`); + + expect(host.scopedSync().exists(normalize('dist/assets/global-img-absolute.svg'))).toBe(true); + expect(host.scopedSync().exists(normalize('dist/global-img-relative.png'))).toBe(true); + expect(host.scopedSync().exists(normalize('dist/assets/component-img-absolute.svg'))).toBe( + true, + ); + expect(host.scopedSync().exists(normalize('dist/component-img-relative.png'))).toBe(true); + + // Check urls with deploy-url scheme are used as is. + files = ( + await browserBuild(architect, host, target, { + baseHref: '/base/', + deployUrl: 'http://deploy.url/', + }) + ).files; + + styles = await files['styles.css']; + main = await files['main.js']; + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + + // Check urls with base-href scheme are used as is (with deploy-url). + files = ( + await browserBuild(architect, host, target, { + baseHref: 'http://base.url/', + deployUrl: 'deploy/', + }) + ).files; + styles = await files['styles.css']; + main = await files['main.js']; + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + + // Check urls with deploy-url and base-href scheme only use deploy-url. + files = ( + await browserBuild(architect, host, target, { + baseHref: 'http://base.url/', + deployUrl: 'http://deploy.url/', + }) + ).files; + styles = await files['styles.css']; + main = await files['main.js']; + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + + // Check with schemeless base-href and deploy-url flags. + files = ( + await browserBuild(architect, host, target, { + baseHref: '/base/', + deployUrl: 'deploy/', + }) + ).files; + styles = await files['styles.css']; + main = await files['main.js']; + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + + // Check with identical base-href and deploy-url flags. + files = ( + await browserBuild(architect, host, target, { + baseHref: '/base/', + deployUrl: '/base/', + }) + ).files; + + styles = await files['styles.css']; + main = await files['main.js']; + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + + // Check with only base-href flag. + files = ( + await browserBuild(architect, host, target, { + baseHref: '/base/', + }) + ).files; + + styles = await files['styles.css']; + main = await files['main.js']; + expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); + expect(main).toContain(`url('/assets/component-img-absolute.svg')`); + // NOTE: Timeout for large amount of builds in test. Test should be split up when refactored. + }, + 4 * 60 * 1000, + ); + + it(`supports inline javascript in less`, async () => { + const overrides = { styles: [`src/styles.less`] }; + host.writeMultipleFiles({ + 'src/styles.less': ` + .myFunction() { + @functions: ~\`(function () { + return ''; + })()\`; + } + .myFunction(); + `, + }); + + await browserBuild(architect, host, target, overrides); + }); + + it('causes equal failure for tilde url()', async () => { + host.writeMultipleFiles({ + 'src/styles.css': ` + body { + background-image: url('~does-not-exist.jpg'); + } + `, + }); + + const run = await architect.scheduleTarget(target, { optimization: true }); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: false })); + await run.stop(); + }); + + it('causes equal failure for tilde-slash url()', async () => { + host.writeMultipleFiles({ + 'src/styles.css': ` + body { + background-image: url('~/does-not-exist.jpg'); + } + `, + }); + + const run = await architect.scheduleTarget(target, { optimization: true }); + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: false })); + await run.stop(); + }); + + it('supports Protocol-relative Url', async () => { + host.writeMultipleFiles({ + 'src/styles.css': ` + body { + background-image: url('//cdn.com/classic-bg.jpg'); + } + `, + }); + + const overrides = { optimization: true }; + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['styles.css']).toContain('background-image:url(//cdn.com/classic-bg.jpg)'); + }); + + it('supports fonts with space in filename', async () => { + host.writeMultipleFiles({ + 'src/styles.css': ` + @font-face { + font-family: "Font Awesome"; + src: url("./assets/fa solid-900.woff2") format("woff2"); + } + + body { + font-family: "Font Awesome"; + } + `, + 'src/assets/fa solid-900.woff2': '', + }); + + const { output } = await browserBuild(architect, host, target); + expect(output.success).toBe(true); + }); + + extensionsWithImportSupport.forEach((ext) => { + it(`retains declarations order in ${ext} files when using @import`, async () => { + host.writeMultipleFiles({ + [`src/styles-one.${ext}`]: tags.stripIndents` + .one { + color: #fff; + } + `, + [`src/styles-two.${ext}`]: tags.stripIndents` + .two { + color: #fff; + } + `, + // LESS doesn't support css imports by default. + // See: https://github.com/less/less.js/issues/3188#issuecomment-374690630 + [`src/styles-three.${ext}`]: tags.stripIndents` + @import ${ + ext === 'less' ? ' (css) ' : '' + }url("https://fonts.googleapis.com/css?family=Roboto:400"); + .three { + color: #fff; + } + `, + }); + + const overrides = { + styles: [`src/styles-one.${ext}`, `src/styles-two.${ext}`, `src/styles-three.${ext}`], + }; + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['styles.css']).toMatch(/\.one(.|\n|\r)*\.two(.|\n|\r)*\.three/); + }); + + it(`adjusts relative resource URLs when using @import in ${ext} (global)`, async () => { + host.copyFile('src/spectrum.png', './src/more-styles/images/global-img-relative.png'); + host.writeMultipleFiles({ + [`src/styles-one.${ext}`]: tags.stripIndents` + @import "more-styles/styles-two.${ext}"; + `, + [`src/more-styles/styles-two.${ext}`]: tags.stripIndents` + .two { + background-image: url(images/global-img-relative.png); + } + `, + }); + + const overrides = { + sourceMap: false, + styles: [`src/styles-one.${ext}`], + }; + const { files } = await browserBuild(architect, host, target, overrides); + expect(await files['styles.css']).toContain("'global-img-relative.png'"); + }); + + it(`adjusts relative resource URLs when using @import in ${ext} (component)`, async () => { + host.copyFile('src/spectrum.png', './src/app/images/component-img-relative.png'); + host.writeMultipleFiles({ + [`src/app/styles/component-styles.${ext}`]: ` + div { background-image: url(../images/component-img-relative.png); } + `, + [`src/app/app.component.${ext}`]: ` + @import "styles/component-styles.${ext}"; + `, + }); + + host.replaceInFile( + 'src/app/app.component.ts', + './app.component.css', + `./app.component.${ext}`, + ); + + const overrides = { + sourceMap: false, + }; + await browserBuild(architect, host, target, overrides); + }); + }); + + it('should minify colors based on browser support', async () => { + host.writeMultipleFiles({ + 'src/styles.css': ` + div { box-shadow: 0 3px 10px, rgba(0, 0, 0, 0.15); } + `, + }); + + let result = await browserBuild(architect, host, target, { optimization: true }); + expect(await result.files['styles.css']).toContain('#00000026'); + + await host.restore().toPromise(); + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + // Edge 17 doesn't support #rrggbbaa colors + // https://github.com/angular/angular-cli/issues/21594#:~:text=%23rrggbbaa%20hex%20color%20notation + // While this browser is un-supported, this is used as a base case to test that the underlying + // logic to pass the list of supported browsers to the css optimizer works. + host.writeMultipleFiles({ + 'src/styles.css': ` + div { box-shadow: 0 3px 10px, rgba(0, 0, 0, 0.15); } + `, + '.browserslistrc': `edge 18`, + }); + + result = await browserBuild(architect, host, target, { optimization: true }); + + expect(await result.files['styles.css']).toContain('rgba(0,0,0,.15)'); + }, 80_000); + + it('works when using the same css file in `styles` and `stylesUrl`', async () => { + host.writeMultipleFiles({ + 'src/styles.css': ` + div { color: red } + `, + './src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styleUrls: ['../styles.css'] + }) + export class AppComponent { + title = 'app'; + } + `, + }); + + await browserBuild(architect, host, target, { styles: ['src/styles.css'] }); + }); + + it('works when Data URI has escaped quote', async () => { + const svgData = `"data:image/svg+xml;charset=utf-8,"`; + + host.writeMultipleFiles({ + 'src/styles.css': ` + div { background: url(${svgData}) } + `, + }); + + const result = await browserBuild(architect, host, target, { styles: ['src/styles.css'] }); + expect(await result.files['styles.css']).toContain(svgData); + }); + + it('works when Data URI has parenthesis', async () => { + const svgData = + '"data:image/svg+xml;charset=utf-8,' + + `` + + '"'; + + host.writeMultipleFiles({ + 'src/styles.css': ` + div { background: url(${svgData}) } + `, + }); + + const result = await browserBuild(architect, host, target, { styles: ['src/styles.css'] }); + expect(await result.files['styles.css']).toContain(svgData); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/svg_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/svg_spec.ts new file mode 100644 index 000000000000..4fe80e923b2e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/svg_spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, normalize } from '@angular-devkit/core'; +import { createArchitect, host, outputPath } from '../../../testing/test-utils'; + +describe('Browser Builder allow svg', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works with aot', async () => { + const svg = ` + + Hello World + `; + + host.writeMultipleFiles({ + './src/app/app.component.svg': svg, + './src/app/app.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.svg', + styleUrls: [] + }) + export class AppComponent { + title = 'app'; + } + `, + }); + + const overrides = { aot: true }; + + const run = await architect.scheduleTarget(target, overrides); + + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + const exists = host.scopedSync().exists(join(outputPath, 'main.js')); + expect(exists).toBe(true, '"main.js" should exist'); + + if (exists) { + const content = new TextDecoder().decode(host.scopedSync().read(join(outputPath, 'main.js'))); + + // Verify that the svg contents are present in the main bundle, + // e.g. as template instructions. + expect(content).toContain('Hello World'); + + // Verify that the svg contents are *not* present as a separate file. + expect(host.scopedSync().exists(normalize('dist/app.component.svg'))).toBe( + false, + 'should not copy app.component.svg to dist', + ); + } + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/tsconfig-paths_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/tsconfig-paths_spec.ts new file mode 100644 index 000000000000..b097b7c078a0 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/tsconfig-paths_spec.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { normalize } from '@angular-devkit/core'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder tsconfig paths', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('should resolve basic tsconfig paths', async () => { + host.replaceInFile('src/app/app.module.ts', './app.component', '@root/app/app.component'); + + const tsconfigPath = normalize('tsconfig.json'); + const tsconfig = JSON.parse(new TextDecoder().decode(host.scopedSync().read(tsconfigPath))); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '@root/*': ['./src/*'], + }; + host + .scopedSync() + .write(tsconfigPath, new TextEncoder().encode(JSON.stringify(tsconfig, null, 2)).buffer); + + await browserBuild(architect, host, target); + }); + + it('should resolve complex tsconfig paths with wildcards', async () => { + host.writeMultipleFiles({ + 'src/meaning-too.ts': 'export var meaning = 42;', + 'src/app/shared/meaning.ts': 'export var meaning = 42;', + 'src/app/shared/index.ts': `export * from './meaning'`, + }); + const tsconfigPath = normalize('tsconfig.json'); + const tsconfig = JSON.parse(new TextDecoder().decode(host.scopedSync().read(tsconfigPath))); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '@shared': ['./src/app/shared'], + '@shared/*': ['./src/app/shared/*'], + '*': ['./*', './src/app/shared/*'], + }; + host + .scopedSync() + .write(tsconfigPath, new TextEncoder().encode(JSON.stringify(tsconfig, null, 2)).buffer); + + host.appendToFile( + 'src/app/app.component.ts', + ` + import { meaning } from 'src/app/shared/meaning'; + import { meaning as meaning2 } from '@shared'; + import { meaning as meaning3 } from '@shared/meaning'; + import { meaning as meaning4 } from 'meaning'; + import { meaning as meaning5 } from 'src/meaning-too'; + + // need to use imports otherwise they are ignored and + // no error is outputted, even if baseUrl/paths don't work + console.log(meaning) + console.log(meaning2) + console.log(meaning3) + console.log(meaning4) + console.log(meaning5) + `, + ); + + await browserBuild(architect, host, target); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/unused-files-warning_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/unused-files-warning_spec.ts new file mode 100644 index 000000000000..fc66978d8114 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/unused-files-warning_spec.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { logging } from '@angular-devkit/core'; +import { debounceTime, take, tap } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { BrowserBuilderOutput } from '../index'; + +describe('Browser Builder unused files warnings', () => { + const warningMessageSuffix = `is part of the TypeScript compilation but it's unused`; + const targetSpec = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('should not show warning when all files are used', async () => { + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, undefined, { logger }); + const output = await run.result; + expect(output.success).toBe(true); + expect(logs.join().includes(warningMessageSuffix)).toBe(false); + + await run.stop(); + }); + + it('should show warning when some files are unused', async () => { + host.writeMultipleFiles({ + 'src/unused-file.ts': `export const unused = '1';`, + }); + + host.replaceInFile('src/tsconfig.app.json', '"main.ts"', '"main.ts", "unused-file.ts"'); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, undefined, { logger }); + const output = await run.result; + expect(output.success).toBe(true); + expect(logs.join().includes(`unused-file.ts ${warningMessageSuffix}`)).toBe(true); + + await run.stop(); + }); + + it('should not show warning when excluded files are unused', async () => { + const ignoredFiles = { + 'src/file.d.ts': 'export type MyType = number;', + }; + + host.writeMultipleFiles(ignoredFiles); + + host.replaceInFile( + 'src/tsconfig.app.json', + '"main.ts"', + `"main.ts", ${Object.keys(ignoredFiles) + .map((f) => `"${f.replace('src/', '')}"`) + .join(',')}`, + ); + + host.replaceInFile( + 'src/tsconfig.app.json', + '"compilerOptions":', + '"angularCompilerOptions": { "strictTemplates": true }, "compilerOptions":', + ); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, { aot: true }, { logger }); + const output = await run.result; + expect(output.success).toBe(true); + expect(logs.join().includes(warningMessageSuffix)).toBe(false); + + await run.stop(); + }); + + it('should not show warning when type files are used', async () => { + host.writeMultipleFiles({ + 'src/app/type.ts': 'export type MyType = number;', + }); + + host.replaceInFile( + 'src/app/app.component.ts', + `'@angular/core';`, + `'@angular/core';\nimport { MyType } from './type';\n`, + ); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, undefined, { logger }); + const output = await run.result; + expect(output.success).toBe(true); + expect(logs.join().includes(warningMessageSuffix)).toBe(false); + + await run.stop(); + }); + + it('should not show warning when type files are used transitively', async () => { + host.writeMultipleFiles({ + 'src/app/type.ts': `import {Myinterface} from './interface'; export type MyType = Myinterface;`, + 'src/app/interface.ts': 'export interface Myinterface {nbr: number;}', + }); + + host.replaceInFile( + 'src/app/app.component.ts', + `'@angular/core';`, + `'@angular/core';\nimport { MyType } from './type';\n`, + ); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const run = await architect.scheduleTarget(targetSpec, undefined, { logger }); + const output = await run.result; + expect(output.success).toBe(true); + expect(logs.join().includes(warningMessageSuffix)).toBe(false); + + await run.stop(); + }); + + it('works for rebuilds', async () => { + host.replaceInFile('src/tsconfig.app.json', '"**/*.d.ts"', '"**/*.d.ts", "testing/**/*.ts"'); + + const logger = new logging.Logger(''); + let logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + let buildNumber = 0; + const run = await architect.scheduleTarget(targetSpec, { watch: true }, { logger }); + + await run.output + .pipe( + debounceTime(1000), + tap((buildEvent) => { + expect(buildEvent.success).toBe(true); + + buildNumber++; + switch (buildNumber) { + case 1: + // The first should not have unused files + expect(logs.join().includes(warningMessageSuffix)).toBe( + false, + `Case ${buildNumber} failed.`, + ); + + // Write a used file + host.writeMultipleFiles({ + 'src/testing/type.ts': 'export type MyType = number;', + }); + + // touch file to trigger build + host.replaceInFile( + 'src/app/app.component.ts', + `'@angular/core';`, + `'@angular/core';\n`, + ); + break; + + case 2: + // The second should have type.ts as unused + expect(logs.join().includes(`type.ts ${warningMessageSuffix}`)).toBe( + true, + `Case ${buildNumber} failed.`, + ); + + host.replaceInFile( + 'src/app/app.component.ts', + `'@angular/core';`, + `'@angular/core';\nimport { MyType } from '../testing/type';`, + ); + break; + + case 3: + // The third should not have any unused files + expect(logs.join().includes(warningMessageSuffix)).toBe( + false, + `Case ${buildNumber} failed.`, + ); + break; + } + + logs = []; + }), + take(3), + ) + .toPromise(); + await run.stop(); + }); + + it('should only show warning once per file', async () => { + host.replaceInFile('src/tsconfig.app.json', '"**/*.d.ts"', '"**/*.d.ts", "testing/**/*.ts"'); + + // Write a used file + host.writeMultipleFiles({ + 'src/testing/type.ts': 'export type MyType = number;', + }); + + const logger = new logging.Logger(''); + let logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + let buildNumber = 0; + const run = await architect.scheduleTarget(targetSpec, { watch: true }, { logger }); + + await run.output + .pipe( + debounceTime(1000), + tap((buildEvent) => { + expect(buildEvent.success).toBe(true); + + buildNumber++; + switch (buildNumber) { + case 1: + // The first should have type.ts as unused. + expect(logs.join().includes(`type.ts ${warningMessageSuffix}`)).toBe( + true, + `Case ${buildNumber} failed.`, + ); + + // touch a file to trigger a rebuild + host.appendToFile('src/main.ts', ''); + break; + case 2: + // The second should have type.ts as unused but shouldn't warn. + expect(logs.join().includes(warningMessageSuffix)).toBe( + false, + `Case ${buildNumber} failed.`, + ); + break; + } + + logs = []; + }), + take(2), + ) + .toPromise(); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/vendor-chunk_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/vendor-chunk_spec.ts new file mode 100644 index 000000000000..8c6e9db94550 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/vendor-chunk_spec.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +describe('Browser Builder vendor chunk', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const { files } = await browserBuild(architect, host, target, { vendorChunk: true }); + expect('vendor.js' in files).toBe(true); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/vendor-source-map_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/vendor-source-map_spec.ts new file mode 100644 index 000000000000..e1e8a1f86bd4 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/vendor-source-map_spec.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import * as path from 'node:path'; +import { browserBuild, createArchitect, host } from '../../../testing/test-utils'; + +// Following the naming conventions from +// https://sourcemaps.info/spec.html#h.ghqpj1ytqjbm +const IGNORE_LIST = 'x_google_ignoreList'; + +describe('Browser Builder external source map', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + const overrides = { + sourceMap: { + scripts: true, + styles: true, + vendor: true, + }, + }; + + const { files } = await browserBuild(architect, host, target, overrides); + const sourcePaths: string[] = JSON.parse(await files['vendor.js.map']).sources; + const hasTsSourcePaths = sourcePaths.some((p) => path.extname(p) == '.ts'); + expect(hasTsSourcePaths).toBe(true, `vendor.js.map should have '.ts' extentions`); + }); + + it('does not map sourcemaps from external library when disabled', async () => { + const overrides = { + sourceMap: { + scripts: true, + styles: true, + vendor: false, + }, + }; + + const { files } = await browserBuild(architect, host, target, overrides); + const sourcePaths: string[] = JSON.parse(await files['vendor.js.map']).sources; + const hasTsSourcePaths = sourcePaths.some((p) => path.extname(p) == '.ts'); + expect(hasTsSourcePaths).toBe(false, `vendor.js.map not should have '.ts' extentions`); + }); +}); + +describe('Identifying third-party code in source maps', () => { + interface SourceMap { + sources: string[]; + [IGNORE_LIST]: number[]; + } + + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + it('specifies which sources are third party when vendor processing is disabled', async () => { + const overrides = { + sourceMap: { + scripts: true, + vendor: false, + }, + }; + + const { files } = await browserBuild(architect, host, target, overrides); + const mainMap: SourceMap = JSON.parse(await files['main.js.map']); + const polyfillsMap: SourceMap = JSON.parse(await files['polyfills.js.map']); + const runtimeMap: SourceMap = JSON.parse(await files['runtime.js.map']); + const vendorMap: SourceMap = JSON.parse(await files['vendor.js.map']); + + expect(mainMap[IGNORE_LIST]).not.toBeUndefined(); + expect(polyfillsMap[IGNORE_LIST]).not.toBeUndefined(); + expect(runtimeMap[IGNORE_LIST]).not.toBeUndefined(); + expect(vendorMap[IGNORE_LIST]).not.toBeUndefined(); + + expect(mainMap[IGNORE_LIST].length).toEqual(0); + expect(polyfillsMap[IGNORE_LIST].length).not.toEqual(0); + expect(runtimeMap[IGNORE_LIST].length).not.toEqual(0); + expect(vendorMap[IGNORE_LIST].length).not.toEqual(0); + + const thirdPartyInMain = mainMap.sources.some((s) => s.includes('node_modules')); + const thirdPartyInPolyfills = polyfillsMap.sources.some((s) => s.includes('node_modules')); + const thirdPartyInRuntime = runtimeMap.sources.some((s) => s.includes('webpack')); + const thirdPartyInVendor = vendorMap.sources.some((s) => s.includes('node_modules')); + expect(thirdPartyInMain).toBe(false, `main.js.map should not include any node modules`); + expect(thirdPartyInPolyfills).toBe(true, `polyfills.js.map should include some node modules`); + expect(thirdPartyInRuntime).toBe(true, `runtime.js.map should include some webpack code`); + expect(thirdPartyInVendor).toBe(true, `vendor.js.map should include some node modules`); + + // All sources in the main map are first-party. + const sources = mainMap.sources.filter((_, i) => !mainMap[IGNORE_LIST].includes(i)); + sources.sort(); + + expect(sources).toEqual([ + './src/app/app.component.css', + './src/app/app.component.ts', + './src/app/app.module.ts', + './src/main.ts', + ]); + + // Only some sources in the polyfills map are first-party. + expect(polyfillsMap.sources.filter((_, i) => !polyfillsMap[IGNORE_LIST].includes(i))).toEqual([ + './src/polyfills.ts', + ]); + + // None of the sources in the runtime and vendor maps are first-party. + expect(runtimeMap.sources.filter((_, i) => !runtimeMap[IGNORE_LIST].includes(i))).toEqual([]); + expect(vendorMap.sources.filter((_, i) => !vendorMap[IGNORE_LIST].includes(i))).toEqual([]); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/web-worker_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/web-worker_spec.ts new file mode 100644 index 000000000000..65991dbcc42d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/web-worker_spec.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { join, logging } from '@angular-devkit/core'; +import { debounceTime, lastValueFrom, map, switchMap, takeWhile, tap, timer } from 'rxjs'; +import { browserBuild, createArchitect, host, outputPath } from '../../../testing/test-utils'; + +describe('Browser Builder Web Worker support', () => { + const target = { project: 'app', target: 'build' }; + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + const workerFiles: { [k: string]: string } = { + 'src/app/dep.ts': `export const foo = 'bar';`, + 'src/app/app.worker.ts': ` + /// + + import { foo } from './dep'; + console.log('hello from worker'); + addEventListener('message', ({ data }) => { + console.log('worker got message:', data); + if (data === 'hello') { + postMessage(foo); + } + }); + `, + 'src/main.ts': ` + import { platformBrowser } from '@angular/platform-browser'; + import { AppModule } from './app/app.module'; + platformBrowser().bootstrapModule(AppModule).catch(err => console.error(err)); + + const worker = new Worker(new URL('./app/app.worker', import.meta.url), { type: 'module' }); + worker.onmessage = ({ data }) => { + console.log('page got message:', data); + }; + worker.postMessage('hello'); + `, + // Make a new tsconfig for the *.worker.ts files. + // The final place for this tsconfig must take into consideration editor tooling, unit + // tests, and integration with other build targets. + './src/tsconfig.worker.json': ` + { + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "**/*.worker.ts", + ] + }`, + // Alter the app tsconfig to not include *.worker.ts files. + './src/tsconfig.app.json': ` + { + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/worker", + "types": [] + }, + "files": [ + "main.ts", + "polyfills.ts" + ] + }`, + }; + + it('bundles TS worker', async () => { + host.writeMultipleFiles(workerFiles); + + const logger = new logging.Logger(''); + const logs: string[] = []; + logger.subscribe((e) => logs.push(e.message)); + + const overrides = { webWorkerTsConfig: 'src/tsconfig.worker.json' }; + await browserBuild(architect, host, target, overrides, { logger }); + + // Worker bundle contains worker code. + const workerContent = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, 'src_app_app_worker_ts.js')), + ); + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + + // Main bundle references worker. + const mainContent = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, 'main.js')), + ); + expect(mainContent).toContain('src_app_app_worker_ts'); + expect(logs.join().includes('Warning')).toBe(false, 'Should show no warnings.'); + }); + + it('minimizes and hashes worker', async () => { + host.writeMultipleFiles(workerFiles); + const overrides = { + webWorkerTsConfig: 'src/tsconfig.worker.json', + outputHashing: 'all', + optimization: true, + }; + await browserBuild(architect, host, target, overrides); + + // Worker bundle should have hash and minified code. + const workerBundle = host.fileMatchExists( + outputPath, + /src_app_app_worker_ts\.[0-9a-f]{16}\.js/, + ) as string; + expect(workerBundle).toBeTruthy('workerBundle should exist'); + const workerContent = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, workerBundle)), + ); + expect(workerContent).toContain('hello from worker'); + expect(workerContent).toContain('bar'); + expect(workerContent).toContain('"hello"===e&&postMessage'); + + // Main bundle should reference hashed worker bundle. + const mainBundle = host.fileMatchExists(outputPath, /main\.[0-9a-f]{16}\.js/) as string; + expect(mainBundle).toBeTruthy('mainBundle should exist'); + const mainContent = new TextDecoder().decode( + host.scopedSync().read(join(outputPath, mainBundle)), + ); + expect(mainContent).toContain('src_app_app_worker_ts'); + }); + + it('rebuilds TS worker', async () => { + host.writeMultipleFiles(workerFiles); + const overrides = { + webWorkerTsConfig: 'src/tsconfig.worker.json', + watch: true, + }; + + let phase = 1; + const workerPath = join(outputPath, 'src_app_app_worker_ts.js'); + let workerContent = ''; + + // The current linux-based CI environments may not fully settled in regards to filesystem + // changes from previous tests which reuse the same directory and fileset. + // The initial delay helps mitigate false positive rebuild triggers in such scenarios. + const { run } = await lastValueFrom( + timer(1000).pipe( + switchMap(() => architect.scheduleTarget(target, overrides)), + switchMap((run) => run.output.pipe(map((output) => ({ run, output })))), + debounceTime(1000), + tap(({ output }) => expect(output.success).toBe(true, 'build should succeed')), + tap(() => { + switch (phase) { + case 1: + // Original worker content should be there. + workerContent = new TextDecoder().decode(host.scopedSync().read(workerPath)); + expect(workerContent).toContain('bar'); + // Change content of worker dependency. + host.writeMultipleFiles({ 'src/app/dep.ts': `export const foo = 'baz';` }); + phase = 2; + break; + + case 2: + workerContent = new TextDecoder().decode(host.scopedSync().read(workerPath)); + // Worker content should have changed. + expect(workerContent).toContain('baz'); + phase = 3; + break; + } + }), + takeWhile(() => phase < 3), + ), + ); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/works_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/works_spec.ts new file mode 100644 index 000000000000..d5315b1b6257 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/works_spec.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { describeBuilder } from '../../../testing'; +import { buildWebpackBrowser } from '../index'; + +const BROWSER_BUILDER_INFO = { + name: '@angular-devkit/build-angular:browser', + schemaPath: __dirname + '/../schema.json', +}; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('basic test', () => { + it('works', async () => { + // Provide a target and options for builder execution + harness.useTarget('build', { + outputPath: 'dist', + index: 'src/index.html', + main: 'src/main.ts', + polyfills: 'src/polyfills.ts', + tsConfig: 'src/tsconfig.app.json', + progress: false, + vendorChunk: true, + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + scripts: [], + }); + + // Execute builder with above provided project, target, and options + await harness.executeOnce(); + + // Default files should be in outputPath. + expect(harness.hasFile('dist/runtime.js')).toBeTrue(); + expect(harness.hasFile('dist/main.js')).toBeTrue(); + expect(harness.hasFile('dist/polyfills.js')).toBeTrue(); + expect(harness.hasFile('dist/vendor.js')).toBeTrue(); + expect(harness.hasFile('dist/favicon.ico')).toBeTrue(); + expect(harness.hasFile('dist/styles.css')).toBeTrue(); + expect(harness.hasFile('dist/index.html')).toBeTrue(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts new file mode 100644 index 000000000000..f24c83f2d96b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Behavior: "Browser support"', () => { + it('creates correct sourcemaps when downleveling async functions', async () => { + // Add a JavaScript file with async code + await harness.writeFile( + 'src/async-test.js', + 'async function testJs() { console.log("from-async-js-function"); }', + ); + + // Add an async function to the project as well as JavaScript file + // The type `Void123` is used as a unique identifier for the final sourcemap + // If sourcemaps are not properly propagated then it will not be in the final sourcemap + await harness.modifyFile( + 'src/main.ts', + (content) => + 'import "./async-test";\n' + + content + + '\ntype Void123 = void;' + + `\nasync function testApp(): Promise { console.log("from-async-app-function"); }`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + vendorChunk: true, + sourceMap: { + scripts: true, + }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.not.toMatch(/\sasync\s/); + harness.expectFile('dist/main.js.map').content.toContain('Promise'); + }); + + it('downlevels async functions ', async () => { + // Add an async function to the project + await harness.writeFile( + 'src/main.ts', + 'async function test(): Promise { console.log("from-async-function"); }', + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + vendorChunk: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.not.toMatch(/\sasync\s/); + harness.expectFile('dist/main.js').content.toContain('"from-async-function"'); + }); + + it('warns when IE is present in browserslist', async () => { + await harness.writeFile( + '.browserslistrc', + ` + IE 9 + IE 11 + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringContaining('ES5 output is not supported'), + }), + ); + + // Don't duplicate the error. + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining("fall outside Angular's browser support"), + }), + ); + }); + + it("warns when targeting a browser outside Angular's minimum support", async () => { + await harness.writeFile('.browserslistrc', 'Chrome >= 100'); + + harness.useTarget('build', BASE_OPTIONS); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringContaining("fall outside Angular's browser support"), + }), + ); + }); + + it('downlevels "for await...of"', async () => { + // Add an async function to the project + await harness.writeFile( + 'src/main.ts', + ` + (async () => { + for await (const o of [1, 2, 3]) { + console.log("for await...of"); + } + })(); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + vendorChunk: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.not.toMatch(/\sawait\s/); + harness.expectFile('dist/main.js').content.toContain('"for await...of"'); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/index_watch_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/index_watch_spec.ts new file mode 100644 index 000000000000..a0b0b7fadb26 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/index_watch_spec.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Behavior: "index is updated during watch mode"', () => { + it('index is watched in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/index.html').content.toContain('HelloWorldApp'); + harness.expectFile('dist/index.html').content.not.toContain('UpdatedPageTitle'); + + // Trigger rebuild + await harness.modifyFile('src/index.html', (s) => + s.replace('HelloWorldApp', 'UpdatedPageTitle'), + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/index.html').content.toContain('UpdatedPageTitle'); + }, + ]); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/localize_watch_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/localize_watch_spec.ts new file mode 100644 index 000000000000..2c39bf738b3a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/localize_watch_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Behavior: "localize works in watch mode"', () => { + beforeEach(() => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + locales: { + 'fr': 'src/locales/messages.fr.xlf', + }, + }, + }); + }); + + it('localize works in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + localize: true, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', TRANSLATION_FILE_CONTENT); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/fr/main.js').content.toContain('Bonjour'); + + // Trigger rebuild + await harness.appendToFile('src/app/app.component.html', '\n\n'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/fr/main.js').content.toContain('Bonjour'); + }, + ]); + }); + }); +}); + +const TRANSLATION_FILE_CONTENT = ` + + + + + + Bonjour ! + + src/app/app.component.html + 2,3 + + An introduction header for this sample + + + + +`; diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/postcss_config_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/postcss_config_spec.ts new file mode 100644 index 000000000000..0ed245f18b44 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/postcss_config_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Behavior: "Postcss config file"', () => { + it('applies plugins from config file if one is present', async () => { + // See: + // https://github.com/postcss/postcss-plugin-boilerplate/blob/main/template/index.t.js + await harness.writeFile( + 'node_modules/my-postcss-plugin/index.cjs', + `module.exports = (opts = {}) => { + return { + postcssPlugin: 'my-postcss-plugin', + + Root(root, postcss) { + const newRule = new postcss.Rule({ + selector: '.my-postcss-plugin::before', + }); + newRule.append({ text: 'content: "from applied plugin";' }); + root.append(newRule); + }, + }; +}; + +module.exports.postcss = true; +`, + ); + + await harness.writeFile( + 'postcss.config.json', + JSON.stringify({ + plugins: { + 'my-postcss-plugin/index.cjs': {}, + }, + }), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/styles.css').content.toMatch(/from applied plugin/); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/rebuild-errors_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/rebuild-errors_spec.ts new file mode 100644 index 000000000000..26cbe1144d2b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/rebuild-errors_spec.ts @@ -0,0 +1,311 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuild Error"', () => { + it('detects template errors with no AOT codegen or TS emit differences', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + aot: true, + watch: true, + }); + + const goodDirectiveContents = ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo!: number; + } + `; + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + // Create a directive and add to application + await harness.writeFile('src/app/dir.ts', goodDirectiveContents); + await harness.writeFile( + 'src/app/app.module.ts', + ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { Dir } from './dir'; + @NgModule({ + declarations: [ + AppComponent, + Dir, + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + ); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '', + }) + export class AppComponent { } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update directive to use a different input type for 'foo' (number -> string) + // Should cause a template error + await harness.writeFile( + 'src/app/dir.ts', + ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + // Revert the directive change that caused the error + // Should remove the error + await harness.writeFile('src/app/dir.ts', goodDirectiveContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + // Make an unrelated change to verify error cache was updated + // Should continue showing no error + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects template errors with AOT codegen differences', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + aot: true, + watch: true, + }); + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + // Create two directives and add to application + await harness.writeFile( + 'src/app/dir.ts', + ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo!: number; + } + `, + ); + + // Same selector with a different type on the `foo` property but initially no `@Input` + const goodDirectiveContents = ` + import { Directive } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir2 { + foo!: string; + } + `; + await harness.writeFile('src/app/dir2.ts', goodDirectiveContents); + + await harness.writeFile( + 'src/app/app.module.ts', + ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { Dir } from './dir'; + import { Dir2 } from './dir2'; + @NgModule({ + declarations: [ + AppComponent, + Dir, + Dir2, + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + ); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '', + }) + export class AppComponent { } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update second directive to use string property `foo` as an Input + // Should cause a template error + await harness.writeFile( + 'src/app/dir2.ts', + ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir2 { + @Input() foo!: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + // Revert the directive change that caused the error + // Should remove the error + await harness.writeFile('src/app/dir2.ts', goodDirectiveContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + // Make an unrelated change to verify error cache was updated + // Should continue showing no error + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('recovers from component stylesheet error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot: false, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + await harness.writeFile('src/app/app.component.css', 'invalid-css-content'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('invalid-css-content'), + }), + ); + + await harness.writeFile('src/app/app.component.css', 'p { color: green }'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('invalid-css-content'), + }), + ); + + harness.expectFile('dist/main.js').content.toContain('p { color: green }'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/styles_unsupported_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/styles_unsupported_spec.ts new file mode 100644 index 000000000000..3df11cafb163 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/styles_unsupported_spec.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Behavior: "Style Unsupported"', () => { + it('errors when importing a css file as an ECMA module (Webpack specific behaviour)', async () => { + await harness.writeFiles({ + 'src/test-style.css': '.test-a {color: red}', + 'src/main.ts': `import './test-style.css'`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('./src/test-style.css:1:0 - Error'), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/allowed-common-js-dependencies_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/allowed-common-js-dependencies_spec.ts new file mode 100644 index 000000000000..b0e89583bb63 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/allowed-common-js-dependencies_spec.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "allowedCommonJsDependencies"', () => { + describe('given option is not set', () => { + for (const aot of [true, false]) { + it(`should show warning when depending on a Common JS bundle in ${ + aot ? 'AOT' : 'JIT' + } Mode`, async () => { + // Add a Common JS dependency + await harness.appendToFile('src/app/app.component.ts', `import 'ajv';`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + aot, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + /Warning: .+app\.component\.ts depends on 'ajv'\. CommonJS or AMD dependencies/, + ), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('require-from-string'), + }), + 'Should not warn on transitive CommonJS packages which parent is also CommonJS.', + ); + }); + } + }); + + it('should not show warning when depending on a Common JS bundle which is allowed', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'ajv'; + import 'zone.js'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['ajv', 'zone.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + + it('should not show warning when all dependencies are allowed by wildcard', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'ajv'; + import 'zone.js'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['*'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + + it(`should not show warning when importing non global local data '@angular/common/locale/fr'`, async () => { + await harness.appendToFile( + 'src/app/app.component.ts', + `import '@angular/common/locales/fr';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + + it('should not show warning in JIT for templateUrl and styleUrl when using paths', async () => { + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '@app/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.modifyFile('src/app/app.module.ts', (content) => + content.replace('./app.component', '@app/app.component'), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + aot: false, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts new file mode 100644 index 000000000000..c7ac266cf036 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/assets_spec.ts @@ -0,0 +1,402 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "assets"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', ''); + }); + + it('supports an empty array value', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('supports mixing shorthand and longhand syntax', async () => { + await harness.writeFile('src/files/test.svg', ''); + await harness.writeFile('src/files/another.file', 'asset file'); + await harness.writeFile('src/extra.file', 'extra file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/extra.file', { glob: '*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.file').content.toBe('extra file'); + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + describe('shorthand syntax', () => { + it('copies a single asset', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('copies multiple assets', async () => { + await harness.writeFile('src/test.svg', ''); + await harness.writeFile('src/another.file', 'asset file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/test.svg', 'src/another.file'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies an asset with directory and maintains directory in output', async () => { + await harness.writeFile('src/subdirectory/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/subdirectory/test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); + }); + + it('does not fail if asset does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['src/test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + + it('fail if asset path is not within project source root', async () => { + await harness.writeFile('test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: ['test.svg'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.error).toMatch('path must start with the project source root'); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + }); + + describe('longhand syntax', () => { + it('copies a single asset', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('copies multiple assets as separate entries', async () => { + await harness.writeFile('src/test.svg', ''); + await harness.writeFile('src/another.file', 'asset file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { glob: 'test.svg', input: 'src', output: '.' }, + { glob: 'another.file', input: 'src', output: '.' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies multiple assets with a single entry glob pattern', async () => { + await harness.writeFile('src/test.svg', ''); + await harness.writeFile('src/another.file', 'asset file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '{test.svg,another.file}', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies multiple assets with a wildcard glob pattern', async () => { + await harness.writeFile('src/files/test.svg', ''); + await harness.writeFile('src/files/another.file', 'asset file'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + }); + + it('copies multiple assets with a recursive wildcard glob pattern', async () => { + await harness.writeFiles({ + 'src/files/test.svg': '', + 'src/files/another.file': 'asset file', + 'src/files/nested/extra.file': 'extra file', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').content.toBe('asset file'); + harness.expectFile('dist/nested/extra.file').content.toBe('extra file'); + }); + + it('automatically ignores "." prefixed files when using wildcard glob pattern', async () => { + await harness.writeFile('src/files/.gitkeep', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '*', input: 'src/files', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/.gitkeep').toNotExist(); + }); + + it('supports ignoring a specific file when using a glob pattern', async () => { + await harness.writeFiles({ + 'src/files/test.svg': '', + 'src/files/another.file': 'asset file', + 'src/files/nested/extra.file': 'extra file', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/files', output: '.', ignore: ['another.file'] }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').toNotExist(); + harness.expectFile('dist/nested/extra.file').content.toBe('extra file'); + }); + + it('supports ignoring with a glob pattern when using a glob pattern', async () => { + await harness.writeFiles({ + 'src/files/test.svg': '', + 'src/files/another.file': 'asset file', + 'src/files/nested/extra.file': 'extra file', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: 'src/files', output: '.', ignore: ['**/*.file'] }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + harness.expectFile('dist/another.file').toNotExist(); + harness.expectFile('dist/nested/extra.file').toNotExist(); + }); + + it('copies an asset with directory and maintains directory in output', async () => { + await harness.writeFile('src/subdirectory/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'subdirectory/test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); + }); + + it('does not fail if asset does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + + it('uses project output path when output option is empty string', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('uses project output path when output option is "."', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('uses project output path when output option is "/"', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '/' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').content.toBe(''); + }); + + it('creates a project output sub-path when output option path does not exist', async () => { + await harness.writeFile('src/test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: 'subdirectory' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/subdirectory/test.svg').content.toBe(''); + }); + + it('fails if asset input option is outside workspace root (relative)', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: '../outside', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.error).toMatch('asset path must be within the workspace root'); + }); + + it('fails if asset input option is outside workspace root (absolute)', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: '**/*', input: '/tmp/outside-workspace', output: '.' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.error).toMatch('asset path must be within the workspace root'); + }); + + it('fails if output option is not within project output path', async () => { + await harness.writeFile('test.svg', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [{ glob: 'test.svg', input: 'src', output: '..' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.error).toMatch( + 'An asset cannot be written to a location outside of the output path', + ); + + harness.expectFile('dist/test.svg').toNotExist(); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/bundle-budgets_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/bundle-budgets_spec.ts new file mode 100644 index 000000000000..309cdfca1b2b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/bundle-budgets_spec.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { lazyModuleFiles, lazyModuleFnImport } from '../../../../testing/test-utils'; +import { buildWebpackBrowser } from '../../index'; +import { Type } from '../../schema'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + const CSS_EXTENSIONS = ['css', 'scss', 'less']; + const BUDGET_NOT_MET_REGEXP = /Budget .+ was not met by/; + + describe('Option: "bundleBudgets"', () => { + it(`should not warn when size is below threshold`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + budgets: [{ type: Type.All, maximumWarning: '100mb' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + + it(`should error when size is above 'maximumError' threshold`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + budgets: [{ type: Type.All, maximumError: '100b' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + + it(`should warn when size is above 'maximumWarning' threshold`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + budgets: [{ type: Type.All, maximumWarning: '100b' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + + it(`should warn when lazy bundle is above 'maximumWarning' threshold`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + budgets: [{ type: Type.Bundle, name: 'lazy-lazy-module', maximumWarning: '100b' }], + }); + + await harness.writeFiles(lazyModuleFiles); + await harness.writeFiles(lazyModuleFnImport); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('lazy-lazy-module exceeded maximum budget'), + }), + ); + }); + + CSS_EXTENSIONS.forEach((ext) => { + it(`shows warnings for large component ${ext} when using 'anyComponentStyle' when AOT`, async () => { + const cssContent = ` + .foo { color: white; padding: 1px; } + .buz { color: white; padding: 2px; } + .bar { color: white; padding: 3px; } + `; + + await harness.writeFiles({ + [`src/app/app.component.${ext}`]: cssContent, + [`src/assets/foo.${ext}`]: cssContent, + [`src/styles.${ext}`]: cssContent, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', `app.component.${ext}`), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + aot: true, + styles: [`src/styles.${ext}`], + budgets: [{ type: Type.AnyComponentStyle, maximumWarning: '1b' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching(new RegExp(`Warning.+app.component.${ext}`)), + }), + ); + }); + }); + + describe(`should ignore '.map' files`, () => { + it(`when 'bundle' budget`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + optimization: true, + extractLicenses: true, + budgets: [{ type: Type.Bundle, name: 'main', maximumError: '1mb' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + + it(`when 'intial' budget`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + optimization: true, + extractLicenses: true, + budgets: [{ type: Type.Initial, name: 'main', maximumError: '1mb' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + + it(`when 'all' budget`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + optimization: true, + extractLicenses: true, + budgets: [{ type: Type.All, maximumError: '1mb' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + + it(`when 'any' budget`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + optimization: true, + extractLicenses: true, + budgets: [{ type: Type.Any, maximumError: '1mb' }], + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(BUDGET_NOT_MET_REGEXP), + }), + ); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/extract-licenses_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/extract-licenses_spec.ts new file mode 100644 index 000000000000..59b42eb82f06 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/extract-licenses_spec.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "extractLicenses"', () => { + it(`should generate '3rdpartylicenses.txt' when 'extractLicenses' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + }); + + it(`should not generate '3rdpartylicenses.txt' when 'extractLicenses' is false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/3rdpartylicenses.txt').toNotExist(); + }); + + it(`should generate '3rdpartylicenses.txt' when 'extractLicenses' is not set`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/inline-critical_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/inline-critical_spec.ts new file mode 100644 index 000000000000..678a30c670c7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/inline-critical_spec.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "inlineCritical"', () => { + beforeEach(async () => { + await harness.writeFile('src/styles.css', 'body { color: #000 }'); + }); + + it(`should extract critical css when 'inlineCritical' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: false, + styles: { + minify: true, + inlineCritical: true, + }, + fonts: false, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should extract critical css when 'optimization' is unset`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: undefined, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should extract critical css when 'optimization' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should not extract critical css when 'optimization' is false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/index.html').content.not.toContain(` { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: { + scripts: false, + styles: { + minify: false, + inlineCritical: false, + }, + fonts: false, + }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/index.html').content.not.toContain(` { + harness.useTarget('build', { + ...BASE_OPTIONS, + deployUrl: 'http://cdn.com/', + optimization: { + scripts: false, + styles: { + minify: true, + inlineCritical: true, + }, + fonts: false, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should extract critical css when using '@media all {}' and 'minify' is set to true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: { + scripts: false, + styles: { + minify: true, + inlineCritical: true, + }, + fonts: false, + }, + }); + + await harness.writeFile('src/styles.css', '@media all { body { color: #000 } }'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/index.html').content.toContain(`body{color:#000}`); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/inline-style-language_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/inline-style-language_spec.ts new file mode 100644 index 000000000000..eede6f2f8099 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/inline-style-language_spec.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { InlineStyleLanguage } from '../../schema'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "inlineStyleLanguage"', () => { + beforeEach(async () => { + // Setup application component with inline style property + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content + .replace('styleUrls', 'styles') + .replace('./app.component.css', '__STYLE_MARKER__'); + }); + }); + + for (const aot of [true, false]) { + describe(`[${aot ? 'AOT' : 'JIT'}]`, () => { + it('supports SCSS inline component styles when set to "scss"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Scss, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '$primary: indianred;\\nh1 { color: $primary; }'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.toContain('color: indianred'); + }); + + it('supports Sass inline component styles when set to "sass"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Sass, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '$primary: indianred\\nh1\\n\\tcolor: $primary'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.toContain('color: indianred'); + }); + + it('supports Less inline component styles when set to "less"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Less, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '@primary: indianred;\\nh1 { color: @primary; }'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.toContain('color: indianred'); + }); + + it('updates produced stylesheet in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.ts', + inlineStyleLanguage: InlineStyleLanguage.Scss, + aot, + watch: true, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '$primary: indianred;\\nh1 { color: $primary; }'), + ); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.toContain('color: indianred'); + harness.expectFile('dist/main.js').content.not.toContain('color: aqua'); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace( + '$primary: indianred;\\nh1 { color: $primary; }', + '$primary: aqua;\\nh1 { color: $primary; }', + ), + ); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.not.toContain('color: indianred'); + harness.expectFile('dist/main.js').content.toContain('color: aqua'); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace( + '$primary: aqua;\\nh1 { color: $primary; }', + '$primary: blue;\\nh1 { color: $primary; }', + ), + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.not.toContain('color: indianred'); + harness.expectFile('dist/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/main.js').content.toContain('color: blue'); + }, + ]); + }); + }); + } + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/main_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/main_spec.ts new file mode 100644 index 000000000000..4c4839d933d0 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/main_spec.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "main"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/runtime.js').toExist(); + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/index.html').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/main.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.js', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/runtime.js').toExist(); + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/index.html').toExist(); + + harness.expectFile('dist/main.js').content.toContain(`console.log('main')`); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/missing.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Module not found:') }), + ); + + harness.expectFile('dist/runtime.js').toNotExist(); + harness.expectFile('dist/main.js').toNotExist(); + harness.expectFile('dist/index.html').toNotExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/named-chunks_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/named-chunks_spec.ts new file mode 100644 index 000000000000..07d1ff5df2ab --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/named-chunks_spec.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +const MAIN_OUTPUT = 'dist/main.js'; +const NAMED_LAZY_OUTPUT = 'dist/src_lazy-module_ts.js'; +const UNNAMED_LAZY_OUTPUT = 'dist/321.js'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "namedChunks"', () => { + beforeEach(async () => { + // Setup a lazy loaded chunk + await harness.writeFiles({ + 'src/lazy-module.ts': 'export const value = 42;', + 'src/main.ts': `import('./lazy-module');`, + }); + }); + + it('generates named files in output when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + namedChunks: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile(MAIN_OUTPUT).toExist(); + harness.expectFile(NAMED_LAZY_OUTPUT).toExist(); + harness.expectFile(UNNAMED_LAZY_OUTPUT).toNotExist(); + }); + + it('does not generate named files in output when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + namedChunks: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile(MAIN_OUTPUT).toExist(); + harness.expectFile(NAMED_LAZY_OUTPUT).toNotExist(); + harness.expectFile(UNNAMED_LAZY_OUTPUT).toExist(); + }); + + it('does not generate named files in output when not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile(MAIN_OUTPUT).toExist(); + harness.expectFile(NAMED_LAZY_OUTPUT).toNotExist(); + harness.expectFile(UNNAMED_LAZY_OUTPUT).toExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/output-hashing_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/output-hashing_spec.ts new file mode 100644 index 000000000000..26589c223498 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/output-hashing_spec.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { OutputHashing } from '../../schema'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "outputHashing"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', ''); + await harness.writeFile('src/polyfills.ts', ''); + }); + + it('hashes all filenames when set to "all"', async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: 'src/polyfills.ts', + outputHashing: OutputHashing.All, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist', /runtime\.[0-9a-f]{16}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /main\.[0-9a-f]{16}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /polyfills\.[0-9a-f]{16}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.css$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /spectrum\.[0-9a-f]{16}\.png$/)).toBeTrue(); + }); + + it(`doesn't hash any filenames when not set`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: 'src/polyfills.ts', + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist', /runtime\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /main\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /polyfills\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /spectrum\.[0-9a-f]{16}\.png$/)).toBeFalse(); + }); + + it(`doesn't hash any filenames when set to "none"`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: 'src/polyfills.ts', + outputHashing: OutputHashing.None, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist', /runtime\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /main\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /polyfills\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /spectrum\.[0-9a-f]{16}\.png$/)).toBeFalse(); + }); + + it(`hashes CSS resources filenames only when set to "media"`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: 'src/polyfills.ts', + outputHashing: OutputHashing.Media, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist', /runtime\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /main\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /polyfills\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /spectrum\.[0-9a-f]{16}\.png$/)).toBeTrue(); + }); + + it(`hashes bundles filenames only when set to "bundles"`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: 'src/polyfills.ts', + outputHashing: OutputHashing.Bundles, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist', /runtime\.[0-9a-f]{16}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /main\.[0-9a-f]{16}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /polyfills\.[0-9a-f]{16}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.css$/)).toBeTrue(); + expect(harness.hasFileMatch('dist', /spectrum\.[0-9a-f]{16}\.png$/)).toBeFalse(); + }); + + it('does not hash non injected styles', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.All, + sourceMap: true, + styles: [ + { + input: 'src/styles.css', + inject: false, + }, + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist', /styles\.[0-9a-f]{16}\.js.map$/)).toBeFalse(); + harness.expectFile('dist/styles.css').toExist(); + harness.expectFile('dist/styles.css.map').toExist(); + }); + + it('does not override different files which has the same filenames when hashing is "none"', async () => { + await harness.writeFiles({ + 'src/styles.css': ` + h1 { background: url('./test.svg')} + h2 { background: url('./small/test.svg')} + `, + './src/test.svg': ` + Hello World + `, + './src/small/test.svg': ` + Hello World + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + outputHashing: OutputHashing.None, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + harness.expectFile('dist/test.svg').toExist(); + harness.expectFile('dist/small-test.svg').toExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/polyfills_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/polyfills_spec.ts new file mode 100644 index 000000000000..422cebb9ad0e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/polyfills_spec.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "polyfills"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: 'src/polyfills.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/polyfills.js').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: 'src/polyfills.js', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/polyfills.js').content.toContain(`console.log('main')`); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: 'src/missing.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Module not found:') }), + ); + + harness.expectFile('dist/polyfills.js').toNotExist(); + }); + + it('resolves module specifiers in array', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js', 'zone.js/testing'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/polyfills.js').toExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/scripts_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/scripts_spec.ts new file mode 100644 index 000000000000..f855f206a079 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/scripts_spec.ts @@ -0,0 +1,438 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "scripts"', () => { + beforeEach(async () => { + // Application code is not needed for scripts tests + await harness.writeFile('src/main.ts', ''); + }); + + it('supports an empty array value', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('processes an empty script when optimizing', async () => { + await harness.writeFile('src/test-script-a.js', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + }, + scripts: ['src/test-script-a.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').toExist(); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + describe('shorthand syntax', () => { + it('processes a single script into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + await harness.writeFile('src/test-script-b.js', 'console.log("b");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js', 'src/test-script-b.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple scripts in single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + await harness.writeFile('src/test-script-b.js', 'console.log("b");'); + await harness.writeFile('src/test-script-c.js', 'console.log("c");'); + await harness.writeFile('src/test-script-d.js', 'console.log("d");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + 'src/test-script-c.js', + 'src/test-script-d.js', + 'src/test-script-b.js', + 'src/test-script-a.js', + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/scripts.js') + .content.toMatch( + /console\.log\("c"\)[;\s]+console\.log\("d"\)[;\s]+console\.log\("b"\)[;\s]+console\.log\("a"\)/, + ); + }); + + it('fails and shows an error if script does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(`Can't resolve 'src/test-script-a.js'`), + }), + ); + + harness.expectFile('dist/scripts.js').toNotExist(); + }); + + it('shows the output script as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/scripts\.js.+\d+ bytes/) }), + ); + }); + }); + + describe('longhand syntax', () => { + it('processes a single script into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes a single script into a single output named with bundleName', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('uses default bundleName when bundleName is empty string', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with no bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }, { input: 'src/test-script-b.js' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with same bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-a.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'extra' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/extra.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with different bundleNames into separate outputs', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-a.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/other.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple scripts in single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + 'src/test-script-c.js': 'console.log("c");', + 'src/test-script-d.js': 'console.log("d");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-c.js' }, + { input: 'src/test-script-d.js' }, + { input: 'src/test-script-b.js' }, + { input: 'src/test-script-a.js' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/scripts.js') + .content.toMatch( + /console\.log\("c"\)[;\s]+console\.log\("d"\)[;\s]+console\.log\("b"\)[;\s]+console\.log\("a"\)/, + ); + }); + + it('preserves order of multiple scripts with different bundleNames', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + 'src/test-script-c.js': 'console.log("c");', + 'src/test-script-d.js': 'console.log("d");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-c.js', bundleName: 'other' }, + { input: 'src/test-script-d.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'extra' }, + { input: 'src/test-script-a.js', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/other.js') + .content.toMatch(/console\.log\("c"\)[;\s]+console\.log\("a"\)/); + harness + .expectFile('dist/extra.js') + .content.toMatch(/console\.log\("d"\)[;\s]+console\.log\("b"\)/); + harness + .expectFile('dist/index.html') + .content.toMatch( + /'); + }); + + it('does not add script element to index when inject is false', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + // `inject: false` causes the bundleName to be the input file name + harness.expectFile('dist/test-script-a.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.not.toContain(''); + }); + + it('does not add script element to index with bundleName when inject is false', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/index.html') + .content.not.toContain(''); + }); + + it('shows the output script as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/scripts\.js.+\d+ bytes/) }), + ); + }); + + it('shows the output script as a chunk entry with bundleName in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/extra\.js.+\d+ bytes/) }), + ); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts new file mode 100644 index 000000000000..609e21fcef5a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "statsJson"', () => { + beforeEach(async () => { + // Application code is not needed for stat JSON tests + await harness.writeFile('src/main.ts', ''); + }); + + it('generates a Webpack Stats file in output when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + if (harness.expectFile('dist/stats.json').toExist()) { + const content = harness.readFile('dist/stats.json'); + expect(() => JSON.parse(content)) + .withContext('Expected Webpack Stats file to be valid JSON.') + .not.toThrow(); + } + }); + + // TODO: Investigate why this profiling object is no longer present in Webpack 5.90.3+ and if this should even be tested + xit('includes Webpack profiling information', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + if (harness.expectFile('dist/stats.json').toExist()) { + const stats = JSON.parse(harness.readFile('dist/stats.json')); + expect(stats?.chunks?.[0]?.modules?.[0]?.profile?.building).toBeDefined(); + } + }); + + it('does not generate a Webpack Stats file in output when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/stats.json').toNotExist(); + }); + + it('does not generate a Webpack Stats file in output when not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/stats.json').toNotExist(); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/styles_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/styles_spec.ts new file mode 100644 index 000000000000..343f024f403a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/styles_spec.ts @@ -0,0 +1,435 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "styles"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', ''); + }); + + it('supports an empty array value', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').toNotExist(); + }); + + it('does not create an output styles file when option is not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').toNotExist(); + }); + + describe('shorthand syntax', () => { + it('processes a single style into a single output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple styles into a single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css', 'src/test-style-b.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').content.toContain('.test-a {color: red}'); + harness.expectFile('dist/styles.css').content.toContain('.test-b {color: green}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple styles in single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + 'src/test-style-c.css': '.test-c {color: blue}', + 'src/test-style-d.css': '.test-d {color: yellow}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + 'src/test-style-c.css', + 'src/test-style-d.css', + 'src/test-style-b.css', + 'src/test-style-a.css', + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/styles.css') + .content.toMatch( + /\.test-c {color: blue}[\s|\S]+\.test-d {color: yellow}[\s|\S]+\.test-b {color: green}[\s|\S]+\.test-a {color: red}/m, + ); + }); + + it('fails and shows an error if style does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(`Can't resolve 'src/test-style-a.css'`), + }), + ); + + harness.expectFile('dist/styles.css').toNotExist(); + }); + + it('shows the output style as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/styles\.css.+\d+ kB/) }), + ); + }); + }); + + describe('longhand syntax', () => { + it('processes a single style into a single output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes a single style into a single output named with bundleName', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: 'extra' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('uses default bundleName when bundleName is empty string', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple styles with no bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css' }, { input: 'src/test-style-b.css' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').content.toContain('.test-a {color: red}'); + harness.expectFile('dist/styles.css').content.toContain('.test-b {color: green}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple styles with same bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-a.css', bundleName: 'extra' }, + { input: 'src/test-style-b.css', bundleName: 'extra' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.css').content.toContain('.test-a {color: red}'); + harness.expectFile('dist/extra.css').content.toContain('.test-b {color: green}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('processes multiple styles with different bundleNames into separate outputs', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-a.css', bundleName: 'extra' }, + { input: 'src/test-style-b.css', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.css').content.toContain('.test-a {color: red}'); + harness.expectFile('dist/other.css').content.toContain('.test-b {color: green}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple styles in single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + 'src/test-style-c.css': '.test-c {color: blue}', + 'src/test-style-d.css': '.test-d {color: yellow}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-c.css' }, + { input: 'src/test-style-d.css' }, + { input: 'src/test-style-b.css' }, + { input: 'src/test-style-a.css' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/styles.css') + .content.toMatch( + /\.test-c {color: blue}[\s|\S]+\.test-d {color: yellow}[\s|\S]+\.test-b {color: green}[\s|\S]+\.test-a {color: red}/, + ); + }); + + it('preserves order of multiple styles with different bundleNames', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + 'src/test-style-c.css': '.test-c {color: blue}', + 'src/test-style-d.css': '.test-d {color: yellow}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-c.css', bundleName: 'other' }, + { input: 'src/test-style-d.css', bundleName: 'extra' }, + { input: 'src/test-style-b.css', bundleName: 'extra' }, + { input: 'src/test-style-a.css', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/other.css') + .content.toMatch(/\.test-c {color: blue}[\s|\S]+\.test-a {color: red}/); + harness + .expectFile('dist/extra.css') + .content.toMatch(/\.test-d {color: yellow}[\s|\S]+\.test-b {color: green}/); + harness + .expectFile('dist/index.html') + .content.toMatch( + /\s*/, + ); + }); + + it('adds link element to index when inject is true', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', inject: true }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/styles.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.toContain(''); + }); + + it('does not add link element to index when inject is false', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + // `inject: false` causes the bundleName to be the input file name + harness.expectFile('dist/test-style-a.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.not.toContain(''); + }); + + it('does not add link element to index with bundleName when inject is false', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: 'extra', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/extra.css').content.toContain('.test-a {color: red}'); + harness + .expectFile('dist/index.html') + .content.not.toContain(''); + }); + + it('shows the output style as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/styles\.css.+\d+ kB/) }), + ); + }); + + it('shows the output style as a chunk entry with bundleName in the logging output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: 'extra' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/extra\.css.+\d+ kB/) }), + ); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/subresource-integrity_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/subresource-integrity_spec.ts new file mode 100644 index 000000000000..1fcf6b34a826 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/subresource-integrity_spec.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "subresourceIntegrity"', () => { + it(`does not add integrity attribute when not present`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/index.html').content.not.toContain('integrity='); + }); + + it(`does not add integrity attribute when 'false'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/index.html').content.not.toContain('integrity='); + }); + + it(`does add integrity attribute when 'true'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/index.html').content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); + }); + + it(`does not issue a warning when 'true' and 'scripts' is set.`, async () => { + await harness.writeFile('src/script.js', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + scripts: ['src/script.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/index.html').content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/subresource-integrity/), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/tsconfig_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/tsconfig_spec.ts new file mode 100644 index 000000000000..ebef4da04110 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/tsconfig_spec.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "tsConfig"', () => { + it('throws an exception when TypeScript Configuration file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + tsConfig: 'src/missing.json', + }); + + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + + expect(result).toBeUndefined(); + expect(error).toEqual( + jasmine.objectContaining({ + message: jasmine.stringMatching('no such file or directory'), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/verbose_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/verbose_spec.ts new file mode 100644 index 000000000000..c74af39557ed --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/verbose_spec.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +// The below plugin is only enabled when verbose option is set to true. +const VERBOSE_LOG_TEXT = 'LOG from webpack.'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "verbose"', () => { + beforeEach(async () => { + // Application code is not needed for verbose output + await harness.writeFile('src/main.ts', ''); + }); + + it('should include verbose logs when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + verbose: true, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(VERBOSE_LOG_TEXT), + }), + ); + }); + + it('should not include verbose logs when undefined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + verbose: undefined, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(VERBOSE_LOG_TEXT), + }), + ); + }); + + it('should not include verbose logs when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + verbose: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(VERBOSE_LOG_TEXT), + }), + ); + }); + + it('should list modified files when verbose is set to true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + verbose: true, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + // Amend file + await harness.appendToFile('/src/main.ts', ' '); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + /angular\.watch-files-logs-plugin\n\s+Modified files:\n.+main\.ts/, + ), + }), + ); + }, + ]); + }); + + it('should not include error stacktraces when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + verbose: false, + styles: ['./src/styles.scss'], + }); + + // Create a compilatation error. + await harness.writeFile('./src/styles.scss', `@import 'invalid-module';`); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(`Can't find stylesheet to import`), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('styles.scss.webpack'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('at Object.loader'), + }), + ); + }); + + it('should include error stacktraces when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + verbose: true, + styles: ['./src/styles.scss'], + }); + + // Create a compilatation error. + await harness.writeFile('./src/styles.scss', `@import 'invalid-module';`); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('styles.scss.webpack'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('at Object.loader'), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/watch_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/watch_spec.ts new file mode 100644 index 000000000000..d61290aa0b7c --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/watch_spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { timeout } from 'rxjs'; +import { buildWebpackBrowser } from '../../index'; +import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; + +describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { + describe('Option: "watch"', () => { + beforeEach(async () => { + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', ''); + }); + + it('does not wait for file changes when false', (done) => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: false, + }); + + // If the build waits then it will timeout with the custom timeout. + // A single build should not take more than 15 seconds. + let count = 0; + harness + .execute() + .pipe(timeout(15000)) + .subscribe({ + complete() { + expect(count).toBe(1); + done(); + }, + next({ result }) { + count++; + expect(result?.success).toBe(true); + }, + error(error) { + done.fail(error); + }, + }); + }); + + it('does not wait for file changes when not present', (done) => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // If the build waits then it will timeout with the custom timeout. + // A single build should not take more than 15 seconds. + let count = 0; + harness + .execute() + .pipe(timeout(15000)) + .subscribe({ + complete() { + expect(count).toBe(1); + done(); + }, + next({ result }) { + count++; + expect(result?.success).toBe(true); + }, + error(error) { + done.fail(error); + }, + }); + }); + + it('watches for file changes when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + main: 'src/main.ts', + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.not.toContain('abcd1234'); + + await harness.modifyFile( + 'src/main.ts', + (content) => content + 'console.log("abcd1234");', + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/main.js').content.toContain('abcd1234'); + }, + ]); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/setup.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/setup.ts new file mode 100644 index 000000000000..b14bba897987 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/setup.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Schema } from '../schema'; + +export { describeBuilder } from '../../../testing'; + +export const BROWSER_BUILDER_INFO = Object.freeze({ + name: '@angular-devkit/build-angular:browser', + schemaPath: __dirname + '/../schema.json', +}); + +/** + * Contains all required browser builder fields. + * Also disables progress reporting to minimize logging output. + */ +export const BASE_OPTIONS = Object.freeze({ + index: 'src/index.html', + main: 'src/main.ts', + outputPath: 'dist', + tsConfig: 'src/tsconfig.app.json', + progress: false, + + // Disable optimizations + optimization: false, + buildOptimizer: false, +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts new file mode 100644 index 000000000000..1e03eef28ef0 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { DevServerBuilderOutput } from '@angular/build'; +import { type IndexHtmlTransform, checkPort, purgeStaleBuildCache } from '@angular/build/private'; +import type { BuilderContext } from '@angular-devkit/architect'; +import type { Plugin } from 'esbuild'; +import type http from 'node:http'; +import { EMPTY, Observable, defer, switchMap } from 'rxjs'; +import type { ExecutionTransformer } from '../../transforms'; +import { isEsbuildBased, normalizeOptions } from './options'; +import type { Schema as DevServerBuilderOptions } from './schema'; + +/** + * A Builder that executes a development server based on the provided browser target option. + * + * Usage of the `transforms` and/or `extensions` parameters is NOT supported and may cause + * unexpected build output or build failures. + * + * @param options Dev Server options. + * @param context The build context. + * @param transforms A map of transforms that can be used to hook into some logic (such as + * transforming webpack configuration before passing it to webpack). + * @param extensions An optional object containing an array of build plugins (esbuild-based) + * and/or HTTP request middleware. + * + * @experimental Direct usage of this function is considered experimental. + */ +export function execute( + options: DevServerBuilderOptions, + context: BuilderContext, + transforms: { + webpackConfiguration?: ExecutionTransformer; + logging?: import('@angular-devkit/build-webpack').WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; + } = {}, + extensions?: { + buildPlugins?: Plugin[]; + middleware?: (( + req: http.IncomingMessage, + res: http.ServerResponse, + next: (err?: unknown) => void, + ) => void)[]; + builderSelector?: (info: BuilderSelectorInfo, logger: BuilderContext['logger']) => string; + }, +): Observable { + // Determine project name from builder context target + const projectName = context.target?.project; + if (!projectName) { + context.logger.error(`The "dev-server" builder requires a target to be specified.`); + + return EMPTY; + } + + context.logger.warn( + 'The "@angular-devkit/build-angular:dev-server" builder is deprecated as part of Angular\'s Webpack support deprecation. ' + + 'Use "@angular/build:dev-server" instead. For more information, see https://angular.dev/tools/cli/build-system-migration.', + ); + + return defer(() => initialize(options, projectName, context, extensions?.builderSelector)).pipe( + switchMap(({ builderName, normalizedOptions }) => { + // Use vite-based development server for esbuild-based builds + if (isEsbuildBased(builderName)) { + if (transforms?.logging || transforms?.webpackConfiguration) { + throw new Error( + `The "application" and "browser-esbuild" builders do not support Webpack transforms.`, + ); + } + + if (options.publicHost) { + context.logger.warn( + `The "publicHost" option will not be used because it is not supported by the "${builderName}" builder.`, + ); + } + + if (options.disableHostCheck) { + context.logger.warn( + `The "disableHostCheck" option will not be used because it is not supported by the "${builderName}" builder.`, + ); + } + + // New build system defaults hmr option to the value of liveReload + normalizedOptions.hmr ??= normalizedOptions.liveReload; + + // New build system uses Vite's allowedHost option convention of true for disabling host checks + if (normalizedOptions.disableHostCheck) { + (normalizedOptions as unknown as { allowedHosts: true }).allowedHosts = true; + } else { + normalizedOptions.allowedHosts ??= []; + } + + return defer(() => + Promise.all([import('@angular/build/private'), import('../browser-esbuild')]), + ).pipe( + switchMap(([{ serveWithVite, buildApplicationInternal }, { convertBrowserOptions }]) => + serveWithVite( + normalizedOptions as typeof normalizedOptions & { + hmr: boolean; + allowedHosts: true | string[]; + define: { [key: string]: string } | undefined; + }, + builderName, + (options, context, codePlugins) => { + return builderName === '@angular-devkit/build-angular:browser-esbuild' + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + buildApplicationInternal(convertBrowserOptions(options as any), context, { + codePlugins, + }) + : buildApplicationInternal(options, context, { codePlugins }); + }, + context, + transforms, + extensions, + ), + ), + ); + } + + // Warn if the initial options provided by the user enable prebundling with Webpack-based builders + if (options.prebundle) { + context.logger.warn( + `Prebundling has been configured but will not be used because it is not supported by the "${builderName}" builder.`, + ); + } + + if (extensions?.buildPlugins?.length) { + throw new Error('Only the "application" and "browser-esbuild" builders support plugins.'); + } + if (extensions?.middleware?.length) { + throw new Error( + 'Only the "application" and "browser-esbuild" builders support middleware.', + ); + } + + // Webpack based build systems default to false for hmr option + normalizedOptions.hmr ??= false; + + // Use Webpack for all other browser targets + return defer(() => import('./webpack-server')).pipe( + switchMap(({ serveWebpackBrowser }) => + serveWebpackBrowser(normalizedOptions, builderName, context, transforms), + ), + ); + }), + ); +} + +async function initialize( + initialOptions: DevServerBuilderOptions, + projectName: string, + context: BuilderContext, + builderSelector = defaultBuilderSelector, +) { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + const normalizedOptions = await normalizeOptions(context, projectName, initialOptions); + const builderName = builderSelector( + { + builderName: await context.getBuilderNameForTarget(normalizedOptions.buildTarget), + forceEsbuild: !!normalizedOptions.forceEsbuild, + }, + context.logger, + ); + + if ( + !normalizedOptions.disableHostCheck && + !/^127\.\d+\.\d+\.\d+/g.test(normalizedOptions.host) && + normalizedOptions.host !== 'localhost' + ) { + context.logger.warn(` +Warning: This is a simple server for use in testing or debugging Angular applications +locally. It hasn't been reviewed for security issues. + +Binding this server to an open connection can result in compromising your application or +computer. Using a different host than the one passed to the "--host" flag might result in +websocket connection issues. You might need to use "--disable-host-check" if that's the +case. + `); + } + + if (normalizedOptions.disableHostCheck) { + context.logger.warn( + 'Warning: Running a server with --disable-host-check is a security risk. ' + + 'See https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a for more information.', + ); + } + + normalizedOptions.port = await checkPort(normalizedOptions.port, normalizedOptions.host); + + return { + builderName, + normalizedOptions, + }; +} + +interface BuilderSelectorInfo { + builderName: string; + forceEsbuild: boolean; +} + +function defaultBuilderSelector( + info: BuilderSelectorInfo, + logger: BuilderContext['logger'], +): string { + if (isEsbuildBased(info.builderName)) { + return info.builderName; + } + + if (info.forceEsbuild) { + if (!info.builderName.startsWith('@angular-devkit/build-angular:')) { + logger.warn( + 'Warning: Forcing the use of the esbuild-based build system with third-party builders' + + ' may cause unexpected behavior and/or build failures.', + ); + } + + // The compatibility builder should be used if esbuild is force enabled. + return '@angular-devkit/build-angular:browser-esbuild'; + } + + return info.builderName; +} diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts new file mode 100644 index 000000000000..cd61c8e6c31a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { DevServerBuilderOutput } from '@angular/build'; +import { createBuilder } from '@angular-devkit/architect'; +import { execute } from './builder'; +import { Schema as DevServerBuilderOptions } from './schema'; + +export { + type DevServerBuilderOptions, + type DevServerBuilderOutput, + execute as executeDevServerBuilder, +}; +export default createBuilder(execute); + +// Temporary export to support specs +export { execute as executeDevServer }; diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/options.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/options.ts new file mode 100644 index 000000000000..80cd22dff4e2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/options.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import path from 'node:path'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { normalizeOptimization } from '../../utils/normalize-optimization'; +import { Schema as DevServerOptions } from './schema'; + +export type NormalizedDevServerOptions = Awaited>; + +export function isEsbuildBased( + builderName: string, +): builderName is + | '@angular/build:application' + | '@angular-devkit/build-angular:application' + | '@angular-devkit/build-angular:browser-esbuild' { + if ( + builderName === '@angular/build:application' || + builderName === '@angular-devkit/build-angular:application' || + builderName === '@angular-devkit/build-angular:browser-esbuild' + ) { + return true; + } + + return false; +} + +/** + * Normalize the user provided options by creating full paths for all path based options + * and converting multi-form options into a single form that can be directly used + * by the build process. + * + * @param context The context for current builder execution. + * @param projectName The name of the project for the current execution. + * @param options An object containing the options to use for the build. + * @returns An object containing normalized options required to perform the build. + */ +export async function normalizeOptions( + context: BuilderContext, + projectName: string, + options: DevServerOptions, +) { + const { workspaceRoot, logger } = context; + const projectMetadata = await context.getProjectMetadata(projectName); + const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? ''); + + const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); + + // Target specifier defaults to the current project's build target using a development configuration + const buildTargetSpecifier = options.buildTarget ?? `::development`; + const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); + + // Get the application builder options. + const browserBuilderName = await context.getBuilderNameForTarget(buildTarget); + const rawBuildOptions = await context.getTargetOptions(buildTarget); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buildOptions = (await context.validateOptions(rawBuildOptions, browserBuilderName)) as any; + const optimization = normalizeOptimization(buildOptions.optimization); + + if (options.prebundle !== false && isEsbuildBased(browserBuilderName)) { + if (!cacheOptions.enabled) { + // Warn if the initial options provided by the user enable prebundling but caching is disabled + logger.warn( + 'Prebundling has been configured but will not be used because caching has been disabled.', + ); + } else if (optimization.scripts) { + // Warn if the initial options provided by the user enable prebundling but script optimization is enabled. + logger.warn( + 'Prebundling has been configured but will not be used because scripts optimization is enabled.', + ); + } + } + + let inspect: false | { host?: string; port?: number } = false; + const inspectRaw = options.inspect; + if (inspectRaw === true || inspectRaw === '' || inspectRaw === 'true') { + inspect = { + host: undefined, + port: undefined, + }; + } else if (typeof inspectRaw === 'string' && inspectRaw !== 'false') { + const port = +inspectRaw; + if (isFinite(port)) { + inspect = { + host: undefined, + port, + }; + } else { + const [host, port] = inspectRaw.split(':'); + inspect = { + host, + port: isNaN(+port) ? undefined : +port, + }; + } + } + + // Initial options to keep + const { + host, + port, + poll, + open, + verbose, + watch, + allowedHosts, + disableHostCheck, + liveReload, + hmr, + headers, + proxyConfig, + servePath, + publicHost, + ssl, + sslCert, + sslKey, + forceEsbuild, + prebundle, + } = options; + + // Return all the normalized options + return { + buildTarget, + host: host ?? 'localhost', + port: port ?? 4200, + poll, + open, + verbose, + watch, + liveReload: !!liveReload, + hmr, + headers, + workspaceRoot, + projectRoot, + cacheOptions, + allowedHosts, + disableHostCheck, + proxyConfig, + servePath, + publicHost, + ssl, + sslCert: sslCert && path.resolve(workspaceRoot, sslCert), + sslKey: sslKey && path.resolve(workspaceRoot, sslKey), + forceEsbuild, + // Prebundling defaults to true but requires caching to function + prebundle: cacheOptions.enabled && !optimization.scripts && (prebundle ?? true), + inspect, + }; +} diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json b/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json new file mode 100644 index 000000000000..495f244b1722 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/schema.json @@ -0,0 +1,135 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Dev Server Target", + "description": "Dev Server target options for Build Facade.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "A build builder target to serve in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.", + "pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$" + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 4200 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "proxyConfig": { + "type": "string", + "description": "Proxy configuration file. For more information, see https://angular.dev/tools/cli/serve#proxying-to-a-backend-server." + }, + "ssl": { + "type": "boolean", + "description": "Serve using HTTPS.", + "default": false + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "sslCert": { + "type": "string", + "description": "SSL certificate to use for serving HTTPS." + }, + "headers": { + "type": "object", + "description": "Custom HTTP headers to be added to all responses.", + "propertyNames": { + "pattern": "^[-_A-Za-z0-9]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "open": { + "type": "boolean", + "description": "Opens the url in default browser.", + "default": false, + "alias": "o" + }, + "verbose": { + "type": "boolean", + "description": "Adds more details to output logging." + }, + "liveReload": { + "type": "boolean", + "description": "Whether to reload the page on change, using live-reload.", + "default": true + }, + "publicHost": { + "type": "string", + "description": "The URL that the browser client (or live-reload client, if enabled) should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies. This option has no effect when using the 'application' or other esbuild-based builders." + }, + "allowedHosts": { + "type": "array", + "description": "List of hosts that are allowed to access the dev server.", + "default": [], + "items": { + "type": "string" + } + }, + "servePath": { + "type": "string", + "description": "The pathname where the application will be served." + }, + "disableHostCheck": { + "type": "boolean", + "description": "Don't verify connected clients are part of allowed hosts.", + "default": false + }, + "hmr": { + "type": "boolean", + "description": "Enable hot module replacement." + }, + "watch": { + "type": "boolean", + "description": "Rebuild on change.", + "default": true + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + }, + "inspect": { + "default": false, + "description": "Activate debugging inspector. This option only has an effect when 'SSR' or 'SSG' are enabled.", + "oneOf": [ + { + "type": "string", + "description": "Activate the inspector on host and port in the format of `[[host:]port]`. See the security warning in https://nodejs.org/docs/latest-v22.x/api/cli.html#warning-binding-inspector-to-a-public-ipport-combination-is-insecure regarding the host parameter usage." + }, + { "type": "boolean" } + ] + }, + "forceEsbuild": { + "type": "boolean", + "description": "Force the development server to use the 'browser-esbuild' builder when building.", + "default": false + }, + "prebundle": { + "description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled. This option has no effect when using the 'browser' or other Webpack-based builders.", + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "exclude": { + "description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.", + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false, + "required": ["exclude"] + } + ] + } + }, + "additionalProperties": false, + "required": ["buildTarget"] +} diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/specs/hmr_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/hmr_spec.ts new file mode 100644 index 000000000000..ef273285ff39 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/hmr_spec.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect, BuilderRun } from '@angular-devkit/architect'; +import path from 'node:path'; +// eslint-disable-next-line import/no-extraneous-dependencies +import puppeteer, { Browser, Page } from 'puppeteer'; +import { debounceTime, switchMap, take } from 'rxjs'; +import { createArchitect, host } from '../../../testing/test-utils'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +declare const document: any; +declare const getComputedStyle: any; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +describe('Dev Server Builder HMR', () => { + const target = { project: 'app', target: 'serve' }; + const overrides = { hmr: true, watch: true, port: 0 }; + let architect: Architect; + let browser: Browser; + let page: Page; + let logs: string[] = []; + let runs: BuilderRun[]; + + beforeAll(async () => { + browser = await puppeteer.launch({ + args: ['--no-sandbox', '--disable-gpu'], + executablePath: process.env.CHROME_BIN + ? path.resolve(path.join(process.cwd(), '..', '..', '..'), process.env.CHROME_BIN) + : undefined, + }); + }); + + afterAll(async () => { + await browser.close(); + }); + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + + logs = []; + runs = []; + page = await browser.newPage(); + page.on('console', (msg) => logs.push(msg.text())); + + host.writeMultipleFiles({ + 'src/app/app.component.html': ` +

{{title}}

+ + + + + + + `, + }); + }); + + afterEach(async () => { + await host.restore().toPromise(); + await page.close(); + await Promise.all(runs.map((r) => r.stop())); + }); + + it('works for CSS changes', 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 page.goto(url); + host.writeMultipleFiles({ + 'src/styles.css': 'p { color: rgb(255, 255, 0) }', + }); + break; + case 1: + expect(logs).toContain('[webpack-dev-server] Hot updated 1 modules.'); + expect(logs).toContain(`[HMR] css reload %s ${url}styles.css`); + expect(logs).toContain('[webpack-dev-server] App is up to date.'); + + const pTagColor = await page.evaluate(() => { + const el = document.querySelector('p'); + + return getComputedStyle(el).color; + }); + + expect(pTagColor).toBe('rgb(255, 255, 0)'); + break; + } + + logs = []; + buildCount++; + }), + take(2), + ) + .toPromise(); + }); + + it('works for TS changes', 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 page.goto(url); + host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-hmr'`); + break; + case 1: + expect(logs).toContain('[webpack-dev-server] Hot updated 3 modules.'); + expect(logs).toContain('[webpack-dev-server] App is up to date.'); + + const innerText = await page.evaluate(() => document.querySelector('p').innerText); + expect(innerText).toBe('app-hmr'); + break; + } + + logs = []; + buildCount++; + }), + take(2), + ) + .toPromise(); + }); + + it('restores input and select values', 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 page.goto(url); + await page.evaluate(() => { + document.querySelector('input.visible').value = 'input value'; + document.querySelector('select').value = 'two'; + }); + + host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-hmr'`); + break; + case 1: + expect(logs).toContain('[webpack-dev-server] Hot updated 3 modules.'); + expect(logs).toContain('[webpack-dev-server] App is up to date.'); + expect(logs).toContain('[NG HMR] Restoring input/textarea values.'); + expect(logs).toContain('[NG HMR] Restoring selected options.'); + + const inputValue = await page.evaluate( + () => document.querySelector('input.visible').value, + ); + expect(inputValue).toBe('input value'); + + const selectValue = await page.evaluate(() => document.querySelector('select').value); + expect(selectValue).toBe('two'); + break; + } + + logs = []; + buildCount++; + }), + take(2), + ) + .toPromise(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/specs/index_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/index_spec.ts new file mode 100644 index 000000000000..cfcc01980f78 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/index_spec.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { workspaces } from '@angular-devkit/core'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { DevServerBuilderOutput } from '../index'; + +describe('Dev Server Builder index', () => { + const targetSpec = { project: 'app', target: 'serve' }; + + beforeEach(async () => host.initialize().toPromise()); + afterEach(async () => host.restore().toPromise()); + + it('sets HTML lang attribute with the active locale', async () => { + const locale = 'fr'; + const { workspace } = await workspaces.readWorkspace( + host.root(), + workspaces.createWorkspaceHost(host), + ); + const app = workspace.projects.get('app'); + if (!app) { + fail('Test application "app" not found.'); + + return; + } + + app.extensions['i18n'] = { + locales: { + [locale]: [], + }, + }; + + const target = app.targets.get('build'); + if (!target) { + fail('Test application "app" target "build" not found.'); + + return; + } + if (!target.options) { + target.options = {}; + } + target.options.localize = [locale]; + + await workspaces.writeWorkspace(workspace, workspaces.createWorkspaceHost(host)); + + const architect = (await createArchitect(host.root())).architect; + const run = await architect.scheduleTarget(targetSpec, { port: 0 }); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + const response = await fetch(output.baseUrl); + expect(await response.text()).toContain(`lang="${locale}"`); + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/specs/ssl_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/ssl_spec.ts new file mode 100644 index 000000000000..8d61b76232d7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/ssl_spec.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect, BuilderRun } from '@angular-devkit/architect'; +import { tags } from '@angular-devkit/core'; +import { Agent, fetch } from 'undici'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { DevServerBuilderOutput } from '../index'; + +describe('Dev Server Builder ssl', () => { + const target = { project: 'app', target: 'serve' }; + let architect: Architect; + // We use runs like this to ensure it WILL stop the servers at the end of each tests. + let runs: BuilderRun[]; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + runs = []; + }); + afterEach(async () => { + await host.restore().toPromise(); + await Promise.all(runs.map((r) => r.stop())); + }); + + it('works', async () => { + const run = await architect.scheduleTarget(target, { ssl: true, port: 0 }); + runs.push(run); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + expect(output.baseUrl).toMatch(/^https:\/\/localhost:\d+\//); + + const response = await fetch(output.baseUrl, { + dispatcher: new Agent({ + connect: { rejectUnauthorized: false }, + }), + }); + expect(await response.text()).toContain('HelloWorldApp'); + }); + + it('supports key and cert', async () => { + host.writeMultipleFiles({ + 'ssl/server.key': tags.stripIndents` + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEA0+kOHdfpsPNUguCtai27DJ+DuOfVw4gw1P3BgYhNG8qoukaW + gdDLowj59+cXXgn9ZnQ8PdvZXhCEQcP3wjd1cBPuGQzO7BekoKEgzfGE/zjrrIeL + Q7tHYx9ddCPotQX4OJu4FZFJyS1Ar7zDDpQ9fSw6qmsXWN6I18fIGSVvbDbVB9rw + iAsLGe2jWadjXAjSqVOy6aT+CKJgnRnxudNZGP1LRC1YDRl/s7icCIh/9gEGfn7G + gwQQ9AM2p3JNjP2quclSjBvMv0uVj+yzL2bPGRz0hu90CDaIEU2FtBCse50/O75q + x0bQnPbuHKD3ajs0DfQYoAmFZGK078ZDl/VQxwIDAQABAoIBAEl17kXcNo/4GqDw + QE2hoslCdwhfnhQVn1AG09ESriBnRcylccF4308aaoVM4CXicqzUuJl9IEJimWav + B7GVRinfTtfyP71KiPCCSvv5sPBFDDYYGugVAS9UjTIYzLAMbLs7CDq5zglmnZkO + Z9QjAZnl/kRbsZFGO8wJ3s0Q1Cp/ygZcvFU331K2jHXW7B4YXiFOH/lBQrjdz0Gy + WBjX4zIdNWnwarvxu46IS/0z1P1YOHM8+B1Uv54MG94A6szBdd/Vp0cQRs78t/Cu + BQ1Rnuk16Pi+ieC5K04yUgeuNusYW0PWLtPX1nKNp9z46bmD1NHKAxaoDFXr7qP3 + pZCaDMkCgYEA8mmTYrhXJTRIrOxoUwM1e3OZ0uOxVXJJ8HF6X8t+UO6dFxXB/JC9 + ZBc+94cZQapaKFOeMmd/j3L2CQIjChk5yKV/G3Io+raxIoAAKPCkMF4NQQVvvNkS + CAGl61Qa78DoF5Habumz0AC1R9P877kNTC0aPSt4lhPWgfotbZNNMlMCgYEA38nM + s4a0pZseXPkuOtPYX/3Ms3E+d70XKSFuIMCHCg79YGsQ8h/9apYcPyeYkpQ0a4gs + I3IUqMaXC2OyqWA5LU1BZv51mXb6zcb2pokZfpiSWk+7sy5XjkE9EmQxp3xHfV3c + EO/DxHfWNvtMjESMbhu0yVzM2O/Aa53Tl9lqAT0CgYEA1dXBuHyqCtyTG08zO78B + 55Ny5rAJ1zkI9jvz2hr0o0nJcvqzcyruliNXXRxkcCNoglg4nXfk81JSrGGhLSBR + c6hhdoF+mqKboLZO7c5Q14WvpWK5TVoiaMOja/J2DHYbhecYS2yGPH7TargaUBDq + JP9IPRtitOhs+Z0Jg7ZDi5cCgYAMb7B6gY/kbBxh2k8hYchyfS41AqQQD2gMFxmB + pHFcs7yM8SY97l0s4S6sq8ykyKupFiYtyhcv0elu7pltJDXJOLPbv2RVpPEHInlu + g8vw5xWrAydRK9Adza5RKVRBFHz8kIy8PDbK4kX7RDfay6xqKgv/7LJNk/VDhb/O + fnyPmQKBgQDg/o8Ubf/gxA9Husnuld4DBu3wwFhkMlWqyO9QH3cKgojQ2JGSrfDz + xHhetmhionEyzg0JCaMSpzgIHY+8o/NAwc++OjNHEoYp3XWM9GTp81ROMz6b83jV + biVR9N0MhONdwF6vtzDCcJxNIUe2p4lTvLf/Xd9jaQDNXe35Gxsdyg== + -----END RSA PRIVATE KEY----- + `, + 'ssl/server.crt': tags.stripIndents` + -----BEGIN CERTIFICATE----- + MIID5jCCAs6gAwIBAgIJAJOebwfGCm61MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV + BAYTAlVTMRAwDgYDVQQIEwdHZW9yZ2lhMRAwDgYDVQQHEwdBdGxhbnRhMRAwDgYD + VQQKEwdBbmd1bGFyMRAwDgYDVQQLEwdBbmd1bGFyMB4XDTE2MTAwNDAxMDAyMVoX + DTI2MTAwMjAxMDAyMVowVTELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0dlb3JnaWEx + EDAOBgNVBAcTB0F0bGFudGExEDAOBgNVBAoTB0FuZ3VsYXIxEDAOBgNVBAsTB0Fu + Z3VsYXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT6Q4d1+mw81SC + 4K1qLbsMn4O459XDiDDU/cGBiE0byqi6RpaB0MujCPn35xdeCf1mdDw929leEIRB + w/fCN3VwE+4ZDM7sF6SgoSDN8YT/OOush4tDu0djH110I+i1Bfg4m7gVkUnJLUCv + vMMOlD19LDqqaxdY3ojXx8gZJW9sNtUH2vCICwsZ7aNZp2NcCNKpU7LppP4IomCd + GfG501kY/UtELVgNGX+zuJwIiH/2AQZ+fsaDBBD0Azanck2M/aq5yVKMG8y/S5WP + 7LMvZs8ZHPSG73QINogRTYW0EKx7nT87vmrHRtCc9u4coPdqOzQN9BigCYVkYrTv + xkOX9VDHAgMBAAGjgbgwgbUwHQYDVR0OBBYEFG4VV6/aNLx/qFIS9MhAWuyeV5OX + MIGFBgNVHSMEfjB8gBRuFVev2jS8f6hSEvTIQFrsnleTl6FZpFcwVTELMAkGA1UE + BhMCVVMxEDAOBgNVBAgTB0dlb3JnaWExEDAOBgNVBAcTB0F0bGFudGExEDAOBgNV + BAoTB0FuZ3VsYXIxEDAOBgNVBAsTB0FuZ3VsYXKCCQCTnm8HxgputTAMBgNVHRME + BTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQDO4jZT/oKVxaiWr+jV5TD+qwThl9zT + Uw/ZpFDkdbZdY/baCFaLCiJwkK9+puMOabLvm1VzcnHHWCoiUNbWpw8AOumLEnTv + ze/5OZXJ6XlA9kd9f3hDlN5zNB3S+Z2nKIrkPGfxQZ603QCbWaptip5dxgek6oDZ + YXVtnbOnPznRsG5jh07U49RO8CNebqZLzdRToLgObbqYlfRMcbUxCOHXjnB5wUlp + 377Iivm4ldnCTvFOjEiDh+FByWL5xic7PjyJPZFMidiYTmsGilP9XTFC83CRZwz7 + vW+RCSlU6x8Uejz98BPmASoqCuCTUeOo+2pFelFhX9NwR/Sb6b7ybdPv + -----END CERTIFICATE----- + `, + }); + + const overrides = { + ssl: true, + sslKey: 'ssl/server.key', + sslCert: 'ssl/server.crt', + port: 0, + }; + + const run = await architect.scheduleTarget(target, overrides); + runs.push(run); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + expect(output.baseUrl).toMatch(/^https:\/\/localhost:\d+\//); + + const response = await fetch(output.baseUrl, { + dispatcher: new Agent({ + connect: { rejectUnauthorized: false }, + }), + }); + expect(await response.text()).toContain('HelloWorldApp'); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/specs/works_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/works_spec.ts new file mode 100644 index 000000000000..14290fc4e5db --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/specs/works_spec.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Architect, BuilderRun } from '@angular-devkit/architect'; +import { EmittedFiles } from '@angular-devkit/build-webpack'; +import { normalize } from '@angular-devkit/core'; +import { createArchitect, host } from '../../../testing/test-utils'; +import { DevServerBuilderOutput } from '../index'; + +describe('Dev Server Builder', () => { + const target = { project: 'app', target: 'serve' }; + let architect: Architect; + let runs: BuilderRun[] = []; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + runs = []; + }); + afterEach(async () => { + await host.restore().toPromise(); + await Promise.all(runs.map((r) => r.stop())); + }); + + it(`doesn't serve files on the cwd directly`, async () => { + const run = await architect.scheduleTarget(target); + runs.push(run); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + + // When webpack-dev-server doesn't have `contentBase: false`, this will serve the repo README. + const response = await fetch(`http://localhost:${output.port}/README.md`, { + headers: { + 'Accept': 'text/html', + }, + }); + + const res = await response.text(); + expect(res).not.toContain('This file is automatically generated during release.'); + expect(res).toContain('HelloWorldApp'); + }); + + it('should not generate sourcemaps when running prod build', async () => { + // Production builds have sourcemaps turned off. + const run = await architect.scheduleTarget( + { ...target, configuration: 'production' }, + { port: 0 }, + ); + runs.push(run); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + const hasSourceMaps = + output.emittedFiles && output.emittedFiles.some((f: EmittedFiles) => f.extension === '.map'); + expect(hasSourceMaps).toBe(false, `Expected emitted files not to contain '.map' files.`); + }); + + it('serves custom headers', async () => { + const run = await architect.scheduleTarget(target, { + headers: { 'X-Header': 'Hello World' }, + port: 0, + }); + runs.push(run); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + const response = await fetch(output.baseUrl); + expect(response.headers.get('X-Header')).toBe('Hello World'); + }); + + it('uses source locale when not localizing', async () => { + const config = host.scopedSync().read(normalize('angular.json')); + const jsonConfig = JSON.parse(new TextDecoder().decode(config)); + const applicationProject = jsonConfig.projects.app; + + applicationProject.i18n = { sourceLocale: 'fr' }; + + host.writeMultipleFiles({ + 'angular.json': JSON.stringify(jsonConfig), + }); + + const architect = (await createArchitect(host.root())).architect; + const run = await architect.scheduleTarget(target, { port: 0 }); + const output = (await run.result) as DevServerBuilderOutput; + expect(output.success).toBe(true); + + const indexResponse = await fetch(output.baseUrl); + expect(await indexResponse.text()).toContain('lang="fr"'); + const vendorResponse = await fetch(output.baseUrl + 'vendor.js'); + const vendorText = await vendorResponse.text(); + expect(vendorText).toContain('fr'); + expect(vendorText).toContain('octobre'); + + await run.stop(); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-assets_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-assets_spec.ts new file mode 100644 index 000000000000..f104bcaa0025 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-assets_spec.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget, isVite) => { + const javascriptFileContent = + "import {foo} from 'unresolved'; /* a comment */const foo = `bar`;\n\n\n"; + + describe('Behavior: "browser builder assets"', () => { + it('serves a project JavaScript asset unmodified', async () => { + await harness.writeFile('src/extra.js', javascriptFileContent); + + setupTarget(harness, { + assets: ['src/extra.js'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.js'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('serves a project TypeScript asset unmodified', async () => { + await harness.writeFile('src/extra.ts', javascriptFileContent); + + setupTarget(harness, { + assets: ['src/extra.ts'], + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.ts'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('should return 404 for non existing assets', async () => { + setupTarget(harness, { + assets: [], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'does-not-exist.js'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(404); + }); + + it(`should return the asset that matches 'index.html' when path has a trailing '/'`, async () => { + await harness.writeFile( + 'src/login/index.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }); + + (isVite ? it : xit)( + `should return the asset that matches '.html' when path has no trailing '/'`, + async () => { + await harness.writeFile( + 'src/login/new.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/new'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }, + ); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-base-href_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-base-href_spec.ts new file mode 100644 index 000000000000..813796079b17 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-base-href_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "buildTarget baseHref"', () => { + beforeEach(async () => { + setupTarget(harness, { + baseHref: '/test/', + }); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("foo");'); + }); + + it('uses the baseHref defined in the "buildTarget" options as the serve path', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test/main.js'); + + expect(result?.success).toBeTrue(); + const baseUrl = new URL(`${result?.baseUrl}/`); + expect(baseUrl.pathname).toBe('/test/'); + expect(await response?.text()).toContain('console.log'); + }); + + it('serves the application from baseHref location without trailing slash', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(' + + + + + %SCRIPTS% + + + + + + diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma-debug.html b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma-debug.html new file mode 100644 index 000000000000..dc58d8e6ee60 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma-debug.html @@ -0,0 +1,46 @@ + + + + + %X_UA_COMPATIBLE% + Karma DEBUG RUNNER + + + + + + + + + + + + + + + + %SCRIPTS% + + + + + + diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts new file mode 100644 index 000000000000..07aac6f8b9d2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/* eslint-disable */ +// TODO: cleanup this file, it's copied as is from Angular CLI. +import type { IncomingMessage, ServerResponse } from 'node:http'; +import * as path from 'node:path'; +import type { Compiler } from 'webpack'; +import webpackDevMiddleware from 'webpack-dev-middleware'; + +import { statsErrorsToString } from '../../utils/stats'; +import { logging } from '@angular-devkit/core'; +import { BuildOptions } from '../../../../utils/build-options'; +import { normalizeSourceMaps } from '../../../../utils/index'; + +let webpackMiddleware: webpackDevMiddleware.API; + +const init: any = (config: any, emitter: any) => { + if (!config.buildWebpack) { + throw new Error( + `The '@angular-devkit/build-angular/plugins/karma' karma plugin is meant to` + + ` be used from within Angular CLI and will not work correctly outside of it.`, + ); + } + const options = config.buildWebpack.options as BuildOptions; + const logger: logging.Logger = config.buildWebpack.logger; + + // Add a reporter that fixes sourcemap urls. + if (normalizeSourceMaps(options.sourceMap).scripts) { + config.reporters.unshift('@angular-devkit/build-angular--sourcemap-reporter'); + + // Code taken from https://github.com/tschaub/karma-source-map-support. + // We can't use it directly because we need to add it conditionally in this file, and karma + // frameworks cannot be added dynamically. + const smsPath = path.dirname(require.resolve('source-map-support')); + const ksmsPath = path.dirname(require.resolve('karma-source-map-support')); + + config.files.unshift( + { + pattern: path.join(smsPath, 'browser-source-map-support.js'), + included: true, + served: true, + watched: false, + }, + { pattern: path.join(ksmsPath, 'client.js'), included: true, served: true, watched: false }, + ); + } + + // When using code-coverage, auto-add karma-coverage. + if ( + options.codeCoverage && + !config.reporters.some((r: string) => r === 'coverage' || r === 'coverage-istanbul') + ) { + config.reporters.push('coverage'); + } + + const compiler = config.buildWebpack.compiler as Compiler; + const webpackMiddlewareConfig = { + // Hide webpack output because its noisy. + stats: false, + }; + + config.webpackMiddleware = { ...webpackMiddlewareConfig, ...config.webpackMiddleware }; + + // Our custom context and debug files list the webpack bundles directly instead of using + // the karma files array. + config.customContextFile = `${__dirname}/karma-context.html`; + config.customDebugFile = `${__dirname}/karma-debug.html`; + + // Add the webpack server fallback. + config.middleware = config.middleware || []; + config.middleware.push('@angular-devkit/build-angular--fallback'); + + webpackMiddleware = webpackDevMiddleware(compiler, webpackMiddlewareConfig); + emitter.on('exit', (done: any) => { + webpackMiddleware.close(() => compiler.close(() => done())); + }); + + let lastCompilationHash: string | undefined; + + return new Promise((resolve) => { + compiler.hooks.done.tap('karma', (stats) => { + if (stats.hasErrors()) { + lastCompilationHash = undefined; + + // Only generate needed JSON stats and when needed. + const statsJson = stats.toJson({ + all: false, + children: true, + errors: true, + warnings: true, + }); + + logger.error(statsErrorsToString(statsJson, { colors: true })); + + if (config.singleRun) { + // Notify potential listeners of the compile error. + emitter.emit('load_error'); + } + + // Finish Karma run early in case of compilation error. + emitter.emit('run_complete', [], { exitCode: 1 }); + } else if (stats.hash != lastCompilationHash) { + // Refresh karma only when there are no webpack errors, and if the compilation changed. + lastCompilationHash = stats.hash; + emitter.refreshFiles(); + } + + // This is needed to block Karma from launching browsers before Webpack writes the assets in memory. + // See the below: + // https://github.com/karma-runner/karma-chrome-launcher/issues/154#issuecomment-986661937 + // https://github.com/angular/angular-cli/issues/22495 + resolve(); + }); + }); +}; + +init.$inject = ['config', 'emitter']; + +// Copied from "karma-jasmine-diff-reporter" source code: +// In case, when multiple reporters are used in conjunction +// with initSourcemapReporter, they both will show repetitive log +// messages when displaying everything that supposed to write to terminal. +// So just suppress any logs from initSourcemapReporter by doing nothing on +// browser log, because it is an utility reporter, +// unless it's alone in the "reporters" option and base reporter is used. +function muteDuplicateReporterLogging(context: any, config: any) { + context.writeCommonMsg = () => {}; + const reporterName = '@angular/cli'; + const hasTrailingReporters = config.reporters.slice(-1).pop() !== reporterName; + + if (hasTrailingReporters) { + context.writeCommonMsg = () => {}; + } +} + +// Strip the server address and webpack scheme (webpack://) from error log. +const sourceMapReporter: any = function (this: any, baseReporterDecorator: any, config: any) { + baseReporterDecorator(this); + muteDuplicateReporterLogging(this, config); + + const urlRegexp = /http:\/\/localhost:\d+\/(webpack:\/)?/gi; + + this.onSpecComplete = function (_browser: any, result: any) { + if (!result.success) { + result.log = result.log.map((l: string) => l.replace(urlRegexp, '')); + } + }; + + // avoid duplicate complete message + this.onRunComplete = () => {}; + + // avoid duplicate failure message + this.specFailure = () => {}; +}; + +sourceMapReporter.$inject = ['baseReporterDecorator', 'config']; + +/** + * List of files that are always served by the webpack server. + */ +const alwaysServe: ReadonlySet = new Set([ + '/runtime.js', + '/polyfills.js', + '/scripts.js', + '/styles.css', + '/vendor.js', +]); + +// When a request is not found in the karma server, try looking for it from the webpack server root. +function fallbackMiddleware() { + return function ( + request: IncomingMessage, + response: ServerResponse, + next: (err?: unknown) => void, + ) { + if (!webpackMiddleware) { + next(); + return; + } + + webpackMiddleware(request, response, () => { + if (request.url && alwaysServe.has(request.url)) { + response.statusCode = 200; + response.end(); + } else { + next(); + } + }); + }; +} + +module.exports = { + 'framework:@angular-devkit/build-angular': ['factory', init], + 'reporter:@angular-devkit/build-angular--sourcemap-reporter': ['type', sourceMapReporter], + 'middleware:@angular-devkit/build-angular--fallback': ['factory', fallbackMiddleware], +}; diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/named-chunks-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/named-chunks-plugin.ts new file mode 100644 index 000000000000..417d11484ef7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/named-chunks-plugin.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { AsyncDependenciesBlock, Chunk, Compiler, Template, dependencies } from 'webpack'; + +// `ImportDependency` is not part of Webpack's depenencies typings. +const ImportDependency: typeof dependencies.ModuleDependency = require('webpack/lib/dependencies/import/ImportDependency'); + +const PLUGIN_NAME = 'named-chunks-plugin'; + +/** + * Webpack will not populate the chunk `name` property unless `webpackChunkName` magic comment is used. + * This however will also effect the filename which is not desired when using `deterministic` chunkIds. + * This plugin will populate the chunk `name` which is mainly used so that users can set bundle budgets on lazy chunks. + */ +export class NamedChunksPlugin { + apply(compiler: Compiler) { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.chunkAsset.tap(PLUGIN_NAME, (chunk) => { + if (chunk.name) { + return; + } + + if ([...chunk.files.values()].every((f) => f.endsWith('.css'))) { + // If all chunk files are CSS files skip. + // This happens when using `import('./styles.css')` in a lazy loaded module. + return undefined; + } + + const name = this.generateName(chunk); + if (name) { + chunk.name = name; + } + }); + }); + } + + private generateName(chunk: Chunk): string | undefined { + for (const group of chunk.groupsIterable) { + const [block] = group.getBlocks(); + if (!(block instanceof AsyncDependenciesBlock)) { + continue; + } + + if (block.groupOptions.name) { + // Ignore groups which have been named already. + return undefined; + } + + for (const dependency of block.dependencies) { + if (dependency instanceof ImportDependency) { + return Template.toPath(dependency.request); + } + } + } + + return undefined; + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/occurrences-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/occurrences-plugin.ts new file mode 100644 index 000000000000..0357b267071a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/occurrences-plugin.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Compiler } from 'webpack'; + +const PLUGIN_NAME = 'angular-occurrences-plugin'; + +export interface OccurrencesPluginOptions { + aot?: boolean; + scriptsOptimization?: boolean; +} + +export class OccurrencesPlugin { + constructor(private options: OccurrencesPluginOptions) {} + + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + async (compilationAssets) => { + for (const assetName of Object.keys(compilationAssets)) { + if (!assetName.endsWith('.js')) { + continue; + } + + const scriptAsset = compilation.getAsset(assetName); + if (!scriptAsset || scriptAsset.source.size() <= 0) { + continue; + } + + const src = scriptAsset.source.source().toString('utf-8'); + + let ngComponentCount = 0; + + if (!this.options.aot) { + // Count the number of `Component({` strings (case sensitive), which happens in __decorate(). + ngComponentCount += this.countOccurrences(src, 'Component({'); + } + + if (this.options.scriptsOptimization) { + // for ascii_only true + ngComponentCount += this.countOccurrences(src, '.\\u0275cmp', false); + } else { + // For Ivy we just count ɵcmp.src + ngComponentCount += this.countOccurrences(src, '.ɵcmp', true); + } + + compilation.updateAsset( + assetName, + (s) => s, + (assetInfo) => ({ + ...assetInfo, + ngComponentCount, + }), + ); + } + }, + ); + }); + } + + private countOccurrences(source: string, match: string, wordBreak = false): number { + let count = 0; + + // We condition here so branch prediction happens out of the loop, not in it. + if (wordBreak) { + const re = /\w/; + for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) { + if (!(re.test(source[pos - 1] || '') || re.test(source[pos + match.length] || ''))) { + count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH! + } + + pos -= match.length; + if (pos < 0) { + break; + } + } + } else { + for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) { + count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH! + pos -= match.length; + if (pos < 0) { + break; + } + } + } + + return count; + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/postcss-cli-resources.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/postcss-cli-resources.ts new file mode 100644 index 000000000000..37ee30b5f9bf --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/postcss-cli-resources.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { interpolateName } from 'loader-utils'; +import * as path from 'node:path'; +import * as url from 'node:url'; +import { Declaration, Plugin } from 'postcss'; +import { assertIsError } from '../../../utils/error'; + +function wrapUrl(url: string): string { + let wrappedUrl; + const hasSingleQuotes = url.indexOf("'") >= 0; + + if (hasSingleQuotes) { + wrappedUrl = `"${url}"`; + } else { + wrappedUrl = `'${url}'`; + } + + return `url(${wrappedUrl})`; +} + +export interface PostcssCliResourcesOptions { + baseHref?: string; + deployUrl?: string; + resourcesOutputPath?: string; + rebaseRootRelative?: boolean; + + /** CSS is extracted to a `.css` or is embedded in a `.js` file. */ + extracted?: boolean; + filename: (resourcePath: string) => string; + loader: import('webpack').LoaderContext; + emitFile: boolean; +} + +async function resolve( + file: string, + base: string, + resolver: (file: string, base: string) => Promise, +): Promise { + try { + return await resolver('./' + file, base); + } catch { + return resolver(file, base); + } +} + +export const postcss = true; + +export default function (options?: PostcssCliResourcesOptions): Plugin { + if (!options) { + throw new Error('No options were specified to "postcss-cli-resources".'); + } + + const { + deployUrl = '', + resourcesOutputPath = '', + filename, + loader, + emitFile, + extracted, + } = options; + + const process = async (inputUrl: string, context: string, resourceCache: Map) => { + // If root-relative, absolute or protocol relative url, leave as is + if (/^((?:\w+:)?\/\/|data:|chrome:|#)/.test(inputUrl)) { + return inputUrl; + } + + if (/^\//.test(inputUrl)) { + return inputUrl; + } + + // If starts with a caret, remove and return remainder + // this supports bypassing asset processing + if (inputUrl.startsWith('^')) { + return inputUrl.slice(1); + } + + const cacheKey = path.resolve(context, inputUrl); + const cachedUrl = resourceCache.get(cacheKey); + if (cachedUrl) { + return cachedUrl; + } + + if (inputUrl.startsWith('~')) { + inputUrl = inputUrl.slice(1); + } + + const { pathname, hash, search } = url.parse(inputUrl.replace(/\\/g, '/')); + const resolver = (file: string, base: string) => + new Promise((resolve, reject) => { + loader.resolve(base, decodeURI(file), (err, result) => { + if (err) { + reject(err); + + return; + } + resolve(result as string); + }); + }); + + const result = await resolve(pathname as string, context, resolver); + + return new Promise((resolve, reject) => { + loader.fs.readFile(result, (err, content) => { + if (err) { + reject(err); + + return; + } + + let outputPath = interpolateName( + // TODO: Revisit. Previously due to lack of type safety, this object + // was fine, but in practice it doesn't match the type of the loader context. + { resourcePath: result } as Parameters[0], + filename(result), + { + content, + context: loader.context || loader.rootContext, + }, + ).replace(/\\|\//g, '-'); + + if (resourcesOutputPath) { + outputPath = path.posix.join(resourcesOutputPath, outputPath); + } + + loader.addDependency(result); + if (emitFile) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + loader.emitFile(outputPath, content!, undefined, { sourceFilename: result }); + } + + let outputUrl = outputPath.replace(/\\/g, '/'); + if (hash || search) { + outputUrl = url.format({ pathname: outputUrl, hash, search }); + } + + if (deployUrl && !extracted) { + outputUrl = url.resolve(deployUrl, outputUrl); + } + + resourceCache.set(cacheKey, outputUrl); + resolve(outputUrl); + }); + }); + }; + + const resourceCache = new Map(); + const processed = Symbol('postcss-cli-resources'); + + return { + postcssPlugin: 'postcss-cli-resources', + async Declaration(decl) { + if (!decl.value.includes('url') || processed in decl) { + return; + } + + const value = decl.value; + const urlRegex = /url(?:\(\s*(['"]?))(.*?)(?:\1\s*\))/g; + const segments: string[] = []; + + let match; + let lastIndex = 0; + let modified = false; + + // We want to load it relative to the file that imports + const inputFile = decl.source && decl.source.input.file; + const context = (inputFile && path.dirname(inputFile)) || loader.context; + + while ((match = urlRegex.exec(value))) { + const originalUrl = match[2]; + let processedUrl; + try { + processedUrl = await process(originalUrl, context, resourceCache); + } catch (err) { + assertIsError(err); + loader.emitError(decl.error(err.message, { word: originalUrl })); + continue; + } + + if (lastIndex < match.index) { + segments.push(value.slice(lastIndex, match.index)); + } + + if (!processedUrl || originalUrl === processedUrl) { + segments.push(match[0]); + } else { + segments.push(wrapUrl(processedUrl)); + modified = true; + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < value.length) { + segments.push(value.slice(lastIndex)); + } + + if (modified) { + decl.value = segments.join(''); + } + + (decl as Declaration & { [processed]: boolean })[processed] = true; + }, + }; +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/progress-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/progress-plugin.ts new file mode 100644 index 000000000000..823220eb796e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/progress-plugin.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { ProgressPlugin as WebpackProgressPlugin } from 'webpack'; +import { Spinner } from '../../../utils/spinner'; + +export class ProgressPlugin extends WebpackProgressPlugin { + constructor(platform: 'server' | 'browser') { + const platformCapitalFirst = platform.replace(/^\w/, (s) => s.toUpperCase()); + const spinner = new Spinner(); + spinner.start(`Generating ${platform} application bundles (phase: setup)...`); + + super({ + handler: (percentage: number, message: string) => { + const phase = message ? ` (phase: ${message})` : ''; + spinner.text = `Generating ${platform} application bundles${phase}...`; + + switch (percentage) { + case 1: + if (spinner.isSpinning) { + spinner.succeed(`${platformCapitalFirst} application bundle generation complete.`); + } + break; + case 0: + if (!spinner.isSpinning) { + spinner.start(); + } + break; + } + }, + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/remove-hash-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/remove-hash-plugin.ts new file mode 100644 index 000000000000..f4b8c3311689 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/remove-hash-plugin.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { Compiler } from 'webpack'; +import { HashFormat } from '../utils/helpers'; + +export interface RemoveHashPluginOptions { + chunkNames: string[]; + hashFormat: HashFormat; +} + +export class RemoveHashPlugin { + constructor(private options: RemoveHashPluginOptions) {} + + apply(compiler: Compiler): void { + compiler.hooks.compilation.tap('remove-hash-plugin', (compilation) => { + const assetPath = (path: string, data: { chunk?: { name?: string | null } }) => { + const chunkName = data.chunk?.name; + const { chunkNames, hashFormat } = this.options; + + if (chunkName && chunkNames?.includes(chunkName)) { + // Replace hash formats with empty strings. + return path.replace(hashFormat.chunk, '').replace(hashFormat.extract, ''); + } + + return path; + }; + + compilation.hooks.assetPath.tap('remove-hash-plugin', assetPath); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/scripts-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/scripts-webpack-plugin.ts new file mode 100644 index 000000000000..10d7d2d245bb --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/scripts-webpack-plugin.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { interpolateName } from 'loader-utils'; +import * as path from 'node:path'; +import { Chunk, Compilation, Compiler, sources as webpackSources } from 'webpack'; +import { assertIsError } from '../../../utils/error'; +import { addError } from '../../../utils/webpack-diagnostics'; + +const Entrypoint = require('webpack/lib/Entrypoint'); + +/** + * The name of the plugin provided to Webpack when tapping Webpack compiler hooks. + */ +const PLUGIN_NAME = 'scripts-webpack-plugin'; + +export interface ScriptsWebpackPluginOptions { + name: string; + sourceMap?: boolean; + scripts: string[]; + filename: string; + basePath: string; +} + +interface ScriptOutput { + filename: string; + source: webpackSources.CachedSource; +} + +function addDependencies(compilation: Compilation, scripts: string[]): void { + for (const script of scripts) { + compilation.fileDependencies.add(script); + } +} + +export class ScriptsWebpackPlugin { + private _lastBuildTime?: number; + private _cachedOutput?: ScriptOutput; + + constructor(private options: ScriptsWebpackPluginOptions) {} + + async shouldSkip(compilation: Compilation, scripts: string[]): Promise { + if (this._lastBuildTime == undefined) { + this._lastBuildTime = Date.now(); + + return false; + } + + for (const script of scripts) { + const scriptTime = await new Promise((resolve, reject) => { + compilation.fileSystemInfo.getFileTimestamp(script, (error, entry) => { + if (error) { + reject(error); + + return; + } + + resolve(entry && typeof entry !== 'string' ? entry.safeTime : undefined); + }); + }); + + if (!scriptTime || scriptTime > this._lastBuildTime) { + this._lastBuildTime = Date.now(); + + return false; + } + } + + return true; + } + + private _insertOutput( + compilation: Compilation, + { filename, source }: ScriptOutput, + cached = false, + ) { + const chunk = new Chunk(this.options.name); + chunk.rendered = !cached; + chunk.id = this.options.name; + chunk.ids = [chunk.id]; + chunk.files.add(filename); + + const entrypoint = new Entrypoint(this.options.name); + entrypoint.pushChunk(chunk); + chunk.addGroup(entrypoint); + compilation.entrypoints.set(this.options.name, entrypoint); + compilation.chunks.add(chunk); + + compilation.assets[filename] = source; + compilation.hooks.chunkAsset.call(chunk, filename); + } + + apply(compiler: Compiler): void { + if (this.options.scripts.length === 0) { + return; + } + + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + // Use the resolver from the compilation instead of compiler. + // Using the latter will causes a lot of `DescriptionFileUtils.loadDescriptionFile` calls. + // See: https://github.com/angular/angular-cli/issues/24634#issuecomment-1425782668 + const resolver = compilation.resolverFactory.get('normal', { + preferRelative: true, + useSyncFileSystemCalls: true, + // Caching must be disabled because it causes the resolver to become async after a rebuild. + cache: false, + }); + + const scripts: string[] = []; + + for (const script of this.options.scripts) { + try { + const resolvedPath = resolver.resolveSync({}, this.options.basePath, script); + if (resolvedPath) { + scripts.push(resolvedPath); + } else { + addError(compilation, `Cannot resolve '${script}'.`); + } + } catch (error) { + assertIsError(error); + addError(compilation, error.message); + } + } + + compilation.hooks.additionalAssets.tapPromise(PLUGIN_NAME, async () => { + if (await this.shouldSkip(compilation, scripts)) { + if (this._cachedOutput) { + this._insertOutput(compilation, this._cachedOutput, true); + } + + addDependencies(compilation, scripts); + + return; + } + + const sourceGetters = scripts.map((fullPath) => { + return new Promise((resolve, reject) => { + compilation.inputFileSystem.readFile( + fullPath, + (err?: Error | null, data?: string | Buffer) => { + if (err) { + reject(err); + + return; + } + + const content = data?.toString() ?? ''; + + let source; + if (this.options.sourceMap) { + // TODO: Look for source map file (for '.min' scripts, etc.) + + let adjustedPath = fullPath; + if (this.options.basePath) { + adjustedPath = path.relative(this.options.basePath, fullPath); + } + source = new webpackSources.OriginalSource(content, adjustedPath); + } else { + source = new webpackSources.RawSource(content); + } + + resolve(source); + }, + ); + }); + }); + + const sources = await Promise.all(sourceGetters); + const concatSource = new webpackSources.ConcatSource(); + sources.forEach((source) => { + concatSource.add(source); + concatSource.add('\n;'); + }); + + const combinedSource = new webpackSources.CachedSource(concatSource); + + const output = { filename: this.options.filename, source: combinedSource }; + this._insertOutput(compilation, output); + this._cachedOutput = output; + addDependencies(compilation, scripts); + }); + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DEV_TOOLING, + }, + async () => { + const assetName = this.options.filename; + const asset = compilation.getAsset(assetName); + if (asset) { + const interpolatedFilename = interpolateName( + // TODO: Revisit. Previously due to lack of type safety, this object + // was fine, but in practice it doesn't match the type of the loader context. + { resourcePath: 'scripts.js' } as Parameters[0], + assetName, + { content: asset.source.source() }, + ); + if (assetName !== interpolatedFilename) { + compilation.renameAsset(assetName, interpolatedFilename); + } + } + }, + ); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/service-worker-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/service-worker-plugin.ts new file mode 100644 index 000000000000..8b49d4d817f6 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/service-worker-plugin.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { augmentAppWithServiceWorker } from '@angular/build/private'; +import type { Compiler } from 'webpack'; + +export interface ServiceWorkerPluginOptions { + projectRoot: string; + root: string; + baseHref?: string; + ngswConfigPath?: string; +} + +export class ServiceWorkerPlugin { + constructor(private readonly options: ServiceWorkerPluginOptions) {} + + apply(compiler: Compiler) { + compiler.hooks.done.tapPromise('angular-service-worker', async (stats) => { + if (stats.hasErrors()) { + // Don't generate a service worker if the compilation has errors. + // When there are errors some files will not be emitted which would cause other errors down the line such as readdir failures. + return; + } + + const { projectRoot, root, baseHref = '', ngswConfigPath } = this.options; + const { compilation } = stats; + // We use the output path from the compilation instead of build options since during + // localization the output path is modified to a temp directory. + // See: https://github.com/angular/angular-cli/blob/7e64b1537d54fadb650559214fbb12707324cd75/packages/angular_devkit/build_angular/src/utils/i18n-options.ts#L251-L252 + const outputPath = compilation.outputOptions.path; + + if (!outputPath) { + throw new Error('Compilation output path cannot be empty.'); + } + + try { + await augmentAppWithServiceWorker( + projectRoot, + root, + outputPath, + baseHref, + ngswConfigPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (compiler.inputFileSystem as any).promises, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (compiler.outputFileSystem as any).promises, + ); + } catch (error) { + compilation.errors.push( + new compilation.compiler.webpack.WebpackError( + `Failed to generate service worker - ${error instanceof Error ? error.message : error}`, + ), + ); + } + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/styles-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/styles-webpack-plugin.ts new file mode 100644 index 000000000000..38555f56a093 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/styles-webpack-plugin.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; +import type { Compilation, Compiler } from 'webpack'; +import { assertIsError } from '../../../utils/error'; +import { addError } from '../../../utils/webpack-diagnostics'; + +export interface StylesWebpackPluginOptions { + preserveSymlinks?: boolean; + root: string; + entryPoints: Record; +} + +/** + * The name of the plugin provided to Webpack when tapping Webpack compiler hooks. + */ +const PLUGIN_NAME = 'styles-webpack-plugin'; + +export class StylesWebpackPlugin { + private compilation: Compilation | undefined; + + constructor(private readonly options: StylesWebpackPluginOptions) {} + + apply(compiler: Compiler): void { + const { entryPoints, preserveSymlinks, root } = this.options; + const resolver = compiler.resolverFactory.get('global-styles', { + conditionNames: ['sass', 'less', 'style'], + mainFields: ['sass', 'less', 'style', 'main', '...'], + extensions: ['.scss', '.sass', '.less', '.css'], + restrictions: [/\.((le|sa|sc|c)ss)$/i], + preferRelative: true, + useSyncFileSystemCalls: true, + symlinks: !preserveSymlinks, + fileSystem: compiler.inputFileSystem ?? undefined, + }); + + const webpackOptions = compiler.options; + compiler.hooks.environment.tap(PLUGIN_NAME, () => { + const entry = + typeof webpackOptions.entry === 'function' ? webpackOptions.entry() : webpackOptions.entry; + + webpackOptions.entry = async () => { + const entrypoints = await entry; + + for (const [bundleName, paths] of Object.entries(entryPoints)) { + entrypoints[bundleName] ??= {}; + const entryImport = (entrypoints[bundleName].import ??= []); + + for (const path of paths) { + try { + const resolvedPath = resolver.resolveSync({}, root, path); + if (resolvedPath) { + entryImport.push(`${resolvedPath}?ngGlobalStyle`); + } else { + assert(this.compilation, 'Compilation cannot be undefined.'); + addError(this.compilation, `Cannot resolve '${path}'.`); + } + } catch (error) { + assert(this.compilation, 'Compilation cannot be undefined.'); + assertIsError(error); + addError(this.compilation, error.message); + } + } + } + + return entrypoints; + }; + }); + + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + this.compilation = compilation; + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/suppress-entry-chunks-webpack-plugin.ts new file mode 100644 index 000000000000..aae12361dd5a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/suppress-entry-chunks-webpack-plugin.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +/** + * Remove .js files from entry points consisting entirely of stylesheets. + * To be used together with mini-css-extract-plugin. + */ +export class SuppressExtractedTextChunksWebpackPlugin { + apply(compiler: import('webpack').Compiler): void { + compiler.hooks.compilation.tap('SuppressExtractedTextChunks', (compilation) => { + compilation.hooks.chunkAsset.tap('SuppressExtractedTextChunks', (chunk, filename) => { + // Remove only JavaScript assets + if (!filename.endsWith('.js')) { + return; + } + + // Only chunks with a css asset should have JavaScript assets removed + let hasCssFile = false; + for (const file of chunk.files) { + if (file.endsWith('.css')) { + hasCssFile = true; + break; + } + } + + if (!hasCssFile) { + return; + } + + // Only chunks with all CSS entry dependencies should have JavaScript assets removed + let cssOnly = false; + const entryModules = compilation.chunkGraph.getChunkEntryModulesIterable(chunk); + for (const module of entryModules) { + cssOnly = module.dependencies.every( + (dependency: {}) => dependency.constructor.name === 'CssDependency', + ); + + if (!cssOnly) { + break; + } + } + + if (cssOnly) { + chunk.files.delete(filename); + compilation.deleteAsset(filename); + } + }); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/transfer-size-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/transfer-size-plugin.ts new file mode 100644 index 000000000000..77122667452f --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/transfer-size-plugin.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { promisify } from 'node:util'; +import { brotliCompress } from 'node:zlib'; +import { Compiler } from 'webpack'; +import { addWarning } from '../../../utils/webpack-diagnostics'; + +const brotliCompressAsync = promisify(brotliCompress); + +const PLUGIN_NAME = 'angular-transfer-size-estimator'; + +export class TransferSizePlugin { + constructor() {} + + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + async (compilationAssets) => { + const actions = []; + for (const assetName of Object.keys(compilationAssets)) { + if (!assetName.endsWith('.js') && !assetName.endsWith('.css')) { + continue; + } + + const scriptAsset = compilation.getAsset(assetName); + if (!scriptAsset || scriptAsset.source.size() <= 0) { + continue; + } + + actions.push( + brotliCompressAsync(scriptAsset.source.source()) + .then((result) => { + compilation.updateAsset( + assetName, + (s) => s, + (assetInfo) => ({ + ...assetInfo, + estimatedTransferSize: result.length, + }), + ); + }) + .catch((error) => { + addWarning( + compilation, + `Unable to calculate estimated transfer size for '${assetName}'. Reason: ${error.message}`, + ); + }), + ); + } + + await Promise.all(actions); + }, + ); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/typescript.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/typescript.ts new file mode 100644 index 000000000000..129c869036d2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/typescript.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { CompilerOptions } from '@angular/compiler-cli'; +import { AngularWebpackPlugin } from '@ngtools/webpack'; +import { ScriptTarget } from 'typescript'; +import { WebpackConfigOptions } from '../../../utils/build-options'; + +export function createIvyPlugin( + wco: WebpackConfigOptions, + aot: boolean, + tsconfig: string, +): AngularWebpackPlugin { + const { buildOptions, tsConfig } = wco; + const optimize = buildOptions.optimization.scripts; + + const compilerOptions: CompilerOptions = { + sourceMap: buildOptions.sourceMap.scripts, + declaration: false, + declarationMap: false, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */. + removeComments: false, + }; + + if (tsConfig.options.target === undefined || tsConfig.options.target < ScriptTarget.ES2022) { + compilerOptions.target = ScriptTarget.ES2022; + // If 'useDefineForClassFields' is already defined in the users project leave the value as is. + // Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995 + // which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well. + compilerOptions.useDefineForClassFields ??= false; + + wco.logger.warn( + 'TypeScript compiler options "target" and "useDefineForClassFields" are set to "ES2022" and ' + + '"false" respectively by the Angular CLI. To control ECMA version and features use the Browserslist configuration. ' + + 'For more information, see https://angular.dev/tools/cli/build#configuring-browser-compatibility\n' + + `NOTE: You can set the "target" to "ES2022" in the project's tsconfig to remove this warning.`, + ); + } + + if (buildOptions.preserveSymlinks !== undefined) { + compilerOptions.preserveSymlinks = buildOptions.preserveSymlinks; + } + + const fileReplacements: Record = {}; + if (buildOptions.fileReplacements) { + for (const replacement of buildOptions.fileReplacements) { + fileReplacements[replacement.replace] = replacement.with; + } + } + + return new AngularWebpackPlugin({ + tsconfig, + compilerOptions, + fileReplacements, + jitMode: !aot, + emitNgModuleScope: !optimize, + inlineStyleFileExtension: buildOptions.inlineStyleLanguage ?? 'css', + }); +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/watch-files-logs-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/watch-files-logs-plugin.ts new file mode 100644 index 000000000000..d56e0e53db32 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/watch-files-logs-plugin.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { Compiler } from 'webpack'; + +const PLUGIN_NAME = 'angular.watch-files-logs-plugin'; + +export class WatchFilesLogsPlugin { + apply(compiler: Compiler) { + compiler.hooks.watchRun.tap(PLUGIN_NAME, ({ modifiedFiles, removedFiles }) => { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + const logger = compilation.getLogger(PLUGIN_NAME); + if (modifiedFiles?.size) { + logger.log(`Modified files:\n${[...modifiedFiles].join('\n')}\n`); + } + + if (removedFiles?.size) { + logger.log(`Removed files:\n${[...removedFiles].join('\n')}\n`); + } + }); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/utils/async-chunks.ts b/packages/angular_devkit/build_angular/src/tools/webpack/utils/async-chunks.ts new file mode 100644 index 000000000000..00eb0740fb11 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/utils/async-chunks.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { StatsChunk, StatsCompilation } from 'webpack'; +import { NormalizedEntryPoint } from './helpers'; + +/** + * Webpack stats may incorrectly mark extra entry points `initial` chunks, when + * they are actually loaded asynchronously and thus not in the main bundle. This + * function finds extra entry points in Webpack stats and corrects this value + * whereever necessary. Does not modify {@param webpackStats}. + */ +export function markAsyncChunksNonInitial( + webpackStats: StatsCompilation, + extraEntryPoints: NormalizedEntryPoint[], +): StatsChunk[] { + const { chunks = [], entrypoints: entryPoints = {} } = webpackStats; + + // Find all Webpack chunk IDs not injected into the main bundle. We don't have + // to worry about transitive dependencies because extra entry points cannot be + // depended upon in Webpack, thus any extra entry point with `inject: false`, + // **cannot** be loaded in main bundle. + const asyncChunkIds = extraEntryPoints + .filter((entryPoint) => !entryPoint.inject && entryPoints[entryPoint.bundleName]) + .flatMap((entryPoint) => + entryPoints[entryPoint.bundleName].chunks?.filter((n) => n !== 'runtime'), + ); + + // Find chunks for each ID. + const asyncChunks = asyncChunkIds.map((chunkId) => { + const chunk = chunks.find((chunk) => chunk.id === chunkId); + if (!chunk) { + throw new Error(`Failed to find chunk (${chunkId}) in set:\n${JSON.stringify(chunks)}`); + } + + return chunk; + }); + + // A chunk is considered `initial` only if Webpack already belives it to be initial + // and the application developer did not mark it async via an extra entry point. + return chunks.map((chunk) => { + return asyncChunks.find((asyncChunk) => asyncChunk === chunk) + ? { + ...chunk, + initial: false, + } + : chunk; + }); +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/utils/helpers.ts b/packages/angular_devkit/build_angular/src/tools/webpack/utils/helpers.ts new file mode 100644 index 000000000000..e359597ab45e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/utils/helpers.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ObjectPattern } from 'copy-webpack-plugin'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { globSync } from 'tinyglobby'; +import type { Configuration, WebpackOptionsNormalized } from 'webpack'; +import { + AssetPatternClass, + OutputHashing, + ScriptElement, + StyleElement, +} from '../../../builders/browser/schema'; +import { WebpackConfigOptions } from '../../../utils/build-options'; +import { VERSION } from '../../../utils/package-version'; + +export interface HashFormat { + chunk: string; + extract: string; + file: string; + script: string; +} + +export type WebpackStatsOptions = Exclude; + +export function getOutputHashFormat(outputHashing = OutputHashing.None, length = 20): HashFormat { + const hashTemplate = `.[contenthash:${length}]`; + + switch (outputHashing) { + case 'media': + return { + chunk: '', + extract: '', + file: hashTemplate, + script: '', + }; + case 'bundles': + return { + chunk: hashTemplate, + extract: hashTemplate, + file: '', + script: hashTemplate, + }; + case 'all': + return { + chunk: hashTemplate, + extract: hashTemplate, + file: hashTemplate, + script: hashTemplate, + }; + case 'none': + default: + return { + chunk: '', + extract: '', + file: '', + script: '', + }; + } +} + +export type NormalizedEntryPoint = Required>; + +export function normalizeExtraEntryPoints( + extraEntryPoints: (ScriptElement | StyleElement)[], + defaultBundleName: string, +): NormalizedEntryPoint[] { + return extraEntryPoints.map((entry) => { + if (typeof entry === 'string') { + return { input: entry, inject: true, bundleName: defaultBundleName }; + } + + const { inject = true, ...newEntry } = entry; + let bundleName; + if (entry.bundleName) { + bundleName = entry.bundleName; + } else if (!inject) { + // Lazy entry points use the file name as bundle name. + bundleName = path.parse(entry.input).name; + } else { + bundleName = defaultBundleName; + } + + return { ...newEntry, inject, bundleName }; + }); +} + +export function assetNameTemplateFactory(hashFormat: HashFormat): (resourcePath: string) => string { + const visitedFiles = new Map(); + + return (resourcePath: string) => { + if (hashFormat.file) { + // File names are hashed therefore we don't need to handle files with the same file name. + return `[name]${hashFormat.file}.[ext]`; + } + + const filename = path.basename(resourcePath); + // Check if the file with the same name has already been processed. + const visited = visitedFiles.get(filename); + if (!visited) { + // Not visited. + visitedFiles.set(filename, resourcePath); + + return filename; + } else if (visited === resourcePath) { + // Same file. + return filename; + } + + // File has the same name but it's in a different location. + return '[path][name].[ext]'; + }; +} + +export function getInstrumentationExcludedPaths( + root: string, + excludedPaths: string[], +): Set { + const excluded = new Set(); + + for (const excludeGlob of excludedPaths) { + const excludePath = excludeGlob[0] === '/' ? excludeGlob.slice(1) : excludeGlob; + globSync(excludePath, { cwd: root }).forEach((p) => excluded.add(path.join(root, p))); + } + + return excluded; +} + +export function normalizeGlobalStyles(styleEntrypoints: StyleElement[]): { + entryPoints: Record; + noInjectNames: string[]; +} { + const entryPoints: Record = {}; + const noInjectNames: string[] = []; + + if (styleEntrypoints.length === 0) { + return { entryPoints, noInjectNames }; + } + + for (const style of normalizeExtraEntryPoints(styleEntrypoints, 'styles')) { + // Add style entry points. + entryPoints[style.bundleName] ??= []; + entryPoints[style.bundleName].push(style.input); + + // Add non injected styles to the list. + if (!style.inject) { + noInjectNames.push(style.bundleName); + } + } + + return { entryPoints, noInjectNames }; +} + +export function getCacheSettings( + wco: WebpackConfigOptions, + angularVersion: string, +): WebpackOptionsNormalized['cache'] { + const { enabled, path: cacheDirectory } = wco.buildOptions.cache; + if (enabled) { + return { + type: 'filesystem', + profile: wco.buildOptions.verbose, + cacheDirectory: path.join(cacheDirectory, 'angular-webpack'), + maxMemoryGenerations: 1, + // We use the versions and build options as the cache name. The Webpack configurations are too + // dynamic and shared among different build types: test, build and serve. + // None of which are "named". + name: createHash('sha1') + .update(angularVersion) + .update(VERSION) + .update(wco.projectRoot) + .update(JSON.stringify(wco.tsConfig)) + .update( + JSON.stringify({ + ...wco.buildOptions, + // Needed because outputPath changes on every build when using i18n extraction + // https://github.com/angular/angular-cli/blob/736a5f89deaca85f487b78aec9ff66d4118ceb6a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts#L264-L265 + outputPath: undefined, + }), + ) + .digest('hex'), + }; + } + + if (wco.buildOptions.watch) { + return { + type: 'memory', + maxGenerations: 1, + }; + } + + return false; +} + +export function globalScriptsByBundleName( + scripts: ScriptElement[], +): { bundleName: string; inject: boolean; paths: string[] }[] { + return normalizeExtraEntryPoints(scripts, 'scripts').reduce( + (prev: { bundleName: string; paths: string[]; inject: boolean }[], curr) => { + const { bundleName, inject, input } = curr; + + const existingEntry = prev.find((el) => el.bundleName === bundleName); + if (existingEntry) { + if (existingEntry.inject && !inject) { + // All entries have to be lazy for the bundle to be lazy. + throw new Error(`The ${bundleName} bundle is mixing injected and non-injected scripts.`); + } + + existingEntry.paths.push(input); + } else { + prev.push({ + bundleName, + inject, + paths: [input], + }); + } + + return prev; + }, + [], + ); +} + +export function assetPatterns(root: string, assets: AssetPatternClass[]) { + return assets.map((asset: AssetPatternClass, index: number): ObjectPattern => { + // Resolve input paths relative to workspace root and add slash at the end. + // eslint-disable-next-line prefer-const + let { input, output = '', ignore = [], glob } = asset; + input = path.resolve(root, input).replace(/\\/g, '/'); + input = input.endsWith('/') ? input : input + '/'; + output = output.endsWith('/') ? output : output + '/'; + + if (output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return { + context: input, + // Now we remove starting slash to make Webpack place it from the output root. + to: output.replace(/^\//, ''), + from: glob, + noErrorOnMissing: true, + force: true, + globOptions: { + dot: true, + followSymbolicLinks: !!asset.followSymlinks, + ignore: [ + '.gitkeep', + '**/.DS_Store', + '**/Thumbs.db', + // Negate patterns needs to be absolute because copy-webpack-plugin uses absolute globs which + // causes negate patterns not to match. + // See: https://github.com/webpack-contrib/copy-webpack-plugin/issues/498#issuecomment-639327909 + ...ignore, + ].map((i) => path.posix.join(input, i)), + }, + priority: index, + }; + }); +} + +export function getStatsOptions(verbose = false): WebpackStatsOptions { + const webpackOutputOptions: WebpackStatsOptions = { + all: false, // Fallback value for stats options when an option is not defined. It has precedence over local webpack defaults. + colors: true, + hash: true, // required by custom stat output + timings: true, // required by custom stat output + chunks: true, // required by custom stat output + builtAt: true, // required by custom stat output + warnings: true, + errors: true, + assets: true, // required by custom stat output + cachedAssets: true, // required for bundle size calculators + + // Needed for markAsyncChunksNonInitial. + ids: true, + entrypoints: true, + }; + + const verboseWebpackOutputOptions: WebpackStatsOptions = { + // The verbose output will most likely be piped to a file, so colors just mess it up. + colors: false, + usedExports: true, + optimizationBailout: true, + reasons: true, + children: true, + assets: true, + version: true, + chunkModules: true, + errorDetails: true, + errorStack: true, + moduleTrace: true, + logging: 'verbose', + modulesSpace: Infinity, + }; + + return verbose + ? { ...webpackOutputOptions, ...verboseWebpackOutputOptions } + : webpackOutputOptions; +} + +/** + * Checks if a specified package is installed in the given workspace. + * + * @param root - The root directory of the workspace. + * @param name - The name of the package to check for. + * @returns `true` if the package is installed, `false` otherwise. + */ +export function isPackageInstalled(root: string, name: string): boolean { + try { + const resolve = createRequire(root + '/').resolve; + resolve(name); + + return true; + } catch { + return false; + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/utils/stats.ts b/packages/angular_devkit/build_angular/src/tools/webpack/utils/stats.ts new file mode 100644 index 000000000000..3bd046cbae15 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/utils/stats.ts @@ -0,0 +1,367 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + BudgetCalculatorResult, + BundleStats, + generateBuildStatsTable, +} from '@angular/build/private'; +import { WebpackLoggingCallback } from '@angular-devkit/build-webpack'; +import { logging } from '@angular-devkit/core'; +import assert from 'node:assert'; +import * as path from 'node:path'; +import { Configuration, StatsCompilation } from 'webpack'; +import { Schema as BrowserBuilderOptions } from '../../../builders/browser/schema'; +import { normalizeOptimization } from '../../../utils'; +import { colors as ansiColors } from '../../../utils/color'; +import { markAsyncChunksNonInitial } from './async-chunks'; +import { WebpackStatsOptions, getStatsOptions, normalizeExtraEntryPoints } from './helpers'; + +function getBuildDuration(webpackStats: StatsCompilation): number { + assert(webpackStats.builtAt, 'buildAt cannot be undefined'); + assert(webpackStats.time, 'time cannot be undefined'); + + return Date.now() - webpackStats.builtAt + webpackStats.time; +} + +function generateBundleStats(info: { + rawSize?: number; + estimatedTransferSize?: number; + files?: string[]; + names?: string[]; + initial?: boolean; + rendered?: boolean; +}): BundleStats { + const rawSize = typeof info.rawSize === 'number' ? info.rawSize : '-'; + const estimatedTransferSize = + typeof info.estimatedTransferSize === 'number' ? info.estimatedTransferSize : '-'; + const files = + info.files + ?.filter((f) => !f.endsWith('.map')) + .map((f) => path.basename(f)) + .join(', ') ?? ''; + const names = info.names?.length ? info.names.join(', ') : '-'; + const initial = !!info.initial; + + return { + initial, + stats: [files, names, rawSize, estimatedTransferSize], + }; +} + +// We use this cache because we can have multiple builders running in the same process, +// where each builder has different output path. + +// Ideally, we should create the logging callback as a factory, but that would need a refactoring. +const runsCache = new Set(); + +function statsToString( + json: StatsCompilation, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + statsConfig: any, + budgetFailures?: BudgetCalculatorResult[], +): string { + if (!json.chunks?.length) { + return ''; + } + + const colors = statsConfig.colors; + const rs = (x: string) => (colors ? ansiColors.reset(x) : x); + const w = (x: string) => (colors ? ansiColors.bold.white(x) : x); + + const changedChunksStats: BundleStats[] = []; + let hasEstimatedTransferSizes = false; + + const isFirstRun = !runsCache.has(json.outputPath || ''); + + for (const chunk of json.chunks) { + // During first build we want to display unchanged chunks + // but unchanged cached chunks are always marked as not rendered. + if (!isFirstRun && !chunk.rendered) { + continue; + } + + const assets = json.assets?.filter((asset) => chunk.files?.includes(asset.name)); + let rawSize = 0; + let estimatedTransferSize; + if (assets) { + for (const asset of assets) { + if (asset.name.endsWith('.map')) { + continue; + } + + rawSize += asset.size; + + if (typeof asset.info.estimatedTransferSize === 'number') { + if (estimatedTransferSize === undefined) { + estimatedTransferSize = 0; + hasEstimatedTransferSizes = true; + } + estimatedTransferSize += asset.info.estimatedTransferSize; + } + } + } + changedChunksStats.push(generateBundleStats({ ...chunk, rawSize, estimatedTransferSize })); + } + const unchangedChunkNumber = json.chunks.length - changedChunksStats.length; + + runsCache.add(json.outputPath || ''); + + const statsTable = generateBuildStatsTable( + changedChunksStats, + colors, + unchangedChunkNumber === 0, + hasEstimatedTransferSizes, + budgetFailures, + ); + + // In some cases we do things outside of webpack context + // Such us index generation, service worker augmentation etc... + // This will correct the time and include these. + const time = getBuildDuration(json); + + return rs( + `\n${statsTable}\n\n` + + (unchangedChunkNumber > 0 ? `${unchangedChunkNumber} unchanged chunks\n\n` : '') + + `Build at: ${w(new Date().toISOString())} - Hash: ${w(json.hash || '')} - Time: ${w('' + time)}ms`, + ); +} + +export function statsWarningsToString( + json: StatsCompilation, + statsConfig: WebpackStatsOptions, +): string { + const colors = statsConfig.colors; + const c = (x: string) => (colors ? ansiColors.reset.cyan(x) : x); + const y = (x: string) => (colors ? ansiColors.reset.yellow(x) : x); + const yb = (x: string) => (colors ? ansiColors.reset.yellowBright(x) : x); + + const warnings = json.warnings ? [...json.warnings] : []; + if (json.children) { + warnings.push(...json.children.map((c) => c.warnings ?? []).reduce((a, b) => [...a, ...b], [])); + } + + let output = ''; + for (const warning of warnings) { + if (typeof warning === 'string') { + output += yb(`Warning: ${warning}\n\n`); + } else { + let file = warning.file || warning.moduleName; + // Clean up warning paths + // Ex: ./src/app/styles.scss.webpack[javascript/auto]!=!./node_modules/css-loader/dist/cjs.js.... + // to ./src/app/styles.scss.webpack + if (file && !statsConfig.errorDetails) { + const webpackPathIndex = file.indexOf('.webpack['); + if (webpackPathIndex !== -1) { + file = file.substring(0, webpackPathIndex); + } + } + + if (file) { + output += c(file); + if (warning.loc) { + output += ':' + yb(warning.loc); + } + output += ' - '; + } + if (!/^warning/i.test(warning.message)) { + output += y('Warning: '); + } + output += `${warning.message}\n\n`; + } + } + + return output ? '\n' + output : output; +} + +export function statsErrorsToString( + json: StatsCompilation, + statsConfig: WebpackStatsOptions, +): string { + const colors = statsConfig.colors; + const c = (x: string) => (colors ? ansiColors.reset.cyan(x) : x); + const yb = (x: string) => (colors ? ansiColors.reset.yellowBright(x) : x); + const r = (x: string) => (colors ? ansiColors.reset.redBright(x) : x); + + const errors = json.errors ? [...json.errors] : []; + if (json.children) { + errors.push(...json.children.map((c) => c?.errors || []).reduce((a, b) => [...a, ...b], [])); + } + + let output = ''; + for (const error of errors) { + if (typeof error === 'string') { + output += r(`Error: ${error}\n\n`); + } else { + let file = error.file || error.moduleName; + // Clean up error paths + // Ex: ./src/app/styles.scss.webpack[javascript/auto]!=!./node_modules/css-loader/dist/cjs.js.... + // to ./src/app/styles.scss.webpack + if (file && !statsConfig.errorDetails) { + const webpackPathIndex = file.indexOf('.webpack['); + if (webpackPathIndex !== -1) { + file = file.substring(0, webpackPathIndex); + } + } + + if (file) { + output += c(file); + if (error.loc) { + output += ':' + yb(error.loc); + } + output += ' - '; + } + + // In most cases webpack will add stack traces to error messages. + // This below cleans up the error from stacks. + // See: https://github.com/webpack/webpack/issues/15980 + const index = error.message.search(/[\n\s]+at /); + let message = + statsConfig.errorStack || index === -1 ? error.message : error.message.substring(0, index); + + // Clean up error message paths when not verbose + // Ex: Execution of module code from module graph (./src/styles.scss.webpack[javascript/auto]!=!...) failed + // to Execution of module code from module graph (./src/styles.scss) failed + if (message && !statsConfig.errorDetails) { + message = message.replace(/([^(\s]+)\.webpack\[[^\]]+\]!=![^\s)]+/g, '$1'); + } + + if (!/^error/i.test(message)) { + output += r('Error: '); + } + output += `${message}\n\n`; + } + } + + return output ? '\n' + output : output; +} + +export function statsHasErrors(json: StatsCompilation): boolean { + return !!(json.errors?.length || json.children?.some((c) => c.errors?.length)); +} + +export function statsHasWarnings(json: StatsCompilation): boolean { + return !!(json.warnings?.length || json.children?.some((c) => c.warnings?.length)); +} + +export function createWebpackLoggingCallback( + options: BrowserBuilderOptions, + logger: logging.LoggerApi, +): WebpackLoggingCallback { + const { verbose = false, scripts = [], styles = [] } = options; + const extraEntryPoints = [ + ...normalizeExtraEntryPoints(styles, 'styles'), + ...normalizeExtraEntryPoints(scripts, 'scripts'), + ]; + + return (stats, config) => { + if (verbose && config.stats !== false) { + const statsOptions = config.stats === true ? undefined : config.stats; + logger.info(stats.toString(statsOptions)); + } + + const rawStats = stats.toJson(getStatsOptions(false)); + const webpackStats = { + ...rawStats, + chunks: markAsyncChunksNonInitial(rawStats, extraEntryPoints), + }; + + webpackStatsLogger(logger, webpackStats, config); + }; +} + +export interface BuildEventStats { + aot: boolean; + optimization: boolean; + allChunksCount: number; + lazyChunksCount: number; + initialChunksCount: number; + changedChunksCount?: number; + durationInMs: number; + cssSizeInBytes: number; + jsSizeInBytes: number; + ngComponentCount: number; +} + +export function generateBuildEventStats( + webpackStats: StatsCompilation, + browserBuilderOptions: BrowserBuilderOptions, +): BuildEventStats { + const { chunks = [], assets = [] } = webpackStats; + + let jsSizeInBytes = 0; + let cssSizeInBytes = 0; + let initialChunksCount = 0; + let ngComponentCount = 0; + let changedChunksCount = 0; + + const allChunksCount = chunks.length; + const isFirstRun = !runsCache.has(webpackStats.outputPath || ''); + + const chunkFiles = new Set(); + for (const chunk of chunks) { + if (!isFirstRun && chunk.rendered) { + changedChunksCount++; + } + + if (chunk.initial) { + initialChunksCount++; + } + + for (const file of chunk.files ?? []) { + chunkFiles.add(file); + } + } + + for (const asset of assets) { + if (asset.name.endsWith('.map') || !chunkFiles.has(asset.name)) { + continue; + } + + if (asset.name.endsWith('.js')) { + jsSizeInBytes += asset.size; + ngComponentCount += asset.info.ngComponentCount ?? 0; + } else if (asset.name.endsWith('.css')) { + cssSizeInBytes += asset.size; + } + } + + return { + optimization: !!normalizeOptimization(browserBuilderOptions.optimization).scripts, + aot: browserBuilderOptions.aot !== false, + allChunksCount, + lazyChunksCount: allChunksCount - initialChunksCount, + initialChunksCount, + changedChunksCount, + durationInMs: getBuildDuration(webpackStats), + cssSizeInBytes, + jsSizeInBytes, + ngComponentCount, + }; +} + +export function webpackStatsLogger( + logger: logging.LoggerApi, + json: StatsCompilation, + config: Configuration, + budgetFailures?: BudgetCalculatorResult[], +): void { + logger.info(statsToString(json, config.stats, budgetFailures)); + + if (typeof config.stats !== 'object') { + throw new Error('Invalid Webpack stats configuration.'); + } + + if (statsHasWarnings(json)) { + logger.warn(statsWarningsToString(json, config.stats)); + } + + if (statsHasErrors(json)) { + logger.error(statsErrorsToString(json, config.stats)); + } +} diff --git a/packages/angular_devkit/build_angular/src/transforms.ts b/packages/angular_devkit/build_angular/src/transforms.ts new file mode 100644 index 000000000000..6ae0678cd2da --- /dev/null +++ b/packages/angular_devkit/build_angular/src/transforms.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export type ExecutionTransformer = (input: T) => T | Promise; diff --git a/packages/angular_devkit/build_angular/src/utils/action-executor.ts b/packages/angular_devkit/build_angular/src/utils/action-executor.ts new file mode 100644 index 000000000000..716729fe86ed --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/action-executor.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import Piscina from 'piscina'; +import { InlineOptions } from './bundle-inline-options'; +import { maxWorkers } from './environment-options'; +import { I18nOptions } from './i18n-webpack'; + +const workerFile = require.resolve('./process-bundle'); + +export class BundleActionExecutor { + private workerPool?: Piscina; + + constructor(private workerOptions: { i18n: I18nOptions }) {} + + private ensureWorkerPool(): Piscina { + if (this.workerPool) { + return this.workerPool; + } + + this.workerPool = new Piscina({ + filename: workerFile, + name: 'inlineLocales', + workerData: this.workerOptions, + maxThreads: maxWorkers, + recordTiming: false, + }); + + return this.workerPool; + } + + async inline( + action: InlineOptions, + ): Promise<{ file: string; diagnostics: { type: string; message: string }[]; count: number }> { + return this.ensureWorkerPool().run(action, { name: 'inlineLocales' }); + } + + inlineAll(actions: Iterable) { + return BundleActionExecutor.executeAll(actions, (action) => this.inline(action)); + } + + private static async *executeAll( + actions: Iterable, + executor: (action: I) => Promise, + ): AsyncIterable { + const executions = new Map, Promise<[Promise, O]>>(); + for (const action of actions) { + const execution = executor(action); + executions.set( + execution, + execution.then((result) => [execution, result]), + ); + } + + while (executions.size > 0) { + const [execution, result] = await Promise.race(executions.values()); + executions.delete(execution); + yield result; + } + } + + stop(): void { + if (this.workerPool) { + void this.workerPool.destroy(); + } + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/build-options.ts b/packages/angular_devkit/build_angular/src/utils/build-options.ts new file mode 100644 index 000000000000..9ffd13f517d3 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/build-options.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import type { ParsedConfiguration } from '@angular/compiler-cli'; +import { logging } from '@angular-devkit/core'; +import { + AssetPatternClass, + Budget, + CrossOrigin, + I18NTranslation, + IndexUnion, + InlineStyleLanguage, + Localize, + OutputHashing, + ScriptElement, + SourceMapClass, + StyleElement, +} from '../builders/browser/schema'; +import { Schema as DevServerSchema } from '../builders/dev-server/schema'; +import { NormalizedCachedOptions } from './normalize-cache'; +import { NormalizedFileReplacement } from './normalize-file-replacements'; +import { NormalizedOptimizationOptions } from './normalize-optimization'; + +export interface BuildOptions { + optimization: NormalizedOptimizationOptions; + environment?: string; + outputPath: string; + resourcesOutputPath?: string; + aot?: boolean; + sourceMap: SourceMapClass; + vendorChunk?: boolean; + commonChunk?: boolean; + baseHref?: string; + deployUrl?: string; + verbose?: boolean; + progress?: boolean; + localize?: Localize; + i18nMissingTranslation?: I18NTranslation; + externalDependencies?: string[]; + watch?: boolean; + outputHashing?: OutputHashing; + poll?: number; + index?: IndexUnion; + deleteOutputPath?: boolean; + preserveSymlinks?: boolean; + extractLicenses?: boolean; + buildOptimizer?: boolean; + namedChunks?: boolean; + crossOrigin?: CrossOrigin; + subresourceIntegrity?: boolean; + serviceWorker?: boolean; + webWorkerTsConfig?: string; + statsJson: boolean; + hmr?: boolean; + main: string; + polyfills: string[]; + budgets: Budget[]; + assets: AssetPatternClass[]; + scripts: ScriptElement[]; + styles: StyleElement[]; + stylePreprocessorOptions?: { includePaths: string[] }; + platform?: 'browser' | 'server'; + fileReplacements: NormalizedFileReplacement[]; + inlineStyleLanguage?: InlineStyleLanguage; + allowedCommonJsDependencies?: string[]; + cache: NormalizedCachedOptions; + codeCoverage?: boolean; + codeCoverageExclude?: string[]; + supportedBrowsers?: string[]; +} + +export interface WebpackDevServerOptions + extends BuildOptions, + Omit {} + +export interface WebpackConfigOptions { + root: string; + logger: logging.Logger; + projectRoot: string; + sourceRoot?: string; + buildOptions: T; + tsConfig: ParsedConfiguration; + tsConfigPath: string; + projectName: string; +} diff --git a/packages/angular_devkit/build_angular/src/utils/bundle-inline-options.ts b/packages/angular_devkit/build_angular/src/utils/bundle-inline-options.ts new file mode 100644 index 000000000000..68c53b06777c --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/bundle-inline-options.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export interface InlineOptions { + filename: string; + code: string; + map?: string; + outputPath: string; + missingTranslation?: 'warning' | 'error' | 'ignore'; + setLocale?: boolean; +} diff --git a/packages/angular_devkit/build_angular/src/utils/color.ts b/packages/angular_devkit/build_angular/src/utils/color.ts new file mode 100644 index 000000000000..3e6eac3c63c8 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/color.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import * as ansiColors from 'ansi-colors'; +import { WriteStream } from 'node:tty'; + +function supportColor(): boolean { + if (process.env.FORCE_COLOR !== undefined) { + // 2 colors: FORCE_COLOR = 0 (Disables colors), depth 1 + // 16 colors: FORCE_COLOR = 1, depth 4 + // 256 colors: FORCE_COLOR = 2, depth 8 + // 16,777,216 colors: FORCE_COLOR = 3, depth 16 + // See: https://nodejs.org/dist/latest-v12.x/docs/api/tty.html#tty_writestream_getcolordepth_env + // and https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/tty.js#L106; + switch (process.env.FORCE_COLOR) { + case '': + case 'true': + case '1': + case '2': + case '3': + return true; + default: + return false; + } + } + + if (process.stdout instanceof WriteStream) { + return process.stdout.hasColors(); + } + + return false; +} + +// Create a separate instance to prevent unintended global changes to the color configuration +const colors = ansiColors.create(); +colors.enabled = supportColor(); + +export { colors }; diff --git a/packages/angular_devkit/build_angular/src/utils/copy-assets.ts b/packages/angular_devkit/build_angular/src/utils/copy-assets.ts new file mode 100644 index 000000000000..a525cab99b34 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/copy-assets.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { glob } from 'tinyglobby'; + +export async function copyAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + basePaths: Iterable, + root: string, + changed?: Set, +) { + const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db']; + + const outputFiles: { source: string; destination: string }[] = []; + + for (const entry of entries) { + const cwd = path.resolve(root, entry.input); + const files = await glob(entry.glob, { + cwd, + dot: true, + ignore: entry.ignore ? defaultIgnore.concat(entry.ignore) : defaultIgnore, + followSymbolicLinks: entry.followSymlinks ?? false, + }); + + const directoryExists = new Set(); + + for (const file of files) { + const src = path.join(cwd, file); + if (changed && !changed.has(src)) { + continue; + } + + const filePath = entry.flatten ? path.basename(file) : file; + + outputFiles.push({ source: src, destination: path.join(entry.output, filePath) }); + + for (const base of basePaths) { + const dest = path.join(base, entry.output, filePath); + const dir = path.dirname(dest); + if (!directoryExists.has(dir)) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + directoryExists.add(dir); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE); + } + } + } + + return outputFiles; +} diff --git a/packages/angular_devkit/build_angular/src/utils/default-progress.ts b/packages/angular_devkit/build_angular/src/utils/default-progress.ts new file mode 100644 index 000000000000..a924a2fa9689 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/default-progress.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export function defaultProgress(progress: boolean | undefined): boolean { + if (progress === undefined) { + return process.stdout.isTTY === true; + } + + return progress; +} diff --git a/packages/angular_devkit/build_angular/src/utils/environment-options.ts b/packages/angular_devkit/build_angular/src/utils/environment-options.ts new file mode 100644 index 000000000000..9d53be82ce79 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/environment-options.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { availableParallelism } from 'node:os'; + +function isDisabled(variable: string): boolean { + return variable === '0' || variable.toLowerCase() === 'false'; +} + +function isEnabled(variable: string): boolean { + return variable === '1' || variable.toLowerCase() === 'true'; +} + +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +// Optimization and mangling +const debugOptimizeVariable = process.env['NG_BUILD_DEBUG_OPTIMIZE']; +const debugOptimize = (() => { + if (!isPresent(debugOptimizeVariable) || isDisabled(debugOptimizeVariable)) { + return { + mangle: true, + minify: true, + beautify: false, + }; + } + + const debugValue = { + mangle: false, + minify: false, + beautify: true, + }; + + if (isEnabled(debugOptimizeVariable)) { + return debugValue; + } + + for (const part of debugOptimizeVariable.split(',')) { + switch (part.trim().toLowerCase()) { + case 'mangle': + debugValue.mangle = true; + break; + case 'minify': + debugValue.minify = true; + break; + case 'beautify': + debugValue.beautify = true; + break; + } + } + + return debugValue; +})(); + +const mangleVariable = process.env['NG_BUILD_MANGLE']; +export const allowMangle = isPresent(mangleVariable) + ? !isDisabled(mangleVariable) + : debugOptimize.mangle; + +export const shouldBeautify = debugOptimize.beautify; +export const allowMinify = debugOptimize.minify; + +/** + * Some environments, like CircleCI which use Docker report a number of CPUs by the host and not the count of available. + * This cause `Error: Call retries were exceeded` errors when trying to use them. + * + * @see https://github.com/nodejs/node/issues/28762 + * @see https://github.com/webpack-contrib/terser-webpack-plugin/issues/143 + * @see https://ithub.com/angular/angular-cli/issues/16860#issuecomment-588828079 + * + */ +const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; +export const maxWorkers = isPresent(maxWorkersVariable) + ? +maxWorkersVariable + : Math.min(4, Math.max(availableParallelism() - 1, 1)); + +const parallelTsVariable = process.env['NG_BUILD_PARALLEL_TS']; +export const useParallelTs = !isPresent(parallelTsVariable) || !isDisabled(parallelTsVariable); + +const debugPerfVariable = process.env['NG_BUILD_DEBUG_PERF']; +export const debugPerformance = isPresent(debugPerfVariable) && isEnabled(debugPerfVariable); + +const watchRootVariable = process.env['NG_BUILD_WATCH_ROOT']; +export const shouldWatchRoot = isPresent(watchRootVariable) && isEnabled(watchRootVariable); + +const typeCheckingVariable = process.env['NG_BUILD_TYPE_CHECK']; +export const useTypeChecking = + !isPresent(typeCheckingVariable) || !isDisabled(typeCheckingVariable); + +const buildLogsJsonVariable = process.env['NG_BUILD_LOGS_JSON']; +export const useJSONBuildLogs = + isPresent(buildLogsJsonVariable) && isEnabled(buildLogsJsonVariable); diff --git a/packages/angular_devkit/build_angular/src/utils/error.ts b/packages/angular_devkit/build_angular/src/utils/error.ts new file mode 100644 index 000000000000..0ca77c331d2d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/error.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert'; + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); +} diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts b/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts new file mode 100644 index 000000000000..9f639f16cb21 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { EmittedFiles } from '@angular-devkit/build-webpack'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { BundleActionExecutor } from './action-executor'; +import { InlineOptions } from './bundle-inline-options'; +import { copyAssets } from './copy-assets'; +import { assertIsError } from './error'; +import { I18nOptions } from './i18n-webpack'; +import { Spinner } from './spinner'; + +function emittedFilesToInlineOptions( + emittedFiles: EmittedFiles[], + scriptsEntryPointName: string[], + emittedPath: string, + outputPath: string, + missingTranslation: 'error' | 'warning' | 'ignore' | undefined, + context: BuilderContext, +): { options: InlineOptions[]; originalFiles: string[] } { + const options: InlineOptions[] = []; + const originalFiles: string[] = []; + for (const emittedFile of emittedFiles) { + if ( + emittedFile.asset || + emittedFile.extension !== '.js' || + (emittedFile.name && scriptsEntryPointName.includes(emittedFile.name)) + ) { + continue; + } + + const originalPath = path.join(emittedPath, emittedFile.file); + const action: InlineOptions = { + filename: emittedFile.file, + code: fs.readFileSync(originalPath, 'utf8'), + outputPath, + missingTranslation, + setLocale: emittedFile.name === 'main', + }; + originalFiles.push(originalPath); + + try { + const originalMapPath = originalPath + '.map'; + action.map = fs.readFileSync(originalMapPath, 'utf8'); + originalFiles.push(originalMapPath); + } catch (err) { + assertIsError(err); + if (err.code !== 'ENOENT') { + throw err; + } + } + + context.logger.debug(`i18n file queued for processing: ${action.filename}`); + + options.push(action); + } + + return { options, originalFiles }; +} + +export async function i18nInlineEmittedFiles( + context: BuilderContext, + emittedFiles: EmittedFiles[], + i18n: I18nOptions, + baseOutputPath: string, + outputPaths: string[], + scriptsEntryPointName: string[], + emittedPath: string, + missingTranslation: 'error' | 'warning' | 'ignore' | undefined, +): Promise { + const executor = new BundleActionExecutor({ i18n }); + let hasErrors = false; + const spinner = new Spinner(); + spinner.start('Generating localized bundles...'); + + try { + const { options, originalFiles: processedFiles } = emittedFilesToInlineOptions( + emittedFiles, + scriptsEntryPointName, + emittedPath, + baseOutputPath, + missingTranslation, + context, + ); + + for await (const result of executor.inlineAll(options)) { + context.logger.debug(`i18n file processed: ${result.file}`); + + for (const diagnostic of result.diagnostics) { + spinner.stop(); + if (diagnostic.type === 'error') { + hasErrors = true; + context.logger.error(diagnostic.message); + } else { + context.logger.warn(diagnostic.message); + } + spinner.start(); + } + } + + // Copy any non-processed files into the output locations + await copyAssets( + [ + { + glob: '**/*', + input: emittedPath, + output: '', + ignore: [...processedFiles].map((f) => path.relative(emittedPath, f)), + }, + ], + outputPaths, + '', + ); + } catch (err) { + assertIsError(err); + spinner.fail('Localized bundle generation failed: ' + err.message); + + return false; + } finally { + executor.stop(); + } + + if (hasErrors) { + spinner.fail('Localized bundle generation failed.'); + } else { + spinner.succeed('Localized bundle generation complete.'); + } + + return !hasErrors; +} diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts b/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts new file mode 100644 index 000000000000..ef0fd1dc2207 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { + type I18nOptions, + createI18nOptions, + createTranslationLoader, + loadTranslations, +} from '@angular/build/private'; +import { BuilderContext } from '@angular-devkit/architect'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { Schema as BrowserBuilderSchema } from '../builders/browser/schema'; +import { Schema as ServerBuilderSchema } from '../builders/server/schema'; +import { readTsconfig } from '../utils/read-tsconfig'; + +/** + * The base module location used to search for locale specific data. + */ +const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +// Re-export for use within Webpack related builders +export { I18nOptions, loadTranslations }; + +export async function configureI18nBuild( + context: BuilderContext, + options: T, +): Promise<{ + buildOptions: T; + i18n: I18nOptions; +}> { + if (!context.target) { + throw new Error('The builder requires a target.'); + } + + const buildOptions = { ...options }; + const tsConfig = await readTsconfig(buildOptions.tsConfig, context.workspaceRoot); + const metadata = await context.getProjectMetadata(context.target); + const i18n = createI18nOptions(metadata, buildOptions.localize, context.logger); + + // No additional processing needed if no inlining requested and no source locale defined. + if (!i18n.shouldInline && !i18n.hasDefinedSourceLocale) { + return { buildOptions, i18n }; + } + + const projectRoot = path.join(context.workspaceRoot, (metadata.root as string) || ''); + // The trailing slash is required to signal that the path is a directory and not a file. + const projectRequire = createRequire(projectRoot + '/'); + const localeResolver = (locale: string) => + projectRequire.resolve(path.join(LOCALE_DATA_BASE_MODULE, locale)); + + // Load locale data and translations (if present) + let loader; + const usedFormats = new Set(); + for (const [locale, desc] of Object.entries(i18n.locales)) { + if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) { + continue; + } + + let localeDataPath = findLocaleDataPath(locale, localeResolver); + if (!localeDataPath) { + const [first] = locale.split('-'); + if (first) { + localeDataPath = findLocaleDataPath(first.toLowerCase(), localeResolver); + if (localeDataPath) { + context.logger.warn( + `Locale data for '${locale}' cannot be found. Using locale data for '${first}'.`, + ); + } + } + } + if (!localeDataPath) { + context.logger.warn( + `Locale data for '${locale}' cannot be found. No locale data will be included for this locale.`, + ); + } else { + desc.dataPath = localeDataPath; + } + + if (!desc.files.length) { + continue; + } + + loader ??= await createTranslationLoader(); + + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + usedFormats, + buildOptions.i18nDuplicateTranslation, + ); + + if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) { + // This limitation is only for legacy message id support (defaults to true as of 9.0) + throw new Error( + 'Localization currently only supports using one type of translation file format for the entire application.', + ); + } + } + + // If inlining store the output in a temporary location to facilitate post-processing + if (i18n.shouldInline) { + // TODO: we should likely save these in the .angular directory in the next major version. + // We'd need to do a migration to add the temp directory to gitignore. + const tempPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-i18n-')); + buildOptions.outputPath = tempPath; + + process.on('exit', () => { + try { + fs.rmSync(tempPath, { force: true, recursive: true, maxRetries: 3 }); + } catch {} + }); + } + + return { buildOptions, i18n }; +} + +function findLocaleDataPath(locale: string, resolver: (locale: string) => string): string | null { + // Remove private use subtags + const scrubbedLocale = locale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, ''); + + try { + return resolver(scrubbedLocale); + } catch { + // fallback to known existing en-US locale data as of 14.0 + return scrubbedLocale === 'en-US' ? findLocaleDataPath('en', resolver) : null; + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/index.ts b/packages/angular_devkit/build_angular/src/utils/index.ts new file mode 100644 index 000000000000..da07543fafa8 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/index.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +export * from './default-progress'; +export { deleteOutputDir, loadProxyConfiguration } from '@angular/build/private'; +export * from './normalize-file-replacements'; +export * from './normalize-asset-patterns'; +export * from './normalize-source-maps'; +export * from './normalize-optimization'; +export * from './normalize-builder-schema'; +export * from './url'; diff --git a/packages/angular_devkit/build_angular/src/utils/inline-critical-css.ts b/packages/angular_devkit/build_angular/src/utils/inline-critical-css.ts new file mode 100644 index 000000000000..cd58ea795480 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/inline-critical-css.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { readFile } from 'node:fs/promises'; + +export interface InlineCriticalCssProcessorOptions { + minify?: boolean; + deployUrl?: string; + readAsset?: (path: string) => Promise; + autoCsp?: boolean; + outputPath?: string; +} + +export class InlineCriticalCssProcessor { + constructor(protected readonly options: InlineCriticalCssProcessorOptions) {} + + async process(html: string): Promise<{ content: string; warnings: string[]; errors: string[] }> { + const warnings: string[] = []; + const errors: string[] = []; + const { outputPath, deployUrl, minify = false, readAsset } = this.options; + + const { default: Beasties } = await import('beasties'); + + const beasties = new Beasties({ + logger: { + warn: (s: string) => warnings.push(s), + error: (s: string) => errors.push(s), + info: () => {}, + }, + logLevel: 'warn', + path: outputPath, + publicPath: deployUrl, + compress: minify, + pruneSource: false, + reduceInlineStyles: false, + mergeStylesheets: false, + preload: 'media-script', + nonce: (document) => { + const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); + const cspNonce = + nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce'); + + return cspNonce ?? undefined; + }, + noscriptFallback: true, + inlineFonts: true, + }); + + beasties.readFile = (path) => { + return readAsset ? readAsset(path) : readFile(path, 'utf-8'); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const beastiesInternal = beasties as any; + const initialEmbedLinkedStylesheet = + beastiesInternal.embedLinkedStylesheet.bind(beastiesInternal); + beastiesInternal.embedLinkedStylesheet = async (link: unknown, document: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const linkEl = link as any; + const beastiesMedia = linkEl.getAttribute('data-beasties-media'); + if (beastiesMedia) { + linkEl.removeAttribute('data-beasties-media'); + linkEl.setAttribute('media', beastiesMedia); + if (linkEl.next?.name === 'noscript') { + linkEl.next.remove?.(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (document as any).querySelectorAll('script').forEach((script: any) => { + if (script.textContent?.includes('data-beasties-media')) { + script.remove?.(); + } + }); + } + + return initialEmbedLinkedStylesheet(link, document); + }; + const content = await beasties.process(html); + + return { + // Clean up value from value less attributes. + // This is caused because parse5 always requires attributes to have a string value. + // nomodule="" defer="" -> nomodule defer. + content: content.replace(/(\s(?:defer|nomodule))=""/g, '$1'), + errors, + warnings, + }; + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/inline-critical-css_spec.ts b/packages/angular_devkit/build_angular/src/utils/inline-critical-css_spec.ts new file mode 100644 index 000000000000..a5a49baec26b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/inline-critical-css_spec.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { tags } from '@angular-devkit/core'; +import { InlineCriticalCssProcessor } from './inline-critical-css'; + +describe('InlineCriticalCssProcessor', () => { + const styles: Record = { + '/dist/styles.css': ` + body { margin: 0; } + html { color: white; } + `, + '/dist/theme.css': ` + span { color: blue; } + p { color: blue; } + `, + }; + + const readAsset = async (file: string): Promise => { + const content = styles[file]; + if (content) { + return content; + } + + throw new Error('Cannot read asset.'); + }; + + const getContent = (deployUrl: string, bodyContent = ''): string => { + return ` + + + + + + ${bodyContent} + `; + }; + + it('should inline critical css', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + outputPath: '/dist/', + }); + + const { content } = await inlineFontsProcessor.process(getContent('')); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).not.toContain('color: blue'); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should inline critical css when using deployUrl', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + outputPath: '/dist/', + deployUrl: 'http://cdn.com', + }); + + const { content } = await inlineFontsProcessor.process(getContent('http://cdn.com/')); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should compress inline critical css when minify is enabled', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + minify: true, + outputPath: '/dist/', + }); + + const { content } = await inlineFontsProcessor.process(getContent('')); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).toContain(''); + }); + + it(`should process the inline 'onload' handlers if a 'autoCsp' is true`, async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + autoCsp: true, + outputPath: '/dist/', + }); + + const { content } = await inlineCssProcessor.process(getContent('')); + + expect(content).toContain( + '', + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should process the inline `onload` handlers if a CSP nonce is specified', async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + outputPath: '/dist/', + }); + + const { content } = await inlineCssProcessor.process( + getContent('', ''), + ); + + expect(content).toContain( + '', + ); + expect(content).toContain( + '', + ); + // Nonces shouldn't be added inside the `noscript` tags. + expect(content).toContain(''); + expect(content).toContain(' - - - diff --git a/tests/e2e/assets/webpack/test-app-weird/not/so/source/tsconfig.json b/tests/e2e/assets/webpack/test-app-weird/not/so/source/tsconfig.json deleted file mode 100644 index df35f5efe991..000000000000 --- a/tests/e2e/assets/webpack/test-app-weird/not/so/source/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "module": "es2015", - "moduleResolution": "node", - "target": "es5", - "noImplicitAny": false, - "sourceMap": true, - "mapRoot": "", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "lib", - "skipLibCheck": true, - "rootDir": "." - }, - "angularCompilerOptions": { - "genDir": "app/generated/", - "entryModule": "app/app.module#AppModule" - } -} diff --git a/tests/e2e/assets/webpack/test-app-weird/package.json b/tests/e2e/assets/webpack/test-app-weird/package.json deleted file mode 100644 index a31beef8a7c5..000000000000 --- a/tests/e2e/assets/webpack/test-app-weird/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "test", - "license": "MIT", - "dependencies": { - "@angular/common": ">=4.0.0-beta <5.0.0", - "@angular/compiler": ">=4.0.0-beta <5.0.0", - "@angular/compiler-cli": ">=4.0.0-beta <5.0.0", - "@angular/core": ">=4.0.0-beta <5.0.0", - "@angular/http": ">=4.0.0-beta <5.0.0", - "@angular/platform-browser": ">=4.0.0-beta <5.0.0", - "@angular/platform-browser-dynamic": ">=4.0.0-beta <5.0.0", - "@angular/platform-server": ">=4.0.0-beta <5.0.0", - "@angular/router": ">=4.0.0-beta <5.0.0", - "@ngtools/webpack": "0.0.0", - "core-js": "^2.4.1", - "rxjs": "^5.4.2", - "zone.js": "^0.8.14" - }, - "devDependencies": { - "node-sass": "^3.7.0", - "performance-now": "^0.2.0", - "preprocess-loader": "^0.2.2", - "raw-loader": "^0.5.1", - "sass-loader": "^3.2.0", - "typescript": "~2.4.2", - "webpack": "2.2.0" - } -} diff --git a/tests/e2e/assets/webpack/test-app-weird/webpack.config.js b/tests/e2e/assets/webpack/test-app-weird/webpack.config.js deleted file mode 100644 index e31c4e7c7c3a..000000000000 --- a/tests/e2e/assets/webpack/test-app-weird/webpack.config.js +++ /dev/null @@ -1,36 +0,0 @@ -const ngToolsWebpack = require('@ngtools/webpack'); - -const flags = require('./webpack.flags.json'); - -const preprocessLoader = 'preprocess-loader' + (flags.DEBUG ? '?+DEBUG' : ''); - - -module.exports = { - resolve: { - extensions: ['.ts', '.js'] - }, - entry: './not/so/source/app/main.jit.ts', - output: { - path: './dist', - publicPath: 'dist/', - filename: 'app.main.js' - }, - plugins: [ - new ngToolsWebpack.AotPlugin(require('./aotplugin.config.json')) - ], - module: { - loaders: [ - { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader', preprocessLoader] }, - { test: /\.css$/, loader: 'raw-loader' }, - { test: /\.html$/, loaders: ['raw-loader', preprocessLoader] }, - // Use preprocess to remove DEBUG only code. - { test: /\.ts$/, use: [ - { loader: '@ngtools/webpack' }, - { loader: preprocessLoader } - ] } - ] - }, - devServer: { - historyApiFallback: true - } -}; diff --git a/tests/e2e/assets/webpack/test-app-weird/webpack.flags.json b/tests/e2e/assets/webpack/test-app-weird/webpack.flags.json deleted file mode 100644 index aac05f738dc8..000000000000 --- a/tests/e2e/assets/webpack/test-app-weird/webpack.flags.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "DEBUG": false -} diff --git a/tests/e2e/assets/webpack/test-app/app/app.component.html b/tests/e2e/assets/webpack/test-app/app/app.component.html deleted file mode 100644 index 5a532db9308f..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/app.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

hello world

- lazy - -
diff --git a/tests/e2e/assets/webpack/test-app/app/app.component.scss b/tests/e2e/assets/webpack/test-app/app/app.component.scss deleted file mode 100644 index 5cde7b922336..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/app.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - background-color: blue; -} diff --git a/tests/e2e/assets/webpack/test-app/app/app.component.ts b/tests/e2e/assets/webpack/test-app/app/app.component.ts deleted file mode 100644 index 82a4059565d3..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/app.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {Component, ViewEncapsulation} from '@angular/core'; -import {MyInjectable} from './injectable'; - - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'], - encapsulation: ViewEncapsulation.None -}) -export class AppComponent { - constructor(public inj: MyInjectable) { - console.log(inj); - } -} diff --git a/tests/e2e/assets/webpack/test-app/app/app.module.ts b/tests/e2e/assets/webpack/test-app/app/app.module.ts deleted file mode 100644 index ded686868a22..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/app.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NgModule, Component } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { AppComponent } from './app.component'; - -@Component({ - selector: 'home-view', - template: 'home!' -}) -export class HomeView {} - - -@NgModule({ - declarations: [ - AppComponent, - HomeView - ], - imports: [ - BrowserModule, - RouterModule.forRoot([ - {path: 'lazy', loadChildren: './lazy.module#LazyModule'}, - {path: '', component: HomeView} - ]) - ], - bootstrap: [AppComponent] -}) -export class AppModule { } diff --git a/tests/e2e/assets/webpack/test-app/app/feature/feature.module.ts b/tests/e2e/assets/webpack/test-app/app/feature/feature.module.ts deleted file mode 100644 index f464ca028b05..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/feature/feature.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; - -@Component({ - selector: 'feature-component', - template: 'foo.html' -}) -export class FeatureComponent {} - -@NgModule({ - declarations: [ - FeatureComponent - ], - imports: [ - RouterModule.forChild([ - { path: '', component: FeatureComponent} - ]) - ] -}) -export class FeatureModule {} diff --git a/tests/e2e/assets/webpack/test-app/app/feature/lazy-feature.module.ts b/tests/e2e/assets/webpack/test-app/app/feature/lazy-feature.module.ts deleted file mode 100644 index 8fafca158b24..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/feature/lazy-feature.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-feature-comp', - template: 'lazy feature!' -}) -export class LazyFeatureComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyFeatureComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature.module#FeatureModule'} - ]), - HttpModule - ], - declarations: [LazyFeatureComponent] -}) -export class LazyFeatureModule { - constructor(http: Http) {} -} diff --git a/tests/e2e/assets/webpack/test-app/app/injectable.ts b/tests/e2e/assets/webpack/test-app/app/injectable.ts deleted file mode 100644 index 04d8486586c4..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/injectable.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {Injectable, Inject, ViewContainerRef} from '@angular/core'; -import {DOCUMENT} from '@angular/platform-browser'; - - -@Injectable() -export class MyInjectable { - constructor(public viewContainer: ViewContainerRef, @Inject(DOCUMENT) public doc) {} -} diff --git a/tests/e2e/assets/webpack/test-app/app/lazy.module.ts b/tests/e2e/assets/webpack/test-app/app/lazy.module.ts deleted file mode 100644 index 96da4de7515b..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/lazy.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-comp', - template: 'lazy!' -}) -export class LazyComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature/feature.module#FeatureModule'}, - {path: 'lazy-feature', loadChildren: './feature/lazy-feature.module#LazyFeatureModule'} - ]), - HttpModule - ], - declarations: [LazyComponent] -}) -export class LazyModule { - constructor(http: Http) {} -} - -export class SecondModule {} diff --git a/tests/e2e/assets/webpack/test-app/app/main.aot.ts b/tests/e2e/assets/webpack/test-app/app/main.aot.ts deleted file mode 100644 index 1b4503a81e31..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/main.aot.ts +++ /dev/null @@ -1,5 +0,0 @@ -import 'core-js/es7/reflect'; -import {platformBrowser} from '@angular/platform-browser'; -import {AppModuleNgFactory} from './ngfactory/app/app.module.ngfactory'; - -platformBrowser().bootstrapModuleFactory(AppModuleNgFactory); diff --git a/tests/e2e/assets/webpack/test-app/app/main.jit.ts b/tests/e2e/assets/webpack/test-app/app/main.jit.ts deleted file mode 100644 index 0a705a5daebb..000000000000 --- a/tests/e2e/assets/webpack/test-app/app/main.jit.ts +++ /dev/null @@ -1,5 +0,0 @@ -import 'core-js/es7/reflect'; -import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; -import {AppModule} from './app.module'; - -platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/tests/e2e/assets/webpack/test-app/index.html b/tests/e2e/assets/webpack/test-app/index.html deleted file mode 100644 index 89fb0893c35d..000000000000 --- a/tests/e2e/assets/webpack/test-app/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - Document - - - - - - - - diff --git a/tests/e2e/assets/webpack/test-app/package.json b/tests/e2e/assets/webpack/test-app/package.json deleted file mode 100644 index bf1af8b6102e..000000000000 --- a/tests/e2e/assets/webpack/test-app/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "test", - "license": "MIT", - "dependencies": { - "@angular/common": "^2.3.1", - "@angular/compiler": "^2.3.1", - "@angular/compiler-cli": "^2.3.1", - "@angular/core": "^2.3.1", - "@angular/http": "^2.3.1", - "@angular/platform-browser": "^2.3.1", - "@angular/platform-browser-dynamic": "^2.3.1", - "@angular/platform-server": "^2.3.1", - "@angular/router": "^3.3.1", - "@ngtools/webpack": "0.0.0", - "core-js": "^2.4.1", - "rxjs": "^5.4.2", - "zone.js": "^0.8.14" - }, - "devDependencies": { - "node-sass": "^3.7.0", - "performance-now": "^0.2.0", - "raw-loader": "^0.5.1", - "sass-loader": "^3.2.0", - "typescript": "~2.4.2", - "webpack": "2.2.0" - } -} diff --git a/tests/e2e/assets/webpack/test-app/tsconfig.json b/tests/e2e/assets/webpack/test-app/tsconfig.json deleted file mode 100644 index c245563164ba..000000000000 --- a/tests/e2e/assets/webpack/test-app/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - // Test comment - "compilerOptions": { - "baseUrl": "", - "module": "es2015", - "moduleResolution": "node", - "target": "es5", - "noImplicitAny": false, - "sourceMap": true, - "mapRoot": "", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "lib", - "skipLibCheck": true, - "rootDir": "." - }, - "angularCompilerOptions": { - "genDir": "./app/ngfactory", - "entryModule": "app/app.module#AppModule" - } -} diff --git a/tests/e2e/assets/webpack/test-app/webpack.config.js b/tests/e2e/assets/webpack/test-app/webpack.config.js deleted file mode 100644 index 9f4cd36678c6..000000000000 --- a/tests/e2e/assets/webpack/test-app/webpack.config.js +++ /dev/null @@ -1,29 +0,0 @@ -const ngToolsWebpack = require('@ngtools/webpack'); - -module.exports = { - resolve: { - extensions: ['.ts', '.js'] - }, - entry: './app/main.aot.ts', - output: { - path: './dist', - publicPath: 'dist/', - filename: 'app.main.js' - }, - plugins: [ - new ngToolsWebpack.AotPlugin({ - tsConfigPath: './tsconfig.json' - }) - ], - module: { - loaders: [ - { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader'] }, - { test: /\.css$/, loader: 'raw-loader' }, - { test: /\.html$/, loader: 'raw-loader' }, - { test: /\.ts$/, loader: '@ngtools/webpack' } - ] - }, - devServer: { - historyApiFallback: true - } -}; diff --git a/tests/e2e/assets/webpack/test-server-app/app/app.component.html b/tests/e2e/assets/webpack/test-server-app/app/app.component.html deleted file mode 100644 index 5a532db9308f..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/app.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

hello world

- lazy - -
diff --git a/tests/e2e/assets/webpack/test-server-app/app/app.component.scss b/tests/e2e/assets/webpack/test-server-app/app/app.component.scss deleted file mode 100644 index 5cde7b922336..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/app.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - background-color: blue; -} diff --git a/tests/e2e/assets/webpack/test-server-app/app/app.component.ts b/tests/e2e/assets/webpack/test-server-app/app/app.component.ts deleted file mode 100644 index 82a4059565d3..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/app.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {Component, ViewEncapsulation} from '@angular/core'; -import {MyInjectable} from './injectable'; - - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'], - encapsulation: ViewEncapsulation.None -}) -export class AppComponent { - constructor(public inj: MyInjectable) { - console.log(inj); - } -} diff --git a/tests/e2e/assets/webpack/test-server-app/app/app.module.ts b/tests/e2e/assets/webpack/test-server-app/app/app.module.ts deleted file mode 100644 index 7c8a0c296448..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/app.module.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NgModule, Component } from '@angular/core'; -import { ServerModule } from '@angular/platform-server'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; - -import { AppComponent } from './app.component'; -import { MyInjectable } from './injectable'; - -@Component({ - selector: 'home-view', - template: 'home!' -}) -export class HomeView {} - - -@NgModule({ - declarations: [ - AppComponent, - HomeView - ], - imports: [ - BrowserModule.withServerTransition({ - appId: 'app' - }), - ServerModule, - RouterModule.forRoot([ - {path: 'lazy', loadChildren: './lazy.module#LazyModule'}, - {path: '', component: HomeView} - ]) - ], - providers: [MyInjectable], - bootstrap: [AppComponent] -}) -export class AppModule { - static testProp: string; -} diff --git a/tests/e2e/assets/webpack/test-server-app/app/feature/feature.module.ts b/tests/e2e/assets/webpack/test-server-app/app/feature/feature.module.ts deleted file mode 100644 index f464ca028b05..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/feature/feature.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; - -@Component({ - selector: 'feature-component', - template: 'foo.html' -}) -export class FeatureComponent {} - -@NgModule({ - declarations: [ - FeatureComponent - ], - imports: [ - RouterModule.forChild([ - { path: '', component: FeatureComponent} - ]) - ] -}) -export class FeatureModule {} diff --git a/tests/e2e/assets/webpack/test-server-app/app/feature/lazy-feature.module.ts b/tests/e2e/assets/webpack/test-server-app/app/feature/lazy-feature.module.ts deleted file mode 100644 index 8fafca158b24..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/feature/lazy-feature.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-feature-comp', - template: 'lazy feature!' -}) -export class LazyFeatureComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyFeatureComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature.module#FeatureModule'} - ]), - HttpModule - ], - declarations: [LazyFeatureComponent] -}) -export class LazyFeatureModule { - constructor(http: Http) {} -} diff --git a/tests/e2e/assets/webpack/test-server-app/app/injectable.ts b/tests/e2e/assets/webpack/test-server-app/app/injectable.ts deleted file mode 100644 index b357678ae77a..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/injectable.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {Injectable, Inject, ViewContainerRef} from '@angular/core'; -import {DOCUMENT} from '@angular/platform-browser'; - - -@Injectable() -export class MyInjectable { - constructor(@Inject(DOCUMENT) public doc) {} -} diff --git a/tests/e2e/assets/webpack/test-server-app/app/lazy.module.ts b/tests/e2e/assets/webpack/test-server-app/app/lazy.module.ts deleted file mode 100644 index 96da4de7515b..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/lazy.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-comp', - template: 'lazy!' -}) -export class LazyComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature/feature.module#FeatureModule'}, - {path: 'lazy-feature', loadChildren: './feature/lazy-feature.module#LazyFeatureModule'} - ]), - HttpModule - ], - declarations: [LazyComponent] -}) -export class LazyModule { - constructor(http: Http) {} -} - -export class SecondModule {} diff --git a/tests/e2e/assets/webpack/test-server-app/app/main.commonjs.ts b/tests/e2e/assets/webpack/test-server-app/app/main.commonjs.ts deleted file mode 100644 index ce26d93a11de..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/main.commonjs.ts +++ /dev/null @@ -1 +0,0 @@ -export { AppModule } from './app.module'; diff --git a/tests/e2e/assets/webpack/test-server-app/app/main.ts b/tests/e2e/assets/webpack/test-server-app/app/main.ts deleted file mode 100644 index 39f058e884b5..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/app/main.ts +++ /dev/null @@ -1,11 +0,0 @@ -import 'core-js/es7/reflect'; -import {platformDynamicServer, renderModule} from '@angular/platform-server'; -import {AppModule} from './app.module'; - -AppModule.testProp = 'testing'; - -platformDynamicServer().bootstrapModule(AppModule); -renderModule(AppModule, { - document: '', - url: '/' -}); diff --git a/tests/e2e/assets/webpack/test-server-app/index.html b/tests/e2e/assets/webpack/test-server-app/index.html deleted file mode 100644 index 89fb0893c35d..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - Document - - - - - - - - diff --git a/tests/e2e/assets/webpack/test-server-app/index.js b/tests/e2e/assets/webpack/test-server-app/index.js deleted file mode 100644 index bdfb2e792acd..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/index.js +++ /dev/null @@ -1,12 +0,0 @@ -const fs = require('fs'); -const { AppModuleNgFactory } = require('./dist/app.main'); -const { renderModuleFactory } = require('@angular/platform-server'); - -require('zone.js/dist/zone-node'); - -renderModuleFactory(AppModuleNgFactory, { - url: '/', - document: '' -}).then(html => { - fs.writeFileSync('dist/index.html', html); -}) diff --git a/tests/e2e/assets/webpack/test-server-app/package.json b/tests/e2e/assets/webpack/test-server-app/package.json deleted file mode 100644 index 0da8842c5492..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "test", - "license": "MIT", - "dependencies": { - "@angular/animations": "^4.0.0", - "@angular/common": "^4.0.0", - "@angular/compiler": "^4.0.0", - "@angular/compiler-cli": "^4.0.0", - "@angular/core": "^4.0.0", - "@angular/http": "^4.0.0", - "@angular/platform-browser": "^4.0.0", - "@angular/platform-browser-dynamic": "^4.0.0", - "@angular/platform-server": "^4.0.0", - "@angular/router": "^4.0.0", - "@ngtools/webpack": "0.0.0", - "core-js": "^2.4.1", - "rxjs": "^5.4.2", - "zone.js": "^0.8.14" - }, - "devDependencies": { - "node-sass": "^4.5.0", - "performance-now": "^0.2.0", - "raw-loader": "^0.5.1", - "sass-loader": "^6.0.3", - "typescript": "~2.4.2", - "webpack": "2.2.1" - } -} diff --git a/tests/e2e/assets/webpack/test-server-app/tsconfig.json b/tests/e2e/assets/webpack/test-server-app/tsconfig.json deleted file mode 100644 index 5822e780bfc7..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "", - "module": "es2015", - "moduleResolution": "node", - "target": "es5", - "noImplicitAny": false, - "sourceMap": true, - "mapRoot": "", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "lib", - "skipLibCheck": true, - "rootDir": "." - }, - "angularCompilerOptions": { - "genDir": "./app/ngfactory", - "entryModule": "app/app.module#AppModule" - } -} diff --git a/tests/e2e/assets/webpack/test-server-app/webpack.commonjs.config.js b/tests/e2e/assets/webpack/test-server-app/webpack.commonjs.config.js deleted file mode 100644 index b23f63ee54a6..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/webpack.commonjs.config.js +++ /dev/null @@ -1,33 +0,0 @@ -const ngToolsWebpack = require('@ngtools/webpack'); - -module.exports = { - resolve: { - extensions: ['.ts', '.js'] - }, - target: 'web', - entry: './app/main.commonjs.ts', - output: { - path: './dist', - publicPath: 'dist/', - filename: 'app.main.js', - libraryTarget: 'commonjs' - }, - plugins: [ - new ngToolsWebpack.AotPlugin({ - tsConfigPath: './tsconfig.json', - replaceExport: true - }) - ], - externals: /^@angular/, - module: { - loaders: [ - { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader'] }, - { test: /\.css$/, loader: 'raw-loader' }, - { test: /\.html$/, loader: 'raw-loader' }, - { test: /\.ts$/, loader: '@ngtools/webpack' } - ] - }, - devServer: { - historyApiFallback: true - } -}; diff --git a/tests/e2e/assets/webpack/test-server-app/webpack.config.js b/tests/e2e/assets/webpack/test-server-app/webpack.config.js deleted file mode 100644 index 8694fa6a6900..000000000000 --- a/tests/e2e/assets/webpack/test-server-app/webpack.config.js +++ /dev/null @@ -1,30 +0,0 @@ -const ngToolsWebpack = require('@ngtools/webpack'); - -module.exports = { - resolve: { - extensions: ['.ts', '.js'] - }, - target: 'web', - entry: './app/main.ts', - output: { - path: './dist', - publicPath: 'dist/', - filename: 'app.main.js' - }, - plugins: [ - new ngToolsWebpack.AotPlugin({ - tsConfigPath: './tsconfig.json' - }) - ], - module: { - loaders: [ - { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader'] }, - { test: /\.css$/, loader: 'raw-loader' }, - { test: /\.html$/, loader: 'raw-loader' }, - { test: /\.ts$/, loader: '@ngtools/webpack' } - ] - }, - devServer: { - historyApiFallback: true - } -}; diff --git a/tests/e2e/initialize/300-log-environment.ts b/tests/e2e/initialize/300-log-environment.ts new file mode 100644 index 000000000000..50b7d752635f --- /dev/null +++ b/tests/e2e/initialize/300-log-environment.ts @@ -0,0 +1,19 @@ +import { getActivePackageManager } from '../utils/packages'; +import { exec, ng } from '../utils/process'; + +export default async function () { + console.log('Environment:'); + + Object.keys(process.env).forEach((envName) => { + // CI Logs should not contain environment variables that are considered secret + const lowerName = envName.toLowerCase(); + if (lowerName.includes('key') || lowerName.includes('secret')) { + return; + } + + console.log(` ${envName}: ${process.env[envName]!.replace(/[\n\r]+/g, '\n ')}`); + }); + + await exec('which', 'ng', getActivePackageManager()); + await ng('version'); +} diff --git a/tests/e2e/initialize/500-create-project.ts b/tests/e2e/initialize/500-create-project.ts new file mode 100644 index 000000000000..b48c8733a9a5 --- /dev/null +++ b/tests/e2e/initialize/500-create-project.ts @@ -0,0 +1,86 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../utils/env'; +import { expectFileToExist } from '../utils/fs'; +import { gitClean } from '../utils/git'; +import { getActivePackageManager, setRegistry as setNPMConfigRegistry } from '../utils/packages'; +import { ng } from '../utils/process'; +import { prepareProjectForE2e, updateJsonFile } from '../utils/project'; + +export default async function () { + const argv: Record = getGlobalVariable('argv'); + + if (argv.noproject) { + return; + } + + if (argv.reuse && typeof argv.reuse === 'string') { + process.chdir(argv.reuse); + await gitClean(); + } else { + // Ensure local test registry is used when outside a project + await setNPMConfigRegistry(true); + + await ng( + 'new', + 'test-project', + '--skip-install', + '--test-runner', + 'karma', + '--package-manager', + getActivePackageManager(), + ); + + await expectFileToExist(join(process.cwd(), 'test-project')); + process.chdir('./test-project'); + + // Setup esbuild builder if requested on the commandline + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project']['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + outputPath: 'dist/test-project/browser', + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + + const serve = json['projects']['test-project']['architect']['serve']; + serve.builder = '@angular-devkit/build-angular:dev-server'; + + const extract = json['projects']['test-project']['architect']['extract-i18n']; + if (extract) { + extract.builder = '@angular-devkit/build-angular:extract-i18n'; + } + + const test = json['projects']['test-project']['architect']['test']; + test.builder = '@angular-devkit/build-angular:karma'; + test.options ??= {}; + test.options.tsConfig = 'tsconfig.spec.json'; + delete test.options.runner; + }); + await updateJsonFile('tsconfig.json', (tsconfig) => { + delete tsconfig.compilerOptions.esModuleInterop; + tsconfig.compilerOptions.allowSyntheticDefaultImports = true; + }); + } + + // Always need `@angular-devkit/build-angular` due to the use of protractor + await updateJsonFile('package.json', (packageJson) => { + packageJson.devDependencies['@angular-devkit/build-angular'] = + packageJson.devDependencies['@angular/build']; + }); + } + + await prepareProjectForE2e('test-project'); + await ng('version'); +} diff --git a/tests/e2e/initialize/BUILD.bazel b/tests/e2e/initialize/BUILD.bazel new file mode 100644 index 000000000000..2ab5b570925f --- /dev/null +++ b/tests/e2e/initialize/BUILD.bazel @@ -0,0 +1,16 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "initialize", + testonly = True, + srcs = glob(["**/*.ts"]), + data = [ + "//:config-files", + ], + deps = [ + "//:node_modules/@types/node", + "//tests/e2e/utils", + ], +) diff --git a/tests/e2e/ng-snapshot/BUILD.bazel b/tests/e2e/ng-snapshot/BUILD.bazel new file mode 100644 index 000000000000..63d76e1a07da --- /dev/null +++ b/tests/e2e/ng-snapshot/BUILD.bazel @@ -0,0 +1,7 @@ +load("//tools:defaults.bzl", "copy_to_bin") + +copy_to_bin( + name = "ng-snapshot", + srcs = ["package.json"], + visibility = ["//visibility:public"], +) diff --git a/tests/e2e/ng-snapshot/package.json b/tests/e2e/ng-snapshot/package.json new file mode 100644 index 000000000000..cd4b39618af6 --- /dev/null +++ b/tests/e2e/ng-snapshot/package.json @@ -0,0 +1,22 @@ +{ + "description": "snapshot versions of Angular for e2e testing", + "private": true, + "dependencies": { + "@angular/animations": "github:angular/animations-builds#80f7a3d33b4d8fde138a83637128da7ceb01c33f", + "@angular/cdk": "github:angular/cdk-builds#086c8f7fefadd3ff805ebe3d87c5fc3ffd0f4577", + "@angular/common": "github:angular/common-builds#d431201438e0798a31328380f80c1f0345aaaa89", + "@angular/compiler": "github:angular/compiler-builds#fa4b4404039970b6c94ee64b56b788679d3f5f22", + "@angular/compiler-cli": "github:angular/compiler-cli-builds#b0cfa4aa7725920ff199ba8a78e1988cd7391e62", + "@angular/core": "github:angular/core-builds#ec69799a1eba1d6b317ba56c6a2adc78dea4e14c", + "@angular/forms": "github:angular/forms-builds#26584dca942fa0afcda163d975946c8ddd87d930", + "@angular/language-service": "github:angular/language-service-builds#bd195f93adb199752d3192bfdaca041385076423", + "@angular/localize": "github:angular/localize-builds#775afc59ea3fc5686f426d9a51866dd32ae548bc", + "@angular/material": "github:angular/material-builds#ebc19d7a966634d97c8c56dae5b040314b8446ae", + "@angular/material-moment-adapter": "github:angular/material-moment-adapter-builds#cb846b450c5a62b93e05b7a013815dd7bf5192a0", + "@angular/platform-browser": "github:angular/platform-browser-builds#a4046442f715875beffe8905f8b6a1ad3fe90cb2", + "@angular/platform-browser-dynamic": "github:angular/platform-browser-dynamic-builds#5b9e0949af9796a8d59084889c853bd5efa57a23", + "@angular/platform-server": "github:angular/platform-server-builds#49f5a07bea5eaeb71253abfd1e1e6caff9db0209", + "@angular/router": "github:angular/router-builds#a597530f7bf2b8b5554a06dac44a9288d5aebbb4", + "@angular/service-worker": "github:angular/service-worker-builds#da4f659be5b0c248dd46ad30edbf02e2778f845b" + } +} diff --git a/tests/e2e/setup/000-pwd.ts b/tests/e2e/setup/000-pwd.ts deleted file mode 100644 index e36f652698d8..000000000000 --- a/tests/e2e/setup/000-pwd.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {join} from 'path'; - - -export default function() { - // Setup to use the local @angular/cli copy. - process.chdir(join(__dirname, '../../..')); -} diff --git a/tests/e2e/setup/001-npm-sandbox.ts b/tests/e2e/setup/001-npm-sandbox.ts new file mode 100644 index 000000000000..4ba76d0617e7 --- /dev/null +++ b/tests/e2e/setup/001-npm-sandbox.ts @@ -0,0 +1,62 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getGlobalVariable, setGlobalVariable } from '../utils/env'; + +/** + * Configure npm to use a unique sandboxed environment. + */ +export default async function () { + const tempRoot: string = getGlobalVariable('tmp-root'); + const npmModulesPrefix = join(tempRoot, 'npm-global'); + const yarnModulesPrefix = join(tempRoot, 'yarn-global'); + const npmRegistry: string = getGlobalVariable('package-registry'); + const npmrc = join(tempRoot, '.npmrc'); + const yarnrc = join(tempRoot, '.yarnrc'); + + // Change the npm+yarn userconfig to the sandboxed npmrc to override the default ~ + process.env.NPM_CONFIG_USERCONFIG = npmrc; + + // The npm+yarn registry URL + process.env.NPM_CONFIG_REGISTRY = npmRegistry; + + // Configure npm+yarn to use a sandboxed bin directory + // From this point onward all yarn/npm bin files/symlinks are put into the prefix directories + process.env.NPM_CONFIG_PREFIX = npmModulesPrefix; + process.env.YARN_CONFIG_PREFIX = yarnModulesPrefix; + + // Put the npm+yarn caches in the temp dir + process.env.NPM_CONFIG_CACHE = join(tempRoot, 'npm-cache'); + process.env.YARN_CACHE_FOLDER = join(tempRoot, 'yarn-cache'); + + // Snapshot builds may contain versions that are not yet released (e.g., RC phase main branch). + // In this case peer dependency ranges may not resolve causing npm 7+ to fail during tests. + // To support this case, legacy peer dependency mode is enabled for snapshot builds. + if (getGlobalVariable('argv')['ng-snapshots']) { + process.env['NPM_CONFIG_legacy_peer_deps'] = 'true'; + } + + // Allow github tarballs and git dependencies consumption. + process.env['NPM_CONFIG_allow_remote'] = 'all'; + process.env['NPM_CONFIG_allow_git'] = 'true'; + + // Configure the registry and prefix used within the test sandbox via rc files + await writeFile(npmrc, `registry=${npmRegistry}\nprefix=${npmModulesPrefix}`); + await writeFile( + yarnrc, + `registry ${npmRegistry}\nprefix ${yarnModulesPrefix}\nnetwork-timeout 15000\nprefer-offline true\n`, + ); + + await mkdir(npmModulesPrefix); + await mkdir(yarnModulesPrefix); + + setGlobalVariable('npm-global', npmModulesPrefix); + setGlobalVariable('yarn-global', yarnModulesPrefix); + + // Disable all update/notification related npm/yarn features such as the NPM updater notifier. + // The NPM updater notifier may prevent the child process from closing until it timeouts after 3 minutes. + process.env.NO_UPDATE_NOTIFIER = '1'; + process.env.NPM_CONFIG_UPDATE_NOTIFIER = 'false'; + + console.log(` Using "${npmModulesPrefix}" as e2e test global npm bin dir.`); + console.log(` Using "${yarnModulesPrefix}" as e2e test global yarn bin dir.`); +} diff --git a/tests/e2e/setup/010-build.ts b/tests/e2e/setup/010-build.ts deleted file mode 100644 index 7506f8aa0093..000000000000 --- a/tests/e2e/setup/010-build.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {join} from 'path'; -import {getGlobalVariable} from '../utils/env'; -import {npm} from '../utils/process'; -import {updateJsonFile} from '../utils/project'; - -const packages = require('../../../lib/packages').packages; - -export default function() { - const argv = getGlobalVariable('argv'); - - if (argv.nobuild) { - return; - } - - return npm('run', 'build', '--', '--local') - .then(() => { - if (!argv.nightly && !argv['ng-sha']) { - return; - } - - console.log('Updating package.json from dist for nightly Angular packages...'); - const label = argv['ng-sha'] ? `#2.0.0-${argv['ng-sha']}` : ''; - - return Promise.all(Object.keys(packages).map(pkgName => { - return updateJsonFile(join(packages[pkgName].dist, 'package.json'), json => { - Object.keys(json['dependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - json['dependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); - - Object.keys(json['devDependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - json['devDependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); - }); - })); - }); -} diff --git a/tests/e2e/setup/010-local-publish.ts b/tests/e2e/setup/010-local-publish.ts new file mode 100644 index 000000000000..b58a5b871daf --- /dev/null +++ b/tests/e2e/setup/010-local-publish.ts @@ -0,0 +1,29 @@ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path/posix'; +import { getGlobalVariable } from '../utils/env'; +import { PkgInfo } from '../utils/packages'; +import { globalNpm, extractNpmEnv, extractCIAndInfraEnv } from '../utils/process'; +import { isPrereleaseCli } from '../utils/project'; + +export default async function () { + const testRegistry: string = getGlobalVariable('package-registry'); + const packageTars: PkgInfo[] = Object.values(getGlobalVariable('package-tars')); + const npmrc = join(getGlobalVariable('tmp-root'), '.npmrc-publish'); + await writeFile( + npmrc, + ` + ${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret + `, + ); + + // Publish packages specified with --package + await Promise.all( + packageTars.map(({ path: p }) => + globalNpm(['publish', '--tag', isPrereleaseCli() ? 'next' : 'latest', p], { + ...extractNpmEnv(), + ...extractCIAndInfraEnv(), + 'NPM_CONFIG_USERCONFIG': npmrc, + }), + ), + ); +} diff --git a/tests/e2e/setup/100-global-cli.ts b/tests/e2e/setup/100-global-cli.ts new file mode 100644 index 000000000000..d660fc1d1158 --- /dev/null +++ b/tests/e2e/setup/100-global-cli.ts @@ -0,0 +1,29 @@ +import { getGlobalVariable } from '../utils/env'; +import { getActivePackageManager } from '../utils/packages'; +import { globalNpm } from '../utils/process'; + +const PACKAGE_MANAGER_VERSION = { + 'npm': '12.0.2', + 'yarn': '1.22.22', + 'pnpm': '10.17.1', + 'bun': '1.3.2', +}; + +export default async function () { + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + const testRegistry = getGlobalVariable('package-registry'); + const packageManager = getActivePackageManager(); + + // Install global Angular CLI being tested, npm+yarn used by e2e tests. + await globalNpm([ + 'install', + '--global', + `--registry=${testRegistry}`, + '@angular/cli', + `${packageManager}@${PACKAGE_MANAGER_VERSION[packageManager]}`, + ]); +} diff --git a/tests/e2e/setup/100-npm-link.ts b/tests/e2e/setup/100-npm-link.ts deleted file mode 100644 index cdc74cdb8b2c..000000000000 --- a/tests/e2e/setup/100-npm-link.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {join} from 'path'; -import {silentNpm, exec} from '../utils/process'; -import {updateJsonFile} from '../utils/project'; -import {getGlobalVariable} from '../utils/env'; - -const packages = require('../../../lib/packages').packages; - - -export default function () { - return Promise.resolve() - .then(() => { - const argv = getGlobalVariable('argv'); - if (argv.nolink) { - return; - } - - const distAngularCli = packages['@angular/cli'].dist; - const oldCwd = process.cwd(); - process.chdir(distAngularCli); - - // Update the package.json of each packages. - return Promise.all(Object.keys(packages).map(pkgName => { - const p = packages[pkgName]; - - return updateJsonFile(join(p.dist, 'package.json'), json => { - for (const pkgName of Object.keys(packages)) { - if (json['dependencies'] && json['dependencies'][pkgName]) { - json['dependencies'][pkgName] = packages[pkgName].dist; - } - if (json['devDependencies'] && json['devDependencies'][pkgName]) { - json['devDependencies'][pkgName] = packages[pkgName].dist; - } - } - }); - })) - // npm link is very noisy on the older version used in Circle CI. - .then(() => silentNpm('link')) - .then(() => process.chdir(oldCwd)); - }) - .then(() => exec(process.platform.startsWith('win') ? 'where' : 'which', 'ng')); -} diff --git a/tests/e2e/setup/200-create-project-dir.ts b/tests/e2e/setup/200-create-project-dir.ts new file mode 100644 index 000000000000..1e1e049956bf --- /dev/null +++ b/tests/e2e/setup/200-create-project-dir.ts @@ -0,0 +1,19 @@ +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getGlobalVariable, setGlobalVariable } from '../utils/env'; + +/** + * Create a parent directory for test projects to be created within. + * Change the cwd() to that directory in preparation for launching the cli. + */ +export default async function () { + const tempRoot: string = getGlobalVariable('tmp-root'); + const projectsRoot = join(tempRoot, 'e2e-test'); + + setGlobalVariable('projects-root', projectsRoot); + + await mkdir(projectsRoot); + + console.log(` Using "${projectsRoot}" as temporary directory for a new project.`); + process.chdir(projectsRoot); +} diff --git a/tests/e2e/setup/200-create-tmp-dir.ts b/tests/e2e/setup/200-create-tmp-dir.ts deleted file mode 100644 index e63d150721de..000000000000 --- a/tests/e2e/setup/200-create-tmp-dir.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {setGlobalVariable, getGlobalVariable} from '../utils/env'; - - -const temp = require('temp'); - -export default function() { - const argv = getGlobalVariable('argv'); - - // Get to a temporary directory. - let tempRoot = argv.reuse || temp.mkdirSync('angular-cli-e2e-'); - console.log(` Using "${tempRoot}" as temporary directory for a new project.`); - setGlobalVariable('tmp-root', tempRoot); - process.chdir(tempRoot); -} diff --git a/tests/e2e/setup/300-log-environment.ts b/tests/e2e/setup/300-log-environment.ts deleted file mode 100644 index 1a64adbb5523..000000000000 --- a/tests/e2e/setup/300-log-environment.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {node, ng, npm} from '../utils/process'; - -const packages = require('../../../lib/packages').packages; - - -export default function() { - return Promise.resolve() - .then(() => console.log('Environment:')) - .then(() => { - Object.keys(process.env).forEach(envName => { - console.log(` ${envName}: ${process.env[envName].replace(/[\n\r]+/g, '\n ')}`); - }); - }) - .then(() => { - console.log('Packages:'); - console.log(JSON.stringify(packages, null, 2)); - }) - .then(() => node('--version')) - .then(() => npm('--version')) - .then(() => ng('version')); -} diff --git a/tests/e2e/setup/500-create-project.ts b/tests/e2e/setup/500-create-project.ts deleted file mode 100644 index d4ad24b10e76..000000000000 --- a/tests/e2e/setup/500-create-project.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {join} from 'path'; -import {git, ng, silentNpm} from '../utils/process'; -import {expectFileToExist, replaceInFile} from '../utils/fs'; -import { - updateTsConfig, - updateJsonFile, - useNg2, - useSha, - useCIChrome, - useCIDefaults - useBuiltPackages, -} from '../utils/project'; -import {gitClean, gitCommit} from '../utils/git'; -import {getGlobalVariable} from '../utils/env'; - - -let packages = require('../../../lib/packages').packages; - - -export default function() { - const argv = getGlobalVariable('argv'); - let createProject = null; - - // This is a dangerous flag, but is useful for testing packages only. - if (argv.noproject) { - return Promise.resolve(); - } else if (argv.reuse) { - // If we're set to reuse an existing project, just chdir to it and clean it. - createProject = Promise.resolve() - .then(() => process.chdir(argv.reuse)) - .then(() => gitClean()); - } else { - // Otherwise create a project from scratch. - createProject = Promise.resolve() - .then(() => ng('new', 'test-project', '--skip-install')) - .then(() => expectFileToExist(join(process.cwd(), 'test-project'))) - .then(() => process.chdir('./test-project')); - } - - return Promise.resolve() - .then(() => createProject) - .then(() => useBuiltPackages()) - .then(() => useCIChrome()) - .then(() => useCIDefaults()) - .then(() => argv['ng2'] ? useNg2() : Promise.resolve()) - .then(() => argv.nightly || argv['ng-sha'] ? useSha() : Promise.resolve()) - // npm link on Circle CI is very noisy. - .then(() => silentNpm('install')) - // Force sourcemaps to be from the root of the filesystem. - .then(() => updateTsConfig(json => { - json['compilerOptions']['sourceRoot'] = '/'; - })) - .then(() => git('config', 'user.email', 'angular-core+e2e@google.com')) - .then(() => git('config', 'user.name', 'Angular CLI E2e')) - .then(() => git('config', 'commit.gpgSign', 'false')) - .then(() => gitCommit('tsconfig-e2e-update')); -} diff --git a/tests/e2e/setup/BUILD.bazel b/tests/e2e/setup/BUILD.bazel new file mode 100644 index 000000000000..36fe39fa3409 --- /dev/null +++ b/tests/e2e/setup/BUILD.bazel @@ -0,0 +1,13 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "setup", + testonly = True, + srcs = glob(["**/*.ts"]), + deps = [ + "//:node_modules/@types/node", + "//tests/e2e/utils", + ], +) diff --git a/tests/e2e/tests/BUILD.bazel b/tests/e2e/tests/BUILD.bazel new file mode 100644 index 000000000000..891814cb24eb --- /dev/null +++ b/tests/e2e/tests/BUILD.bazel @@ -0,0 +1,20 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "tests", + testonly = True, + srcs = glob(["**/*.ts"]), + deps = [ + "//:node_modules/@types/express", + "//:node_modules/@types/node", + "//:node_modules/@types/semver", + "//:node_modules/express", + "//:node_modules/fast-glob", + "//:node_modules/puppeteer", + "//:node_modules/semver", + "//:node_modules/undici", + "//tests/e2e/utils", + ], +) diff --git a/tests/e2e/tests/architect_cli/direct_execution.ts b/tests/e2e/tests/architect_cli/direct_execution.ts new file mode 100644 index 000000000000..b91010d46283 --- /dev/null +++ b/tests/e2e/tests/architect_cli/direct_execution.ts @@ -0,0 +1,14 @@ +import * as assert from 'node:assert/strict'; +import { exec } from '../../utils/process'; +import { join } from 'node:path'; + +export default async function () { + // Run help command + const binPath = join('node_modules', '.bin', 'architect'); + const { stdout } = await exec(binPath, '--help'); + + assert.ok( + stdout.includes('architect [project][:target][:configuration] [options, ...]'), + 'Expected stdout to contain usage information.', + ); +} diff --git a/tests/e2e/tests/basic/aot.ts b/tests/e2e/tests/basic/aot.ts new file mode 100644 index 000000000000..d462d817332a --- /dev/null +++ b/tests/e2e/tests/basic/aot.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { ng } from '../../utils/process'; + +/** + * AOT builds should contain generated component factories + */ +export default async function () { + await ng('build', '--aot=true', '--configuration=development'); + const content = await readFile('dist/test-project/browser/main.js', 'utf-8'); + assert.match(content, /App_Factory/); +} diff --git a/tests/e2e/tests/basic/build.ts b/tests/e2e/tests/basic/build.ts new file mode 100644 index 000000000000..2026b3988d2b --- /dev/null +++ b/tests/e2e/tests/basic/build.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; + +const OUTPUT_INDEX_PATH = 'dist/test-project/browser/index.html'; + +export default async function () { + // Development build + const { stdout: stdoutDev } = await ng('build', '--configuration=development'); + // Console output should not contain estimated transfer size information + assert.doesNotMatch(stdoutDev, /Estimated transfer size/); + // Output index HTML file should reference main JS file + const devIndexContent = await readFile(OUTPUT_INDEX_PATH, 'utf-8'); + assert.match(devIndexContent, /main\.js/); + + const usingApplicationBuilder = getGlobalVariable('argv')['esbuild']; + + // Production build + const { stdout: stdoutProd } = await ng('build'); + // Console output should contain estimated transfer size information + assert.match(stdoutProd, /Estimated transfer size/); + // Output index HTML file should reference main JS file with hashing + const prodIndexContent = await readFile(OUTPUT_INDEX_PATH, 'utf-8'); + + if (usingApplicationBuilder) { + // application builder uses an 8 character hash and a dash as a separator + assert.match(prodIndexContent, /main-[a-zA-Z0-9]{8}\.js/); + } else { + // browser builder uses a 16 character hash and a period as a separator + assert.match(prodIndexContent, /main\.[a-zA-Z0-9]{16}\.js/); + } +} diff --git a/tests/e2e/tests/basic/command-scope.ts b/tests/e2e/tests/basic/command-scope.ts new file mode 100644 index 000000000000..f83ad21112b4 --- /dev/null +++ b/tests/e2e/tests/basic/command-scope.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { homedir } from 'node:os'; +import { silentNg } from '../../utils/process'; + +export default async function () { + // Run inside workspace + await silentNg('generate', 'component', 'foo', '--dry-run'); + + // The version command can be run in and outside of a workspace. + await silentNg('version'); + + await assert.rejects( + silentNg('new', 'proj-name', '--dry-run'), + /This command is not available when running the Angular CLI inside a workspace\./, + ); + + // Change CWD to run outside a workspace. + process.chdir(homedir()); + + // ng generate can only be ran inside. + await assert.rejects( + silentNg('generate', 'component', 'foo', '--dry-run'), + /This command is not available when running the Angular CLI outside a workspace\./, + ); + + // ng new can only be ran outside of a workspace + await silentNg('new', 'proj-name', '--dry-run'); + + // The version command can be run in and outside of a workspace. + await silentNg('version'); +} diff --git a/tests/e2e/tests/basic/rebuild.ts b/tests/e2e/tests/basic/rebuild.ts new file mode 100644 index 000000000000..a0b0f1ddc79d --- /dev/null +++ b/tests/e2e/tests/basic/rebuild.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { ngServe } from '../../utils/project'; + +export default async function () { + const esbuild = getGlobalVariable('argv')['esbuild']; + const validBundleRegEx = esbuild ? /sent to client/ : /Compiled successfully\./; + const lazyBundleRegEx = esbuild ? /chunk-/ : /src_app_lazy_lazy_ts\.js/; + + // Disable HMR to support page reload based rebuild testing. + const port = await ngServe('--no-hmr'); + + // Add a lazy route. + await silentNg('generate', 'component', 'lazy'); + + // Should trigger a rebuild with a new bundle. + // We need to use Promise.all to ensure we are waiting for the rebuild just before we write + // the file, otherwise rebuilds can be too fast and fail CI. + // Count the bundles. + // Verify that a new chunk was created. + await Promise.all([ + waitForAnyProcessOutputToMatch(lazyBundleRegEx), + replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [{path: 'lazy', loadComponent: () => import('./lazy/lazy').then(c => c.Lazy)}];`, + ), + ]); + + // Change multiple files and check that all of them are invalidated and recompiled. + await setTimeout(500); + await Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx), + appendToFile( + 'src/app/app.routes.ts', + ` + console.log('$$_E2E_GOLDEN_VALUE_1'); + export let X = '$$_E2E_GOLDEN_VALUE_2'; + `, + ), + appendToFile( + 'src/main.ts', + ` + import * as m from './app/app.routes'; + console.log(m.X); + console.log('$$_E2E_GOLDEN_VALUE_3'); + `, + ), + ]); + + await setTimeout(500); + await Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx), + writeMultipleFiles({ + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + + export const routes: Routes = []; + + console.log('$$_E2E_GOLDEN_VALUE_1'); + export let X = '$$_E2E_GOLDEN_VALUE_2'; + console.log('File changed with no import/export changes'); + `, + }), + ]); + { + const response = await fetch(`http://localhost:${port}/main.js`); + const body = await response.text(); + assert.match(body, /\$\$_E2E_GOLDEN_VALUE_1/); + assert.match(body, /\$\$_E2E_GOLDEN_VALUE_2/); + assert.match(body, /\$\$_E2E_GOLDEN_VALUE_3/); + } + + await setTimeout(500); + await Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx), + writeMultipleFiles({ + 'src/app/app.html': '

testingTESTING123

', + }), + ]); + + { + const response = await fetch(`http://localhost:${port}/main.js`); + const body = await response.text(); + assert.match(body, /testingTESTING123/); + } + + await setTimeout(500); + await Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx), + writeMultipleFiles({ + 'src/app/app.css': ':host { color: blue; }', + }), + ]); + + { + const response = await fetch(`http://localhost:${port}/main.js`); + const body = await response.text(); + assert.match(body, /color:\s?blue/); + } + + await setTimeout(500); + await Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx), + writeMultipleFiles({ + 'src/styles.css': 'div { color: green; }', + }), + ]); + + { + const response = await fetch(`http://localhost:${port}/styles.css`); + const body = await response.text(); + assert.match(body, /color:\s?green/); + } +} diff --git a/tests/e2e/tests/basic/run.ts b/tests/e2e/tests/basic/run.ts new file mode 100644 index 000000000000..c5986a8926a4 --- /dev/null +++ b/tests/e2e/tests/basic/run.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { silentNg } from '../../utils/process'; + +const OUTPUT_INDEX_PATH = 'dist/test-project/browser/index.html'; + +export default async function () { + // Development build + await silentNg('run', 'test-project:build:development'); + // Output index HTML file should reference main JS file + const devIndexContent = await readFile(OUTPUT_INDEX_PATH, 'utf-8'); + assert.match(devIndexContent, /main\.js/); + + const usingApplicationBuilder = getGlobalVariable('argv')['esbuild']; + + // Production build + await silentNg('run', 'test-project:build'); + // Output index HTML file should reference main JS file with hashing + const prodIndexContent = await readFile(OUTPUT_INDEX_PATH, 'utf-8'); + if (usingApplicationBuilder) { + // application builder uses an 8 character hash and a dash as a separator + assert.match(prodIndexContent, /main-[a-zA-Z0-9]{8}\.js/); + } else { + // browser builder uses a 16 character hash and a period as a separator + assert.match(prodIndexContent, /main\.[a-zA-Z0-9]{16}\.js/); + } +} diff --git a/tests/e2e/tests/basic/scripts-array.ts b/tests/e2e/tests/basic/scripts-array.ts new file mode 100644 index 000000000000..0721b120da2b --- /dev/null +++ b/tests/e2e/tests/basic/scripts-array.ts @@ -0,0 +1,78 @@ +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/string-script.js': "console.log('string-script'); var number = 1+1;", + 'src/zstring-script.js': "console.log('zstring-script');", + 'src/fstring-script.js': "console.log('fstring-script');", + 'src/ustring-script.js': "console.log('ustring-script');", + 'src/bstring-script.js': "console.log('bstring-script');", + 'src/astring-script.js': "console.log('astring-script');", + 'src/cstring-script.js': "console.log('cstring-script');", + 'src/input-script.js': "console.log('input-script');", + 'src/lazy-script.js': "console.log('lazy-script');", + 'src/pre-rename-script.js': "console.log('pre-rename-script');", + 'src/pre-rename-lazy-script.js': "console.log('pre-rename-lazy-script');", + }); + + await appendToFile('src/main.ts', "import './string-script.js';"); + + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + appArchitect.build.options.scripts = [ + { input: 'src/string-script.js' }, + { input: 'src/zstring-script.js' }, + { input: 'src/fstring-script.js' }, + { input: 'src/ustring-script.js' }, + { input: 'src/bstring-script.js' }, + { input: 'src/astring-script.js' }, + { input: 'src/cstring-script.js' }, + { input: 'src/input-script.js' }, + { input: 'src/lazy-script.js', inject: false }, + { input: 'src/pre-rename-script.js', bundleName: 'renamed-script' }, + { + input: 'src/pre-rename-lazy-script.js', + bundleName: 'renamed-lazy-script', + inject: false, + }, + ]; + }); + + await ng('build', '--configuration=development'); + + // files were created successfully + await expectFileToMatch('dist/test-project/browser/scripts.js', 'string-script'); + await expectFileToMatch('dist/test-project/browser/scripts.js', 'input-script'); + await expectFileToMatch('dist/test-project/browser/lazy-script.js', 'lazy-script'); + await expectFileToMatch('dist/test-project/browser/renamed-script.js', 'pre-rename-script'); + await expectFileToMatch( + 'dist/test-project/browser/renamed-lazy-script.js', + 'pre-rename-lazy-script', + ); + + // index.html lists the right bundles + if (getGlobalVariable('argv')['esbuild']) { + await expectFileToMatch( + 'dist/test-project/browser/index.html', + [ + '', + '', + '', + ].join(''), + ); + } else { + await expectFileToMatch( + 'dist/test-project/browser/index.html', + [ + '', + '', + '', + '', + '', + ].join(''), + ); + } +} diff --git a/tests/e2e/tests/basic/serve.ts b/tests/e2e/tests/basic/serve.ts new file mode 100644 index 000000000000..eac4823a3126 --- /dev/null +++ b/tests/e2e/tests/basic/serve.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { killAllProcesses } from '../../utils/process'; +import { ngServe } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Serve works without HMR + const noHmrPort = await ngServe('--no-hmr'); + await verifyResponse(noHmrPort); + await killAllProcesses(); + + // Serve works with HMR + const hmrPort = await ngServe('--hmr'); + await verifyResponse(hmrPort); + + await executeBrowserTest({ baseUrl: `http://localhost:${hmrPort}/` }); +} + +async function verifyResponse(port: number): Promise { + const indexResponse = await fetch(`http://localhost:${port}/`); + assert.match(await indexResponse.text(), /<\/app-root>/); + + const assetResponse = await fetch(`http://localhost:${port}/favicon.ico`); + assert(assetResponse.ok, 'Expected favicon asset to be available.'); +} diff --git a/tests/e2e/tests/basic/styles-array.ts b/tests/e2e/tests/basic/styles-array.ts new file mode 100644 index 000000000000..cc4d6d56f506 --- /dev/null +++ b/tests/e2e/tests/basic/styles-array.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/string-style.css': '.string-style { color: red }', + 'src/input-style.css': '.input-style { color: red }', + 'src/lazy-style.css': '.lazy-style { color: red }', + 'src/pre-rename-style.css': '.pre-rename-style { color: red }', + 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [ + { input: 'src/string-style.css' }, + { input: 'src/input-style.css' }, + { input: 'src/lazy-style.css', inject: false }, + { input: 'src/pre-rename-style.css', bundleName: 'renamed-style' }, + { + input: 'src/pre-rename-lazy-style.css', + bundleName: 'renamed-lazy-style', + inject: false, + }, + ]; + }); + + const { stdout } = await ng('build', '--configuration=development'); + + await expectFileToMatch('dist/test-project/browser/styles.css', '.string-style'); + await expectFileToMatch('dist/test-project/browser/styles.css', '.input-style'); + await expectFileToMatch('dist/test-project/browser/lazy-style.css', '.lazy-style'); + await expectFileToMatch('dist/test-project/browser/renamed-style.css', '.pre-rename-style'); + await expectFileToMatch( + 'dist/test-project/browser/renamed-lazy-style.css', + '.pre-rename-lazy-style', + ); + await expectFileToMatch( + 'dist/test-project/browser/index.html', + '', + ); + + // Non injected styles should be listed under lazy chunk files + assert.match(stdout, /Lazy chunk files[\s\S]+renamed-lazy-style\.css/m); +} diff --git a/tests/e2e/tests/basic/test.ts b/tests/e2e/tests/basic/test.ts new file mode 100644 index 000000000000..50580581d442 --- /dev/null +++ b/tests/e2e/tests/basic/test.ts @@ -0,0 +1,59 @@ +import { ng } from '../../utils/process'; +import { writeMultipleFiles } from '../../utils/fs'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + // make sure both --watch=false work + await ng('test', '--watch=false'); + + // Works with custom config + await writeMultipleFiles({ + './karma.conf.bis.js': ` + // Karma configuration file, see link for more information + // https://karma-runner.github.io/1.0/config/configuration-file.html + module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['ChromeHeadlessNoSandbox'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: [ + '--no-sandbox', + '--headless', + '--disable-gpu', + '--disable-dev-shm-usage', + ], + } + }, + singleRun: false, + restartOnFileChange: true + }); + }; + `, + }); + + const isWebpack = !getGlobalVariable('argv')['esbuild']; + + if (isWebpack) { + await ng('test', '--watch=false', '--karma-config=karma.conf.bis.js'); + } else { + await ng('test', '--watch=false', '--runner-config=karma.conf.bis.js'); + } +} diff --git a/tests/e2e/tests/build/aot/aot-decorators.ts b/tests/e2e/tests/build/aot/aot-decorators.ts deleted file mode 100644 index 86d15b6448bc..000000000000 --- a/tests/e2e/tests/build/aot/aot-decorators.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {ng} from '../../../utils/process'; -import {appendToFile, expectFileToMatch, prependToFile, replaceInFile} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return ng('generate', 'component', 'test-component', '--module', 'app.module.ts') - .then(() => prependToFile('src/app/test-component/test-component.component.ts', ` - import { Optional, SkipSelf } from '@angular/core'; - `)) - .then(() => replaceInFile('src/app/test-component/test-component.component.ts', - /constructor.*/, ` - constructor(@Optional() @SkipSelf() public test: TestComponentComponent) { - console.log(test); - } - `)) - .then(() => appendToFile('src/app/app.component.html', ` - - `)) - .then(() => ng('build', '--aot')) - .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', /\bComponent\b/))) - // Check that the decorators are still kept. - .then(() => expectFileToMatch('dist/main.bundle.js', /ctorParameters.*Optional.*SkipSelf/)) - .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', /\bNgModule\b/))); -} diff --git a/tests/e2e/tests/build/aot/aot-i18n.ts b/tests/e2e/tests/build/aot/aot-i18n.ts deleted file mode 100644 index b66ff8f1384b..000000000000 --- a/tests/e2e/tests/build/aot/aot-i18n.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectFileToMatch, writeFile, createDir, appendToFile, readFile} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; -import {Version} from '../../../../../packages/@angular/cli/upgrade/version'; -import {SemVer} from 'semver'; - -export default function() { - return Promise.resolve() - .then(() => createDir('src/locale')) - .then(() => writeFile('src/locale/messages.fr.xlf', ` - - - - - - Hello i18n! - Bonjour i18n! - An introduction header for this sample - - - - `)) - .then(() => appendToFile('src/app/app.component.html', - '

Hello i18n!

')) - .then(() => ng('build', '--aot', '--i18n-file', 'src/locale/messages.fr.xlf', '--i18n-format', - 'xlf', '--locale', 'fr')) - .then(() => expectFileToMatch('dist/main.bundle.js', /Bonjour i18n!/)) - .then(() => ng('build', '--aot')) - .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', /Bonjour i18n!/))) - .then(() => expectFileToMatch('dist/main.bundle.js', /Hello i18n!/)) - .then(() => appendToFile('src/app/app.component.html', - '

Other content

')) - .then(() => readFile('node_modules/@angular/compiler-cli/package.json') - .then((compilerCliPackage): any => { - const version = new Version(JSON.parse(compilerCliPackage).version); - if (version.major === 2) { - return expectToFail(() => ng('build', '--aot', '--i18nFile', 'src/locale/messages.fr.xlf', - '--i18nFormat', 'xlf', '--locale', 'fr', '--missingTranslation', 'ignore')); - } else { - return ng('build', '--aot', '--i18nFile', 'src/locale/messages.fr.xlf', '--i18nFormat', - 'xlf', '--locale', 'fr', '--missingTranslation', 'ignore') - .then(() => expectFileToMatch('dist/main.bundle.js', /Other content/)); - } - }) - ) - .then(() => readFile('node_modules/@angular/compiler-cli/package.json') - .then((compilerCliPackage): any => { - const version = new Version(JSON.parse(compilerCliPackage).version); - if (version.isGreaterThanOrEqualTo(new SemVer('4.2.0-beta.0')) || version.major === 2) { - return expectToFail(() => ng('build', '--aot', '--i18nFile', 'src/locale/messages.fr.xlf', - '--i18nFormat', 'xlf', '--locale', 'fr', '--missingTranslation', 'error')); - } else { - return ng('build', '--aot', '--i18nFile', 'src/locale/messages.fr.xlf', - '--i18nFormat', 'xlf', '--locale', 'fr', '--missingTranslation', 'error'); - } - }) - ); -} diff --git a/tests/e2e/tests/build/aot/aot-rebuild.ts b/tests/e2e/tests/build/aot/aot-rebuild.ts deleted file mode 100644 index b8b3f012b0d8..000000000000 --- a/tests/e2e/tests/build/aot/aot-rebuild.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../../utils/process'; -import { appendToFile } from '../../../utils/fs'; -import { getGlobalVariable } from '../../../utils/env'; -import { request } from '../../../utils/http'; -import { wait } from '../../../utils/utils'; - -const validBundleRegEx = /webpack: bundle is now VALID|webpack: Compiled successfully./; - -export default function () { - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return execAndWaitForOutputToMatch('ng', ['serve', '--aot'], validBundleRegEx) - // Wait before editing a file. - // Editing too soon seems to trigger a rebuild and throw polling/watch out of whack. - .then(() => wait(2000)) - // Check AOT templates are up to date with current code. - .then(() => request('http://localhost:4200/main.bundle.js')) - .then((body) => { - if (body.match(/\$\$_E2E_GOLDEN_VALUE_1/)) { - throw new Error('Expected golden value 1 to not be present.'); - } - }) - .then(() => appendToFile('src/app/app.component.html', '

$$_E2E_GOLDEN_VALUE_1

')) - .then(() => waitForAnyProcessOutputToMatch(validBundleRegEx, 20000)) - .then(() => request('http://localhost:4200/main.bundle.js')) - .then((body) => { - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_1/)) { - throw new Error('Expected golden value 1.'); - } - }) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/e2e/tests/build/aot/aot.ts b/tests/e2e/tests/build/aot/aot.ts deleted file mode 100644 index d10cca8bf7a3..000000000000 --- a/tests/e2e/tests/build/aot/aot.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - -export default function() { - return ng('build', '--aot') - .then(() => expectFileToMatch('dist/main.bundle.js', - /bootstrapModuleFactory.*\/\* AppModuleNgFactory \*\//)); -} diff --git a/tests/e2e/tests/build/aot/exclude.ts b/tests/e2e/tests/build/aot/exclude.ts deleted file mode 100644 index 98904d0f9f22..000000000000 --- a/tests/e2e/tests/build/aot/exclude.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ng } from '../../../utils/process'; -import { writeFile, moveFile } from '../../../utils/fs'; -import { updateJsonFile } from '../../../utils/project'; -import { getGlobalVariable } from '../../../utils/env'; - -export default function () { - // Disable parts of it in webpack tests. - const ejected = getGlobalVariable('argv').eject; - - // Check if **/*.spec.ts files are excluded by default. - return Promise.resolve() - // This import would cause aot to fail. - .then(() => writeFile('src/another.component.spec.ts', ` - import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; - `)) - .then(() => ng('build', '--aot')) - // Verify backwards compatibility with old project using the shared tsconfig. - .then(() => moveFile('src/tsconfig.app.json', 'src/tsconfig.json')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app.tsconfig = 'tsconfig.json'; - delete app['testTsconfig']; - })) - .then(() => updateJsonFile('src/tsconfig.json', tsconfigJson => { - delete tsconfigJson['exclude']; - delete tsconfigJson['compilerOptions']['types']; - })) - .then(() => ng('build', '--aot')) - .then(() => !ejected && ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/build/app-shell/app-shell-ngmodule.ts b/tests/e2e/tests/build/app-shell/app-shell-ngmodule.ts new file mode 100644 index 000000000000..b1b6cfab499d --- /dev/null +++ b/tests/e2e/tests/build/app-shell/app-shell-ngmodule.ts @@ -0,0 +1,35 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + await ng('generate', 'app', 'test-project-two', '--routing', '--no-standalone', '--skip-install'); + await ng('generate', 'app-shell', '--project', 'test-project-two'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + + await ng('build', 'test-project-two'); + await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!'); +} diff --git a/tests/e2e/tests/build/app-shell/app-shell-with-schematic.ts b/tests/e2e/tests/build/app-shell/app-shell-with-schematic.ts new file mode 100644 index 000000000000..65f6fdb23c48 --- /dev/null +++ b/tests/e2e/tests/build/app-shell/app-shell-with-schematic.ts @@ -0,0 +1,34 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { appendToFile, expectFileToMatch } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + await appendToFile('src/app/app.html', ''); + await ng('generate', 'app-shell', '--project', 'test-project'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + await ng('build'); + await expectFileToMatch('dist/test-project/browser/index.html', 'app-shell works!'); +} 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 new file mode 100644 index 000000000000..0841754ad1b2 --- /dev/null +++ b/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts @@ -0,0 +1,65 @@ +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../../utils/env'; +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'); + +export default async function () { + await appendToFile('src/app/app.html', ''); + await ng('generate', 'service-worker', '--project', 'test-project'); + await ng('generate', 'app-shell', '--project', 'test-project'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + + await ng('build'); + await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); + + await executeBrowserTest({ + configuration: 'production', + checkFn: async (page) => { + // Wait for service worker to load. + await setTimeout(2000); + + const baseUrl = page.url(); + await page.goto(new URL('/ngsw/state', baseUrl).href); + + // 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}`); + } + + // TODO: Investigate why the last condition fails with vite-based setup. + // Temporarily disabled to support protractor migration. + if (getGlobalVariable('argv')['esbuild']) { + return; + } + + 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/tests/build/assets.ts b/tests/e2e/tests/build/assets.ts index ce15e48a81c0..0d3c718b270e 100644 --- a/tests/e2e/tests/build/assets.ts +++ b/tests/e2e/tests/build/assets.ts @@ -1,137 +1,67 @@ -import { - writeMultipleFiles, - createDir, - expectFileToMatch, - expectFileToExist -} from '../../utils/fs'; +import assert from 'node:assert/strict'; +import { writeFile, stat, mkdir, symlink, utimes } from 'node:fs/promises'; +import { expectFileToExist, expectFileToMatch } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; import { expectToFail } from '../../utils/utils'; -import {getGlobalVariable} from '../../utils/env'; +import { getGlobalVariable } from '../../utils/env'; +export default async function () { + // Update the atime and mtime of the original file. + // Note: Node.js has different time precision, which may cause mtime-based tests to fail. + // Ensure both values are rounded to the same precision for consistency. + // Example: + // Original: '1742973507738.0234' + // Node.js CP: '1742973507737.999' + const { atime, mtime } = await stat('public/favicon.ico'); + await utimes('public/favicon.ico', atime, mtime); -export default function () { - // Disable parts of it in webpack tests. - const ejected = getGlobalVariable('argv').eject; + await writeFile('public/.file', ''); + await writeFile('public/test.abc', 'hello world'); - return Promise.resolve() - .then(_ => createDir('./src/folder')) - .then(_ => createDir('./node_modules/some-package/')) - // Write assets. - .then(_ => writeMultipleFiles({ - './src/folder/.gitkeep': '', - './src/folder/folder-asset.txt': 'folder-asset.txt', - './src/string-asset.txt': 'string-asset.txt', - './src/glob-asset.txt': 'glob-asset.txt', - './src/output-asset.txt': 'output-asset.txt', - './node_modules/some-package/node_modules-asset.txt': 'node_modules-asset.txt', - })) - // Add asset config in .angular-cli.json. - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = [ - 'folder', - 'string-asset.txt', - { 'glob': 'glob-asset.txt' }, - { 'glob': 'output-asset.txt', 'output': 'output-folder' }, - { 'glob': '**/*', 'input': '../node_modules/some-package/', 'output': 'package-folder' } - ]; - })) - // Test files are present on build output. - .then(() => ng('build')) - .then(() => expectFileToMatch('./dist/folder/folder-asset.txt', 'folder-asset.txt')) - .then(() => expectFileToMatch('./dist/string-asset.txt', 'string-asset.txt')) - .then(() => expectFileToMatch('./dist/glob-asset.txt', 'glob-asset.txt')) - .then(() => expectFileToMatch('./dist/output-folder/output-asset.txt', 'output-asset.txt')) - .then(() => expectFileToMatch('./dist/package-folder/node_modules-asset.txt', - 'node_modules-asset.txt')) - // .gitkeep shouldn't be copied. - .then(() => expectToFail(() => expectFileToExist('dist/assets/.gitkeep'))) - // Update app to test assets are present. - .then(_ => !ejected && 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'; + await ng('build', '--configuration=development'); + await expectFileToExist('dist/test-project/browser/favicon.ico'); + await expectFileToExist('dist/test-project/browser/.file'); + await expectFileToMatch('dist/test-project/browser/test.abc', 'hello world'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/.gitkeep')); - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - FormsModule, - HttpModule - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `, - 'src/app/app.component.ts': ` - import { Component } from '@angular/core'; - import { Http, Response } from '@angular/http'; - import 'rxjs/add/operator/map'; + // Timestamp preservation only supported with application build system on Node.js v22+ + if (getGlobalVariable('argv')['esbuild']) { + const [originalStats, outputStats] = await Promise.all([ + stat('public/favicon.ico'), + stat('dist/test-project/browser/favicon.ico'), + ]); - @Component({ - selector: 'app-root', - template: '

{{asset.content }}

' - }) - export class AppComponent { - public assets = [ - { path: './folder/folder-asset.txt', content: '' }, - { path: './string-asset.txt', content: '' }, - { path: './glob-asset.txt', content: '' }, - { path: './output-folder/output-asset.txt', content: '' }, - { path: './package-folder/node_modules-asset.txt', content: '' }, - ]; - constructor(private http: Http) { - this.assets.forEach(asset => http.get(asset.path) - .subscribe(res => asset.content = res['_body'])); - } - }`, - 'src/app/app.component.spec.ts': ` - import { TestBed, async } from '@angular/core/testing'; - import { HttpModule } from '@angular/http'; - import { AppComponent } from './app.component'; + assert.equal( + originalStats.mtimeMs, + outputStats.mtimeMs, + 'Asset file modified timestamp should be preserved.', + ); + } - describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - HttpModule - ], - declarations: [ - AppComponent - ] - }).compileComponents(); - })); + // Ensure `followSymlinks` option follows symlinks + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect['build'].options.assets = [ + { glob: '**/*', input: 'public', followSymlinks: true }, + ]; + }); - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - });`, - 'e2e/app.e2e-spec.ts': ` - import { browser, element, by } from 'protractor'; + await mkdir('dirToSymlink/subdir1', { recursive: true }); + await mkdir('dirToSymlink/subdir2/subsubdir1', { recursive: true }); + await symlink(process.cwd() + '/dirToSymlink', 'public/symlinkDir'); - describe('master-project App', function () { - it('should display asset contents', () => { - browser.get('/'); - element.all(by.css('app-root p')).then(function (assets) { - expect(assets.length).toBe(5); - expect(assets[0].getText()).toBe('folder-asset.txt'); - expect(assets[1].getText()).toBe('string-asset.txt'); - expect(assets[2].getText()).toBe('glob-asset.txt'); - expect(assets[3].getText()).toBe('output-asset.txt'); - expect(assets[4].getText()).toBe('node_modules-asset.txt'); - }); - }); - });`, - })) - .then(() => !ejected && ng('test', '--single-run')) - .then(() => !ejected && ng('e2e')); + await Promise.all([ + writeFile('dirToSymlink/a.txt', ''), + writeFile('dirToSymlink/subdir1/b.txt', ''), + writeFile('dirToSymlink/subdir2/c.txt', ''), + writeFile('dirToSymlink/subdir2/subsubdir1/d.txt', ''), + ]); + + await ng('build', '--configuration=development'); + + await expectFileToExist('dist/test-project/browser/symlinkDir/a.txt'); + await expectFileToExist('dist/test-project/browser/symlinkDir/subdir1/b.txt'); + await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/c.txt'); + await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/subsubdir1/d.txt'); } diff --git a/tests/e2e/tests/build/auto-csp.ts b/tests/e2e/tests/build/auto-csp.ts new file mode 100644 index 000000000000..aa4ed0e53f18 --- /dev/null +++ b/tests/e2e/tests/build/auto-csp.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch, writeFile, writeMultipleFiles } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +const CSP_META_TAG = / { + const build = json['projects']['test-project']['architect']['build']; + build.options = { + ...build.options, + security: { autoCsp: true }, + }; + }); + + await writeMultipleFiles({ + 'serve.js': ` + const express = require('express'); + const path = require('path'); + + const app = express(); + const PORT = process.env.PORT || 3000; + + app.use(express.static(path.join(__dirname, 'dist/test-project/browser'))); + + app.listen(PORT, () => { + console.log('Node Express server listening on ' + PORT); + }); + `, + 'public/script1.js': ` + const externalScriptCreated = 1337; + console.warn('First External Script: ' + inlineScriptBodyCreated); + `, + 'public/script2.js': `console.warn('Second External Script: ' + externalScriptCreated);`, + 'src/index.html': ` + + + + + + + + + + + + + + + + `, + }); + + async function spawnServer(): Promise { + const port = await findFreePort(); + + await execAndWaitForOutputToMatch('node', ['serve.js'], /Node Express server listening on/, { + ...process.env, + 'PORT': String(port), + }); + + return port; + } + + await ng('build'); + + // Make sure the output files have auto-CSP as a result of `ng build` + await expectFileToMatch('dist/test-project/browser/index.html', CSP_META_TAG); + + // Make sure if contains the critical CSS inlining CSP code. + await expectFileToMatch('dist/test-project/browser/index.html', 'data-beasties-media'); + + // Make sure that our e2e tests run to confirm that our angular project runs. + const port = await spawnServer(); + await executeBrowserTest({ + baseUrl: `http://localhost:${port}/`, + checkFn: async (page) => { + const warnMessages: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'warn') { + warnMessages.push(msg.text()); + } + }); + + // Reload to ensure we capture messages from the start if needed, + // although executeBrowserTest already navigated. + await page.reload(); + + // Wait for the expected number of warnings + let retries = 50; + while (warnMessages.length < 4 && retries > 0) { + await setTimeout(100); + retries--; + } + + assert.strictEqual( + warnMessages.length, + 4, + `Expected 4 console warnings, but got ${warnMessages.length}:\n${warnMessages.join('\n')}`, + ); + + const expectedMessages = [ + 'Inline Script Head', + 'Inline Script Body: 1339', + 'First External Script: 1338', + 'Second External Script: 1337', + ]; + + for (let i = 0; i < expectedMessages.length; i++) { + if (!warnMessages[i].includes(expectedMessages[i])) { + assert.fail( + `Expected warning ${i} to include '${expectedMessages[i]}', but got '${warnMessages[i]}'`, + ); + } + } + }, + }); +} diff --git a/tests/e2e/tests/build/base-href.ts b/tests/e2e/tests/build/base-href.ts deleted file mode 100644 index 1f14735bf2e3..000000000000 --- a/tests/e2e/tests/build/base-href.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; -import {updateJsonFile} from '../../utils/project'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return ng('build', '--base-href', '/myUrl') - .then(() => expectFileToMatch('dist/index.html', //)) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['baseHref'] = '/myUrl'; - })) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/index.html', //)) -} diff --git a/tests/e2e/tests/build/build-optimizer.ts b/tests/e2e/tests/build/build-optimizer.ts deleted file mode 100644 index 3ff84fdce3c1..000000000000 --- a/tests/e2e/tests/build/build-optimizer.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectFileToMatch, expectFileToExist } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - return ng('build', '--aot', '--build-optimizer') - .then(() => expectToFail(() => expectFileToExist('dist/vendor.js'))) - .then(() => expectToFail(() => expectFileToMatch('dist/main.js', /\.decorators =/))); -} diff --git a/tests/e2e/tests/build/bundle-budgets.ts b/tests/e2e/tests/build/bundle-budgets.ts new file mode 100644 index 000000000000..1401d8d3c6e3 --- /dev/null +++ b/tests/e2e/tests/build/bundle-budgets.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // Error + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'all', maximumError: '100b' }, + ]; + }); + + const { message: errorMessage } = await expectToFail(() => ng('build')); + assert.match(errorMessage, /Error.+budget/i, 'Budget error: all, max error.'); + + // Warning + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'all', minimumWarning: '100mb' }, + ]; + }); + + const { stderr } = await ng('build'); + assert.match(stderr, /Warning.+budget/i, 'Budget warning: all, min warning'); + + // Pass + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'allScript', maximumError: '100mb' }, + ]; + }); + + const { stderr: stderr2 } = await ng('build'); + assert.doesNotMatch(stderr2, /(Warning|Error)/i, 'BIG max for all, should not error'); +} diff --git a/tests/e2e/tests/build/chunk-hash.ts b/tests/e2e/tests/build/chunk-hash.ts deleted file mode 100644 index a3306410cc92..000000000000 --- a/tests/e2e/tests/build/chunk-hash.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {oneLine} from 'common-tags'; -import * as fs from 'fs'; - -import {ng} from '../../utils/process'; -import {writeFile} from '../../utils/fs'; -import {addImportToModule} from '../../utils/ast'; -import {getGlobalVariable} from '../../utils/env'; - -const OUTPUT_RE = /(main|polyfills|vendor|inline|styles|\d+)\.[a-z0-9]+\.(chunk|bundle)\.(js|css)$/; - -function generateFileHashMap(): Map { - const hashes = new Map(); - - fs.readdirSync('./dist') - .forEach(name => { - if (!name.match(OUTPUT_RE)) { - return; - } - - const [module, hash] = name.split('.'); - hashes.set(module, hash); - }); - - return hashes; -} - -function validateHashes( - oldHashes: Map, - newHashes: Map, - shouldChange: Array): void { - - console.log(' Validating hashes...'); - console.log(` Old hashes: ${JSON.stringify([...oldHashes])}`); - console.log(` New hashes: ${JSON.stringify([...newHashes])}`); - - oldHashes.forEach((hash, module) => { - if (hash == newHashes.get(module)) { - if (shouldChange.includes(module)) { - throw new Error(`Module "${module}" did not change hash (${hash})...`); - } - } else if (!shouldChange.includes(module)) { - throw new Error(`Module "${module}" changed hash (${hash})...`); - } - }); -} - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - - let oldHashes: Map; - let newHashes: Map; - // First, collect the hashes. - return Promise.resolve() - .then(() => ng('generate', 'module', 'lazy', '--routing')) - .then(() => addImportToModule('src/app/app.module.ts', oneLine` - RouterModule.forRoot([{ path: "lazy", loadChildren: "./lazy/lazy.module#LazyModule" }]) - `, '@angular/router')) - .then(() => addImportToModule( - 'src/app/app.module.ts', 'ReactiveFormsModule', '@angular/forms')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - oldHashes = generateFileHashMap(); - }) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, []); - oldHashes = newHashes; - }) - .then(() => writeFile('src/styles.css', 'body { background: blue; }')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, ['inline', 'styles']); - oldHashes = newHashes; - }) - .then(() => writeFile('src/app/app.component.css', 'h1 { margin: 10px; }')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, ['inline', 'main']); - oldHashes = newHashes; - }) - .then(() => addImportToModule( - 'src/app/lazy/lazy.module.ts', 'ReactiveFormsModule', '@angular/forms')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, ['inline', '0']); - }); -} diff --git a/tests/e2e/tests/build/chunk-optimizer-animations.ts b/tests/e2e/tests/build/chunk-optimizer-animations.ts new file mode 100644 index 000000000000..2a4e46adc676 --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-animations.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { installPackage } from '../../utils/packages'; +import { execWithEnv } from '../../utils/process'; +import { readNgVersion } from '../../utils/version'; + +export default async function () { + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + let animationsSpecifier: string; + if (isSnapshotBuild) { + const snapshots = require('../../ng-snapshot/package.json'); + animationsSpecifier = snapshots.dependencies['@angular/animations']; + } else { + const coreVersion = readNgVersion(); + animationsSpecifier = `@angular/animations@${coreVersion}`; + } + + // Install @angular/animations package + await installPackage(animationsSpecifier); + + // Configure app.config.ts with provideAnimationsAsync + const originalConfig = await readFile('src/app/app.config.ts'); + await writeFile( + 'src/app/app.config.ts', + ` + import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + ${originalConfig.replace(/providers:\s*\[/, 'providers: [provideAnimationsAsync(),')} + `, + ); + + const updatedConfig = await readFile('src/app/app.config.ts'); + assert.ok( + updatedConfig.includes('provideAnimationsAsync()'), + 'Expected src/app/app.config.ts to include provideAnimationsAsync().', + ); + + // Build with chunk optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '1', + }); + const optimizedFiles = await readdir('dist/test-project/browser'); + const optimizedJsFiles = optimizedFiles.filter((f) => f.endsWith('.js')); + + // Read the optimized main.js file + const mainCode = await readFile('dist/test-project/browser/main.js'); + + // Check that optimized chunks still contain more than 1 javascript file + assert.ok( + optimizedJsFiles.length > 1, + `Expected more than one chunk, but found ${optimizedJsFiles.length}.`, + ); + + // Check that one of the lazy loaded chunks contains the animations package code + let foundAnimationsChunk = false; + for (const file of optimizedJsFiles) { + if (file === 'main.js') { + continue; + } + const code = await readFile(`dist/test-project/browser/${file}`); + if (code.includes('AnimationEngine')) { + foundAnimationsChunk = true; + break; + } + } + + assert.ok( + foundAnimationsChunk, + 'Expected to find AnimationEngine in one of the optimized lazy chunks.', + ); + + // The animations engine should not be bundled in main.js + assert.ok( + !mainCode.includes('AnimationEngine'), + 'Expected main.js not to contain AnimationEngine from @angular/animations/browser.', + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer-env.ts b/tests/e2e/tests/build/chunk-optimizer-env.ts new file mode 100644 index 000000000000..e5221811664e --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-env.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; +import { installPackage, uninstallPackage } from '../../utils/packages'; + +export default async function () { + // Case 1: Force on with true/1 with 1 lazy chunk + await ng('generate', 'component', 'lazy-a'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + ];`, + ); + + // Build with forced optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + + // Build with forced off + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + + // We just verify it runs without error. + // With 1 chunk it might not be able to optimize further, so counts might be equal. + + // Case 2: Force off with false/0 with 3 lazy chunks + await ng('generate', 'component', 'lazy-b'); + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + `path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + },`, + `path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + },`, + ); + + // Build with forced off + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files3Unopt = await readdir('dist/test-project/browser'); + const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js')); + + // Build with default (should optimize because 3 chunks) + await ng('build', '--output-hashing=none'); + const files3Default = await readdir('dist/test-project/browser'); + const jsFiles3Default = files3Default.filter((f) => f.endsWith('.js')); + + assert.ok( + jsFiles3Default.length < jsFiles3Unopt.length, + `Expected default build (3 chunks) to be optimized compared to forced off. Default: ${jsFiles3Default.length}, Forced Off: ${jsFiles3Unopt.length}`, + ); + + // Case 3: Custom threshold + // Set threshold to 4 with 3 chunks -> should NOT optimize! + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '4', + }); + const files3Thresh4 = await readdir('dist/test-project/browser'); + const jsFiles3Thresh4 = files3Thresh4.filter((f) => f.endsWith('.js')); + + assert.ok( + jsFiles3Thresh4.length >= jsFiles3Unopt.length, + `Expected build with threshold 4 and 3 chunks to NOT be optimized. Thresh 4: ${jsFiles3Thresh4.length}, Unoptimized: ${jsFiles3Unopt.length}`, + ); + + // Case 4: Opt back into Rollup (requires manual install since it's an optional peer dependency) + await installPackage('rollup@4.62.0'); + try { + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_CHUNKS_ROLLDOWN: 'false', + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const filesRollup = await readdir('dist/test-project/browser'); + const jsFilesRollup = filesRollup.filter((f) => f.endsWith('.js')); + + assert.ok(jsFilesRollup.length > 0, 'Expected Rollup build to produce output files.'); + } finally { + // Clean up + await uninstallPackage('rollup'); + } +} diff --git a/tests/e2e/tests/build/chunk-optimizer-heuristic.ts b/tests/e2e/tests/build/chunk-optimizer-heuristic.ts new file mode 100644 index 000000000000..f8db4220699a --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-heuristic.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; + +export default async function () { + // Case 1: 2 lazy chunks (below threshold of 3) -> should NOT optimize by default + await ng('generate', 'component', 'lazy-a'); + await ng('generate', 'component', 'lazy-b'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + ];`, + ); + + // Build without explicit flag (should use default threshold of 3) + await ng('build', '--output-hashing=none'); + const files2 = await readdir('dist/test-project/browser'); + const jsFiles2 = files2.filter((f) => f.endsWith('.js')); + + // Build with forced optimization to see if it COULD reduce chunks + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const files2Opt = await readdir('dist/test-project/browser'); + const jsFiles2Opt = files2Opt.filter((f) => f.endsWith('.js')); + + // If forced optimization reduces chunks, then default should have MORE chunks (since it didn't run). + // If forced optimization doesn't reduce chunks, they will be equal. + // So we assert that default is NOT fewer than forced. + assert.ok( + jsFiles2.length >= jsFiles2Opt.length, + `Expected default build with 2 lazy chunks to NOT be optimized. Default: ${jsFiles2.length}, Forced: ${jsFiles2Opt.length}`, + ); + + // Case 2: 3 lazy chunks (at threshold of 3) -> should optimize by default + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + `path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + },`, + `path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + },`, + ); + + // Build without explicit flag (should use default threshold of 3) + await ng('build', '--output-hashing=none'); + const files3 = await readdir('dist/test-project/browser'); + const jsFiles3 = files3.filter((f) => f.endsWith('.js')); + + // Build with explicit disable + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files3Unopt = await readdir('dist/test-project/browser'); + const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js')); + + // Expect default build to be optimized (fewer chunks than explicitly disabled) + assert.ok( + jsFiles3.length < jsFiles3Unopt.length, + `Expected default build with 3 lazy chunks to be optimized. Default: ${jsFiles3.length}, Unoptimized: ${jsFiles3Unopt.length}`, + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer-lazy.ts b/tests/e2e/tests/build/chunk-optimizer-lazy.ts new file mode 100644 index 000000000000..9b51c74d0898 --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-lazy.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; + +export default async function () { + // Add lazy routes. + await ng('generate', 'component', 'lazy-a'); + await ng('generate', 'component', 'lazy-b'); + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + }, + ];`, + ); + + // Build without chunk optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const unoptimizedFiles = await readdir('dist/test-project/browser'); + const unoptimizedJsFiles = unoptimizedFiles.filter((f) => f.endsWith('.js')); + + // Build with chunk optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '1', + }); + const optimizedFiles = await readdir('dist/test-project/browser'); + const optimizedJsFiles = optimizedFiles.filter((f) => f.endsWith('.js')); + + // Check that the number of chunks is reduced but not all combined + assert.ok( + optimizedJsFiles.length < unoptimizedJsFiles.length, + `Expected chunk count to be less than ${unoptimizedJsFiles.length}, but was ${optimizedJsFiles.length}.`, + ); + assert.ok( + optimizedJsFiles.length > 1, + `Expected more than one chunk, but found ${optimizedJsFiles.length}.`, + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer.ts b/tests/e2e/tests/build/chunk-optimizer.ts new file mode 100644 index 000000000000..fff428b47263 --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { execWithEnv } from '../../utils/process'; + +/** + * AOT builds with chunk optimizer should contain generated component definitions. + * This is currently testing that the generated code is propagating through the + * chunk optimization step. + */ +export default async function () { + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '1', + NG_BUILD_MANGLE: '0', + }); + + const content = await readFile('dist/test-project/browser/main.js', 'utf-8'); + assert.match(content, /(ɵɵ|\\u0275\\u0275)defineComponent/u); +} diff --git a/tests/e2e/tests/build/config-file-fallback.ts b/tests/e2e/tests/build/config-file-fallback.ts index e480ad3e9c08..53298b573e12 100644 --- a/tests/e2e/tests/build/config-file-fallback.ts +++ b/tests/e2e/tests/build/config-file-fallback.ts @@ -1,10 +1,9 @@ -import {ng} from '../../utils/process'; -import {moveFile} from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { moveFile } from '../../utils/fs'; - -export default function() { +export default function () { return Promise.resolve() .then(() => ng('build')) - .then(() => moveFile('.angular-cli.json', 'angular-cli.json')) + .then(() => moveFile('angular.json', '.angular.json')) .then(() => ng('build')); } diff --git a/tests/e2e/tests/build/css-urls.ts b/tests/e2e/tests/build/css-urls.ts index ac86b2e3f008..839ecf58f567 100644 --- a/tests/e2e/tests/build/css-urls.ts +++ b/tests/e2e/tests/build/css-urls.ts @@ -1,13 +1,14 @@ -import * as fs from 'fs'; import { ng } from '../../utils/process'; import { expectFileToMatch, expectFileToExist, expectFileMatchToExist, - writeMultipleFiles + writeMultipleFiles, } from '../../utils/fs'; +import { copyProjectAsset } from '../../utils/assets'; import { expectToFail } from '../../utils/utils'; import { getGlobalVariable } from '../../utils/env'; +import { mkdir } from 'node:fs/promises'; const imgSvg = ` @@ -15,74 +16,210 @@ const imgSvg = ` `; -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } +export default async function () { + const usingWebpack = !getGlobalVariable('argv')['esbuild']; + + const mediaPath = usingWebpack + ? './dist/test-project/browser' + : './dist/test-project/browser/media'; + + await mkdir('public/assets/', { recursive: true }); - return Promise.resolve() + await Promise.resolve() // Verify absolute/relative paths in global/component css. - .then(() => writeMultipleFiles({ - 'src/styles.css': ` + .then(() => + writeMultipleFiles({ + 'src/styles.css': ` h1 { background: url('/assets/global-img-absolute.svg'); } - h2 { background: url('./assets/global-img-relative.svg'); } + h2 { background: url('./assets/global-img-relative.png'); } `, - 'src/app/app.component.css': ` + 'src/app/app.css': ` h3 { background: url('/assets/component-img-absolute.svg'); } - h4 { background: url('../assets/component-img-relative.svg'); } + h4 { background: url('../assets/component-img-relative.png'); } `, - // Using SVGs because they are loaded via file-loader and thus never inlined. - 'src/assets/global-img-absolute.svg': imgSvg, - 'src/assets/global-img-relative.svg': imgSvg, - 'src/assets/component-img-absolute.svg': imgSvg, - 'src/assets/component-img-relative.svg': imgSvg - })) - .then(() => ng('build', '--extract-css', '--aot')) + 'public/assets/global-img-absolute.svg': imgSvg, + 'public/assets/component-img-absolute.svg': imgSvg, + }), + ) + .then(() => copyProjectAsset('images/spectrum.png', './src/assets/global-img-relative.png')) + .then(() => copyProjectAsset('images/spectrum.png', './src/assets/component-img-relative.png')) + .then(() => ng('build', '--aot', '--configuration=development')) // Check paths are correctly generated. - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\('\/assets\/global-img-absolute\.svg'\)/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\(global-img-relative\.[0-9a-f]{20}\.svg\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(\\'\/assets\/component-img-absolute\.svg\\'\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(component-img-relative\.[0-9a-f]{20}\.svg\)/)) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', 'assets/global-img-absolute.svg'), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\((['"]?)\/assets\/global-img-absolute\.svg\1\)/, + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/main.js', '/assets/component-img-absolute.svg'), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/main.js', /component-img-relative\.png/), + ) // Check files are correctly created. - .then(() => expectToFail(() => expectFileToExist('dist/global-img-absolute.svg'))) - .then(() => expectToFail(() => expectFileToExist('dist/component-img-absolute.svg'))) - .then(() => expectFileMatchToExist('./dist', /global-img-relative\.[0-9a-f]{20}\.svg/)) - .then(() => expectFileMatchToExist('./dist', /component-img-relative\.[0-9a-f]{20}\.svg/)) - // Check urls with deploy-url scheme are used as is. - .then(() => ng('build', '--base-href=/base/', '--deploy-url=http://deploy.url/', - '--extract-css')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\(\'http:\/\/deploy\.url\/assets\/global-img-absolute\.svg\'\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(\'http:\/\/deploy\.url\/assets\/component-img-absolute\.svg\'\)/)) - // Check urls with base-href scheme are used as is (with deploy-url). - .then(() => ng('build', '--base-href=http://base.url/', '--deploy-url=deploy/', - '--extract-css')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\(\'http:\/\/base\.url\/deploy\/assets\/global-img-absolute\.svg\'\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(\'http:\/\/base\.url\/deploy\/assets\/component-img-absolute\.svg\'\)/)) - // Check urls with deploy-url and base-href scheme only use deploy-url. - .then(() => ng('build', '--base-href=http://base.url/', '--deploy-url=http://deploy.url/', - '--extract-css')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\(\'http:\/\/deploy\.url\/assets\/global-img-absolute\.svg\'\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(\'http:\/\/deploy\.url\/assets\/component-img-absolute\.svg\'\)/)) - // Check with base-href and deploy-url flags. - .then(() => ng('build', '--base-href=/base/', '--deploy-url=deploy/', - '--extract-css', '--aot')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\('\/base\/deploy\/assets\/global-img-absolute\.svg'\)/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /url\(global-img-relative\.[0-9a-f]{20}\.svg\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(\\'\/base\/deploy\/assets\/component-img-absolute\.svg\\'\)/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /url\(deploy\/component-img-relative\.[0-9a-f]{20}\.svg\)/)); + .then(() => expectToFail(() => expectFileToExist(`${mediaPath}/global-img-absolute.svg`))) + .then(() => expectToFail(() => expectFileToExist(`${mediaPath}/component-img-absolute.svg`))) + .then(() => expectFileMatchToExist(mediaPath, /global-img-relative\.png/)) + .then(() => expectFileMatchToExist(mediaPath, /component-img-relative\.png/)); + + // Early exit before deploy url tests + if (!usingWebpack) { + return; + } + + // Check urls with deploy-url scheme are used as is. + return ( + Promise.resolve() + .then(() => + ng( + 'build', + '--base-href=/base/', + '--deploy-url=http://deploy.url/', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\(\'\/assets\/global-img-absolute\.svg\'\)/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /url\(\'\/assets\/component-img-absolute\.svg\'\)/, + ), + ) + // Check urls with base-href scheme are used as is (with deploy-url). + .then(() => + ng( + 'build', + '--base-href=http://base.url/', + '--deploy-url=deploy/', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\(\'\/assets\/global-img-absolute\.svg\'\)/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /url\(\'\/assets\/component-img-absolute\.svg\'\)/, + ), + ) + // Check urls with deploy-url and base-href scheme only use deploy-url. + .then(() => + ng( + 'build', + '--base-href=http://base.url/', + '--deploy-url=http://deploy.url/', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\(\'\/assets\/global-img-absolute\.svg\'\)/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /url\(\'\/assets\/component-img-absolute\.svg\'\)/, + ), + ) + // Check with base-href and deploy-url flags. + .then(() => + ng( + 'build', + '--base-href=/base/', + '--deploy-url=deploy/', + '--aot', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + '/assets/global-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + '/assets/component-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /deploy\/component-img-relative\.png/, + ), + ) + // Check with identical base-href and deploy-url flags. + .then(() => + ng( + 'build', + '--base-href=/base/', + '--deploy-url=/base/', + '--aot', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + '/assets/global-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + '/assets/component-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /\/base\/component-img-relative\.png/, + ), + ) + // Check with only base-href flag. + .then(() => ng('build', '--base-href=/base/', '--aot', '--configuration=development')) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + '/assets/global-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + '/assets/component-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/main.js', /component-img-relative\.png/), + ) + ); } diff --git a/tests/e2e/tests/build/delete-output-path.ts b/tests/e2e/tests/build/delete-output-path.ts deleted file mode 100644 index e503a67f6db7..000000000000 --- a/tests/e2e/tests/build/delete-output-path.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; -import {deleteFile, expectFileToExist} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return ng('build') - // This is supposed to fail since there's a missing file - .then(() => deleteFile('src/app/app.component.ts')) - // The build fails but we don't delete the output of the previous build. - .then(() => expectToFail(() => ng('build', '--no-delete-output-path'))) - .then(() => expectFileToExist('dist')) - // By default, output path is always cleared. - .then(() => expectToFail(() => ng('build'))) - .then(() => expectToFail(() => expectFileToExist('dist'))); -} diff --git a/tests/e2e/tests/build/deploy-url.ts b/tests/e2e/tests/build/deploy-url.ts deleted file mode 100644 index 83d66b5ba5a2..000000000000 --- a/tests/e2e/tests/build/deploy-url.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; -import { stripIndents } from 'common-tags'; - - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/styles.css': 'div { background: url("./assets/more.svg"); }', - 'src/assets/more.svg': stripIndents` - - - - `})) - .then(() => ng('build', '--deploy-url=deployUrl/', '--extract-css')) - .then(() => expectFileToMatch('dist/index.html', 'deployUrl/main.bundle.js')) - // verify --deploy-url isn't applied to extracted css urls - .then(() => expectFileToMatch('dist/styles.bundle.css', /url\(more\.[0-9a-f]{20}\.svg\)/)) - // verify option also works in config - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['deployUrl'] = 'config-deployUrl/'; - })) - .then(() => ng('build')) - .then(() => expectFileToMatch('dist/index.html', 'config-deployUrl/main.bundle.js')) - // verify --deploy-url is applied to non-extracted css urls - .then(() => ng('build', '--deploy-url=deployUrl/', '--extract-css=false')) - .then(() => expectFileToMatch('dist/styles.bundle.js', - /__webpack_require__.p \+ \"more\.[0-9a-f]{20}\.svg\"/)); -} diff --git a/tests/e2e/tests/build/dev-build.ts b/tests/e2e/tests/build/dev-build.ts deleted file mode 100644 index 34729084b075..000000000000 --- a/tests/e2e/tests/build/dev-build.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch, expectFileToExist} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {getGlobalVariable} from '../../utils/env'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - const ejected = getGlobalVariable('argv').eject; - - return ng('build', '--env=dev') - .then(() => expectFileToMatch('dist/index.html', 'main.bundle.js')) - .then(() => expectToFail(() => expectFileToExist('dist/3rdpartylicenses.txt'))) - // If this is an ejected test, the eject will create files so git will not be clean. - .then(() => !ejected && expectGitToBeClean()); -} diff --git a/tests/e2e/tests/build/disk-cache-purge.ts b/tests/e2e/tests/build/disk-cache-purge.ts new file mode 100644 index 000000000000..52edd4845038 --- /dev/null +++ b/tests/e2e/tests/build/disk-cache-purge.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path'; +import { createDir, expectFileNotToExist, expectFileToExist, writeFile } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + const cachePath = '.angular/cache'; + const staleCachePath = join(cachePath, 'v1.0.0'); + + // No need to include all applications code to verify disk cache existence. + await writeFile('src/main.ts', 'console.log(1);'); + + // Enable cache for all environments + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = { + environment: 'all', + enabled: true, + path: cachePath, + }; + }); + + // Create a dummy stale disk cache directory. + await createDir(staleCachePath); + await expectFileToExist(staleCachePath); + + await silentNg('build'); + await expectFileToExist(cachePath); + await expectFileNotToExist(staleCachePath); +} diff --git a/tests/e2e/tests/build/disk-cache.ts b/tests/e2e/tests/build/disk-cache.ts new file mode 100644 index 000000000000..1873905646ca --- /dev/null +++ b/tests/e2e/tests/build/disk-cache.ts @@ -0,0 +1,56 @@ +import { expectFileNotToExist, expectFileToExist, rimraf, writeFile } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +const defaultCachePath = '.angular/cache'; +const overriddenCachePath = '.cache/angular-cli'; + +export default async function () { + const originalCIValue = process.env['CI']; + + // No need to include all applications code to verify disk cache existence. + await writeFile('src/main.ts', 'console.log(1);'); + + try { + // Should be enabled by default. + process.env['CI'] = '0'; + await configureAndRunTest(); + + // Should not write cache when it's disabled + await configureAndRunTest({ enabled: false }); + await expectFileNotToExist(defaultCachePath); + + // Should not write cache by default when in CI. + process.env['CI'] = '1'; + await configureAndRunTest(); + await expectFileNotToExist(defaultCachePath); + + // Should write cache when it's enabled and 'environment' is set to 'all' or 'ci'. + await configureAndRunTest({ environment: 'all' }); + await expectFileToExist(defaultCachePath); + + // Should write cache to custom path when configured. + await configureAndRunTest({ environment: 'ci', path: overriddenCachePath }); + await expectFileNotToExist(defaultCachePath); + await expectFileToExist(overriddenCachePath); + } finally { + process.env['CI'] = originalCIValue; + } +} + +async function configureAndRunTest(cacheOptions?: { + environment?: 'ci' | 'local' | 'all'; + enabled?: boolean; + path?: string; +}): Promise { + await Promise.all([ + rimraf(overriddenCachePath), + rimraf(defaultCachePath), + updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = cacheOptions; + }), + ]); + + await silentNg('build'); +} diff --git a/tests/e2e/tests/build/environment.ts b/tests/e2e/tests/build/environment.ts deleted file mode 100644 index e6e736bae029..000000000000 --- a/tests/e2e/tests/build/environment.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {expectToFail} from '../../utils/utils'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // Disable parts of it in webpack tests. - const ejected = getGlobalVariable('argv').eject; - - // Try a prod build. - return ng('build', '--env=prod') - .then(() => expectFileToMatch('dist/main.bundle.js', 'production: true')) - // If this is an ejected test, the eject will create files so git will not be clean. - .then(() => !ejected && expectGitToBeClean()) - - // Build fails on invalid build target - .then(() => expectToFail(() => ng('build', '--target=potato'))) - - // This is a valid target. - .then(() => ng('build', '--target=production')); -} diff --git a/tests/e2e/tests/build/extract-licenses.ts b/tests/e2e/tests/build/extract-licenses.ts index 601c97796b56..04ab08f5d49e 100644 --- a/tests/e2e/tests/build/extract-licenses.ts +++ b/tests/e2e/tests/build/extract-licenses.ts @@ -1,10 +1,29 @@ -import {join} from 'path'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; -import {ng} from '../../utils/process'; - -export default function() { - return ng('build', '--prod', '--extract-licenses=false') - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - .then(() => expectToFail(() => expectFileToExist('dist/3rdpartylicenses.txt'))); +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const usingWebpack = !getGlobalVariable('argv')['esbuild']; + + // Licenses should be left intact if extraction is disabled + await ng('build', '--extract-licenses=false', '--output-hashing=none'); + + if (usingWebpack) { + await expectToFail(() => expectFileToExist('dist/test-project/browser/3rdpartylicenses.txt')); + } else { + // Application builder puts the licenses at the output path root + await expectToFail(() => expectFileToExist('dist/test-project/3rdpartylicenses.txt')); + } + await expectFileToMatch('dist/test-project/browser/main.js', '@license'); + + // Licenses should be removed if extraction is enabled + await ng('build', '--extract-licenses', '--output-hashing=none'); + + if (usingWebpack) { + await expectFileToExist('dist/test-project/browser/3rdpartylicenses.txt'); + } else { + await expectFileToExist('dist/test-project/3rdpartylicenses.txt'); + } + await expectToFail(() => expectFileToMatch('dist/test-project/browser/main.js', '@license')); } diff --git a/tests/e2e/tests/build/filename.ts b/tests/e2e/tests/build/filename.ts deleted file mode 100644 index 2399e833a803..000000000000 --- a/tests/e2e/tests/build/filename.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; -import {copyFile} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => copyFile('src/index.html', 'src/config-index.html')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['outDir'] = 'config-build-output'; - app['index'] = 'config-index.html'; - })) - .then(() => ng('build')) - .then(() => expectFileToExist('./config-build-output/config-index.html')); -} diff --git a/tests/e2e/tests/build/incremental-watch.ts b/tests/e2e/tests/build/incremental-watch.ts new file mode 100644 index 000000000000..68f281c8bab5 --- /dev/null +++ b/tests/e2e/tests/build/incremental-watch.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, readFile, writeFile } from '../../utils/fs'; +import { execAndWaitForOutputToMatch, waitForAnyProcessOutputToMatch } from '../../utils/process'; + +const buildReadyRegEx = /Application bundle generation complete\./; + +async function getOutputChunks( + dir: string, + predicate: (chunks: string[]) => boolean, + timeout = 10_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const chunks = (await readdir(dir)).filter((file) => file.endsWith('.js')); + if (predicate(chunks)) { + return chunks; + } + } catch (err: any) { + if (err?.code !== 'ENOENT') { + throw err; + } + } + await setTimeout(50); + } + + const chunks = (await readdir(dir)).filter((file) => file.endsWith('.js')); + assert(predicate(chunks), `Condition not met for chunks in ${dir}: ${JSON.stringify(chunks)}`); + + return chunks; +} + +export default async function () { + const usingApplicationBuilder = getGlobalVariable('argv')['esbuild']; + assert( + usingApplicationBuilder, + 'Incremental watch E2E test should not be executed with Webpack.', + ); + + // Perform an initial build in watch mode + await execAndWaitForOutputToMatch( + 'ng', + ['build', '--watch', '--configuration=development'], + buildReadyRegEx, + ); + const initialOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length > 0, + ); + + const originalMain = await readFile('src/main.ts'); + + // Add a dynamic import to create an additional output chunk + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + writeFile( + 'src/a.ts', + ` + export function sayHi() { + console.log('hi'); + } + `, + ), + appendToFile('src/main.ts', `\nimport('./a').then((m) => m.sayHi());`), + ]); + const intermediateOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length > initialOutputChunks.length, + ); + assert( + initialOutputChunks.length < intermediateOutputChunks.length, + 'Additional chunks should be present', + ); + + // Remove usage of dynamic import which should remove the additional output chunk + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + writeFile('src/main.ts', originalMain), + ]); + const finalOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length === initialOutputChunks.length, + ); + assert.equal( + initialOutputChunks.length, + finalOutputChunks.length, + 'Final chunk count should be equal to initial chunk count.', + ); +} diff --git a/tests/e2e/tests/build/jit-ngmodule.ts b/tests/e2e/tests/build/jit-ngmodule.ts new file mode 100644 index 000000000000..aa6b3fda86bb --- /dev/null +++ b/tests/e2e/tests/build/jit-ngmodule.ts @@ -0,0 +1,48 @@ +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; +import { updateJsonFile, useCIDefaults } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + await ng('generate', 'app', 'test-project-two', '--no-standalone', '--skip-install'); + await useCIDefaults('test-project-two'); + + // Make prod use JIT. + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + // Setup webpack builder if esbuild is not requested on the commandline + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project-two']['architect']['build']; + if (useWebpackBuilder) { + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + buildOptimizer: false, + outputPath: 'dist/test-project-two', + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + }; + } + + // Remove bundle budgets due to the increased size from JIT + build.configurations.production = { + ...build.configurations.production, + budgets: undefined, + }; + + build.options.aot = false; + + const serve = json['projects']['test-project-two']['architect']['serve']; + serve.builder = '@angular-devkit/build-angular:dev-server'; + }); + // Test it works + await executeBrowserTest({ project: 'test-project-two', configuration: 'production' }); + await executeBrowserTest({ project: 'test-project-two', configuration: 'development' }); +} diff --git a/tests/e2e/tests/build/jit-prod.ts b/tests/e2e/tests/build/jit-prod.ts new file mode 100644 index 000000000000..b2dc9d0bdddc --- /dev/null +++ b/tests/e2e/tests/build/jit-prod.ts @@ -0,0 +1,22 @@ +import { getGlobalVariable } from '../../utils/env'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Make prod use JIT. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + appArchitect.build.configurations['production'].aot = false; + + // JIT applications have significantly larger sizes + appArchitect.build.configurations['production'].budgets = []; + + if (!getGlobalVariable('argv')['esbuild']) { + // The build optimizer option does not exist with the application build system + appArchitect.build.configurations['production'].buildOptimizer = false; + } + }); + + // Test it works + await executeBrowserTest({ configuration: 'production' }); +} diff --git a/tests/e2e/tests/build/json.ts b/tests/e2e/tests/build/json.ts deleted file mode 100644 index 71cfa32f0544..000000000000 --- a/tests/e2e/tests/build/json.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return ng('build', '--stats-json') - .then(() => expectFileToExist('./dist/stats.json')) - .then(() => expectGitToBeClean()); -} diff --git a/tests/e2e/tests/build/lazy-load-syntax.ts b/tests/e2e/tests/build/lazy-load-syntax.ts new file mode 100644 index 000000000000..bc0a375673dc --- /dev/null +++ b/tests/e2e/tests/build/lazy-load-syntax.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Add lazy route. + await ng('generate', 'component', 'lazy-comp'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [{ + path: 'lazy', + loadComponent: () => import('./lazy-comp/lazy-comp').then(c => c.LazyComp), + }];`, + ); + + // Convert the default config to use JIT and prod to just do AOT. + // This way we can use `ng e2e` to test JIT and `ng e2e --configuration=production` to test AOT. + await updateJsonFile('angular.json', (json) => { + const buildTarget = json['projects']['test-project']['architect']['build']; + buildTarget['options']['aot'] = true; + buildTarget['configurations']['development']['aot'] = false; + }); + + const checkFn = async (page: any) => { + await page.goto(page.url() + 'lazy'); + await page.waitForFunction( + () => + !!(globalThis as any).document + .querySelector('app-lazy-comp p') + ?.textContent?.includes('lazy-comp works!'), + { timeout: 10000 }, + ); + }; + + await executeBrowserTest({ checkFn }); + await executeBrowserTest({ configuration: 'production', checkFn }); +} diff --git a/tests/e2e/tests/build/library-with-demo-app.ts b/tests/e2e/tests/build/library-with-demo-app.ts new file mode 100644 index 000000000000..8e8d14ceb54f --- /dev/null +++ b/tests/e2e/tests/build/library-with-demo-app.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { appendToFile, createDir, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'mylib'); + await createLibraryEntryPoint('secondary'); + await createLibraryEntryPoint('another'); + + // Scenario #1 where we use wildcard path mappings for secondary entry-points. + await updateJsonFile('tsconfig.json', (json) => { + json.compilerOptions.paths = { 'mylib': ['./dist/mylib'], 'mylib/*': ['./dist/mylib/*'] }; + }); + + await appendToFile( + 'src/app/app.config.ts', + ` + import * as secondary from 'mylib/secondary'; + import * as another from 'mylib/another'; + + console.log({ + secondary, + another + }); + `, + ); + + await ng('build', 'mylib'); + await ng('build'); + + // Scenario #2 where we don't use wildcard path mappings. + await updateJsonFile('tsconfig.json', (json) => { + json.compilerOptions.paths = { + 'mylib': ['./dist/mylib'], + 'mylib/secondary': ['./dist/mylib/secondary'], + 'mylib/another': ['./dist/mylib/another'], + }; + }); + + await ng('build'); +} + +async function createLibraryEntryPoint(name: string): Promise { + await createDir(`projects/mylib/${name}`); + await writeFile(`projects/mylib/${name}/index.ts`, `export const foo = 'foo';`); + + await writeFile( + `projects/mylib/${name}/ng-package.json`, + JSON.stringify({ + lib: { + entryFile: 'index.ts', + }, + }), + ); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-full-aot.ts b/tests/e2e/tests/build/library/lib-consumption-full-aot.ts new file mode 100644 index 000000000000..08d114f1de4a --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-full-aot.ts @@ -0,0 +1,14 @@ +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in full mode (development) + await ng('build', 'my-lib', '--configuration=development'); + + // Check that the e2e succeeds prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-full-jit.ts b/tests/e2e/tests/build/library/lib-consumption-full-jit.ts new file mode 100644 index 000000000000..906a920dba44 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-full-jit.ts @@ -0,0 +1,34 @@ +import { updateJsonFile } from '../../../utils/project'; +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in full mode (development) + await ng('build', 'my-lib', '--configuration=development'); + + // JIT linking + await updateJsonFile('angular.json', (config) => { + const build = config.projects['test-project'].architect.build; + build.options.aot = false; + build.configurations.production.budgets = undefined; + if (!getGlobalVariable('argv')['esbuild']) { + build.configurations.production.buildOptimizer = false; + } + }); + + // Ensure app works in prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); + + // Validate that sourcemaps for the library exists. + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/main.js.map', + 'projects/my-lib/src/lib/my-lib.ts', + ); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-partial-aot.ts b/tests/e2e/tests/build/library/lib-consumption-partial-aot.ts new file mode 100644 index 000000000000..f906be54b0e6 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-partial-aot.ts @@ -0,0 +1,14 @@ +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in partial mode (production) + await ng('build', 'my-lib', '--configuration=production'); + + // Check that the e2e succeeds prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-partial-jit.ts b/tests/e2e/tests/build/library/lib-consumption-partial-jit.ts new file mode 100644 index 000000000000..503c09e525e9 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-partial-jit.ts @@ -0,0 +1,27 @@ +import { updateJsonFile } from '../../../utils/project'; +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in partial mode (production) + await ng('build', 'my-lib', '--configuration=production'); + + // JIT linking + await updateJsonFile('angular.json', (config) => { + const build = config.projects['test-project'].architect.build; + build.options.aot = false; + build.configurations.production.budgets = undefined; + + if (!getGlobalVariable('argv')['esbuild']) { + build.configurations.production.buildOptimizer = false; + } + }); + + // Check that the e2e succeeds prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-sourcemaps.ts b/tests/e2e/tests/build/library/lib-consumption-sourcemaps.ts new file mode 100644 index 000000000000..c0b7a5e78a73 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-sourcemaps.ts @@ -0,0 +1,17 @@ +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { libraryConsumptionSetup } from './setup'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in full mode (development) + await ng('build', 'my-lib', '--configuration=development'); + + // Validate that sourcemaps for the library exists. + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/main.js.map', + 'projects/my-lib/src/lib/my-lib.ts', + ); +} diff --git a/tests/e2e/tests/build/library/lib-unused-decorated-class-treeshake.ts b/tests/e2e/tests/build/library/lib-unused-decorated-class-treeshake.ts new file mode 100644 index 000000000000..33b221a32efe --- /dev/null +++ b/tests/e2e/tests/build/library/lib-unused-decorated-class-treeshake.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert'; +import { appendToFile, expectFileToExist, expectFileToMatch, readFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { libraryConsumptionSetup } from './setup'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await ng('cache', 'off'); + await libraryConsumptionSetup(); + + // Add an unused class as part of the public api. + await appendToFile( + 'projects/my-lib/src/lib/my-lib.ts', + ` + function something() { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + console.log("someDecorator"); + }; + } + + export class ExampleClass { + @something() + method() {} + } + `, + ); + + // build the lib + await ng('build', 'my-lib', '--configuration=production'); + const packageJson = JSON.parse(await readFile('dist/my-lib/package.json')); + assert.equal(packageJson.sideEffects, false); + + // build the app + await ng('build', 'test-project', '--configuration=production', '--output-hashing=none'); + // Output should not contain `ExampleClass` as the library is marked as side-effect free. + await expectFileToExist('dist/test-project/browser/main.js'); + await expectToFail(() => expectFileToMatch('dist/test-project/browser/main.js', 'someDecorator')); + + // Mark library as side-effectful. + await updateJsonFile('dist/my-lib/package.json', (packageJson) => { + packageJson.sideEffects = true; + }); + + // build the app + await ng('build', 'test-project', '--configuration=production', '--output-hashing=none'); + // Output should contain `ExampleClass` as the library is marked as side-effectful. + await expectFileToMatch('dist/test-project/browser/main.js', 'someDecorator'); +} diff --git a/tests/e2e/tests/build/library/setup.ts b/tests/e2e/tests/build/library/setup.ts new file mode 100644 index 000000000000..621b740cf4bc --- /dev/null +++ b/tests/e2e/tests/build/library/setup.ts @@ -0,0 +1,45 @@ +import type { Page } from 'puppeteer'; +import { writeMultipleFiles } from '../../../utils/fs'; +import { silentNg } from '../../../utils/process'; + +export async function libraryConsumptionSetup(): Promise { + await silentNg('generate', 'library', 'my-lib'); + + // Force an external template + await writeMultipleFiles({ + 'projects/my-lib/src/lib/my-lib.html': `

my-lib works!

`, + 'projects/my-lib/src/lib/my-lib.ts': `import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-my-lib', + templateUrl: './my-lib.html', + }) + export class MyLibComponent {}`, + './src/app/app.ts': ` + import { Component } from '@angular/core'; + import { MyLibComponent } from 'my-lib'; + + @Component({ + selector: 'app-root', + template: '', + imports: [MyLibComponent], + }) + export class App { + title = 'test-project'; + + constructor() { + } + } + `, + }); +} + +export async function browserCheck(page: Page): Promise { + await page.waitForFunction( + () => + !!(globalThis as any).document + .querySelector('lib-my-lib p') + ?.textContent?.includes('my-lib works!'), + { timeout: 10000 }, + ); +} diff --git a/tests/e2e/tests/build/material.ts b/tests/e2e/tests/build/material.ts new file mode 100644 index 000000000000..bc4862f735fc --- /dev/null +++ b/tests/e2e/tests/build/material.ts @@ -0,0 +1,89 @@ +import { appendFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, replaceInFile } from '../../utils/fs'; +import { + getActivePackageManager, + installPackage, + installWorkspacePackages, +} from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { isPrereleaseCli, updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isPrerelease = await isPrereleaseCli(); + let tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Angular material adds dependencies on other Angular packages + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries(snapshots.dependencies)) { + if (name in dependencies) { + dependencies[name] = version; + } + } + + dependencies['@angular/material-moment-adapter'] = + snapshots.dependencies['@angular/material-moment-adapter']; + }); + await installWorkspacePackages(); + } else { + if (!tag) { + const installedMaterialVersion = JSON.parse(await readFile('package.json'))['dependencies'][ + '@angular/material' + ]; + tag = `@${installedMaterialVersion}`; + } + await installPackage(`@angular/material-moment-adapter${tag}`); + } + + await installPackage('moment'); + + await ng('build'); + + // Ensure moment adapter works (uses unique importing mechanism for moment) + // Issue: https://github.com/angular/angular-cli/issues/17320 + await replaceInFile( + 'src/app/app.config.ts', + `from '@angular/core';`, + ` + from '@angular/core'; + import { + MomentDateAdapter, + MAT_MOMENT_DATE_FORMATS + } from '@angular/material-moment-adapter'; + import { + DateAdapter, + MAT_DATE_LOCALE, + MAT_DATE_FORMATS + } from '@angular/material/core'; + `, + ); + + await replaceInFile( + 'src/app/app.config.ts', + `provideRouter(routes)`, + `provideRouter(routes), + { + provide: DateAdapter, + useClass: MomentDateAdapter, + deps: [MAT_DATE_LOCALE] + }, + { + provide: MAT_DATE_FORMATS, + useValue: MAT_MOMENT_DATE_FORMATS + }`, + ); + + await executeBrowserTest({ configuration: 'production' }); +} diff --git a/tests/e2e/tests/build/module-id.ts b/tests/e2e/tests/build/module-id.ts deleted file mode 100644 index 581d8bbddf1d..000000000000 --- a/tests/e2e/tests/build/module-id.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ng } from '../../utils/process'; -import { replaceInFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => replaceInFile('src/app/app.component.ts', - '@Component({', - '@Component({ moduleId: module.id,')) - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/build/multiple-configs.ts b/tests/e2e/tests/build/multiple-configs.ts new file mode 100644 index 000000000000..dbbeba4658e3 --- /dev/null +++ b/tests/e2e/tests/build/multiple-configs.ts @@ -0,0 +1,63 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // TODO: Restructure to support application builder option + // This only needs to be tested once since it is really testing the CLI itself and not the builders + if (getGlobalVariable('argv')['esbuild']) { + return; + } + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + // These are the default options, that we'll overwrite in subsequent configs. + // sourceMap defaults to true + appArchitect['build'] = { + ...appArchitect['build'], + defaultConfiguration: undefined, + options: { + ...appArchitect['build'].options, + optimization: false, + sourceMap: true, + outputHashing: 'none', + vendorChunk: true, + styles: ['src/styles.css'], + scripts: [], + budgets: [], + }, + configurations: { + development: { + sourceMap: true, + }, + one: { + assets: [], + }, + two: { + sourceMap: false, + }, + }, + }; + + return workspaceJson; + }); + + // Test the base configuration. + await ng('build', '--configuration=development'); + await expectFileToExist('dist/test-project/browser/favicon.ico'); + await expectFileToExist('dist/test-project/browser/main.js.map'); + await expectFileToExist('dist/test-project/browser/vendor.js'); + await ng('build'); + await expectFileToExist('dist/test-project/browser/styles.css'); + // Use two configurations. + await ng('build', '--configuration=one,two', '--vendor-chunk=false'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/favicon.ico')); + await expectToFail(() => expectFileToExist('dist/test-project/browser/main.js.map')); + // Use two configurations and two overrides, one of which overrides a config. + await ng('build', '--configuration=one,two', '--vendor-chunk=false', '--source-map=true'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/favicon.ico')); + await expectFileToExist('dist/test-project/browser/main.js.map'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/vendor.js')); +} diff --git a/tests/e2e/tests/build/no-angular-router.ts b/tests/e2e/tests/build/no-angular-router.ts deleted file mode 100644 index 84eb22cd14d0..000000000000 --- a/tests/e2e/tests/build/no-angular-router.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist, moveFile} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; -import {getGlobalVariable} from '../../utils/env'; -import * as path from 'path'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const tmp = getGlobalVariable('tmp-root'); - - return Promise.resolve() - .then(() => moveFile('node_modules/@angular/router', path.join(tmp, '@angular-router.backup'))) - .then(() => ng('build')) - .then(() => expectFileToExist('./dist/index.html')) - .then(() => moveFile(path.join(tmp, '@angular-router.backup'), 'node_modules/@angular/router')); -} diff --git a/tests/e2e/tests/build/no-implicit-any.ts b/tests/e2e/tests/build/no-implicit-any.ts deleted file mode 100644 index 340f7169b698..000000000000 --- a/tests/e2e/tests/build/no-implicit-any.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {updateTsConfig} from '../../utils/project'; -import {ng} from '../../utils/process'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return updateTsConfig(json => { - json['compilerOptions']['noImplicitAny'] = true; - }) - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/build/output-dir.ts b/tests/e2e/tests/build/output-dir.ts index 5273cbd0c558..0a93ebae67b4 100644 --- a/tests/e2e/tests/build/output-dir.ts +++ b/tests/e2e/tests/build/output-dir.ts @@ -1,27 +1,29 @@ -import {getGlobalVariable} from '../../utils/env'; -import {expectFileToExist} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { expectGitToBeClean } from '../../utils/git'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; +export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. -export default function() { - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } + const usingWebpack = !getGlobalVariable('argv')['esbuild']; - return ng('build', '-op', './build-output') - .then(() => expectFileToExist('./build-output/index.html')) - .then(() => expectFileToExist('./build-output/main.bundle.js')) + return ng('build', '--output-path', 'build-output', '--configuration=development') + .then(() => expectFileToExist(`./build-output/${usingWebpack ? '' : 'browser/'}index.html`)) + .then(() => expectFileToExist(`./build-output/${usingWebpack ? '' : 'browser/'}main.js`)) .then(() => expectToFail(expectGitToBeClean)) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['outDir'] = 'config-build-output'; - })) - .then(() => ng('build')) - .then(() => expectFileToExist('./config-build-output/index.html')) - .then(() => expectFileToExist('./config-build-output/main.bundle.js')) + .then(() => + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.outputPath = 'config-build-output'; + }), + ) + .then(() => ng('build', '--configuration=development')) + .then(() => + expectFileToExist(`./config-build-output/${usingWebpack ? '' : 'browser/'}index.html`), + ) + .then(() => expectFileToExist(`./config-build-output/${usingWebpack ? '' : 'browser/'}main.js`)) .then(() => expectToFail(expectGitToBeClean)); } diff --git a/tests/e2e/tests/build/output-hashing.ts b/tests/e2e/tests/build/output-hashing.ts deleted file mode 100644 index a7c091c25041..000000000000 --- a/tests/e2e/tests/build/output-hashing.ts +++ /dev/null @@ -1,47 +0,0 @@ -import {stripIndents} from 'common-tags'; -import {ng} from '../../utils/process'; -import { writeMultipleFiles, expectFileToMatch, expectFileMatchToExist } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - -function verifyMedia(css: RegExp, content: RegExp) { - return expectFileMatchToExist('./dist', css) - .then(fileName => expectFileToMatch(`dist/${fileName}`, content)); -} - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/styles.css': stripIndents` - body { background-image: url("image.svg"); } - `, - 'src/image.svg': 'I would like to be an image someday.' - })) - .then(() => ng('build', '--dev', '--output-hashing=all')) - .then(() => expectFileToMatch('dist/index.html', /inline\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /main\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.[0-9a-f]{20}\.bundle\.(css|js)/)) - .then(() => verifyMedia(/styles\.[0-9a-f]{20}\.bundle\.(css|js)/, /image\.[0-9a-f]{20}\.svg/)) - - .then(() => ng('build', '--prod', '--output-hashing=none')) - .then(() => expectFileToMatch('dist/index.html', /inline\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /main\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.bundle\.(css|js)/)) - .then(() => verifyMedia(/styles\.bundle\.(css|js)/, /image\.svg/)) - - .then(() => ng('build', '--dev', '--output-hashing=media')) - .then(() => expectFileToMatch('dist/index.html', /inline\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /main\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.bundle\.(css|js)/)) - .then(() => verifyMedia(/styles\.bundle\.(css|js)/, /image\.[0-9a-f]{20}\.svg/)) - - .then(() => ng('build', '--dev', '--output-hashing=bundles')) - .then(() => expectFileToMatch('dist/index.html', /inline\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /main\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.[0-9a-f]{20}\.bundle\.(css|js)/)) - .then(() => verifyMedia(/styles\.[0-9a-f]{20}\.bundle\.(css|js)/, /image\.svg/)); -} diff --git a/tests/e2e/tests/build/platform-server.ts b/tests/e2e/tests/build/platform-server.ts deleted file mode 100644 index 314435b13dd3..000000000000 --- a/tests/e2e/tests/build/platform-server.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { normalize } from 'path'; - -import { updateJsonFile, updateTsConfig } from '../../utils/project'; -import { expectFileToMatch, writeFile, replaceInFile, prependToFile } from '../../utils/fs'; -import { ng, silentNpm, silentExec } from '../../utils/process'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - // Skip this for ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - delete app['polyfills']; - delete app['styles']; - app['platform'] = 'server'; - })) - .then(() => updateJsonFile('package.json', packageJson => { - const dependencies = packageJson['dependencies']; - dependencies['@angular/platform-server'] = '^4.0.0'; - })) - .then(() => updateTsConfig(tsConfig => { - tsConfig['angularCompilerOptions'] = { - entryModule: 'app/app.module#AppModule' - }; - })) - .then(() => writeFile('./src/main.ts', 'export { AppModule } from \'./app/app.module\';')) - .then(() => prependToFile('./src/app/app.module.ts', - 'import { ServerModule } from \'@angular/platform-server\';')) - .then(() => replaceInFile('./src/app/app.module.ts', /\[\s*BrowserModule/g, - `[BrowserModule.withServerTransition(\{ appId: 'app' \}), ServerModule`)) - .then(() => silentNpm('install')) - .then(() => ng('build')) - // files were created successfully - .then(() => expectFileToMatch('dist/main.bundle.js', - /__webpack_exports__, "AppModule"/)) - .then(() => writeFile('./index.js', ` - require('zone.js/dist/zone-node'); - require('reflect-metadata'); - const fs = require('fs'); - const \{ AppModule \} = require('./dist/main.bundle'); - const \{ renderModule \} = require('@angular/platform-server'); - - renderModule(AppModule, \{ - url: '/', - document: '' - \}).then(html => \{ - fs.writeFileSync('dist/index.html', html); - \}); - `)) - .then(() => silentExec(normalize('node'), 'index.js')) - .then(() => expectFileToMatch('dist/index.html', - new RegExp('

Here are some links to help you start:

'))) - .then(() => ng('build', '--aot')) - // files were created successfully - .then(() => expectFileToMatch('dist/main.bundle.js', - /__webpack_exports__, "AppModuleNgFactory"/)) - .then(() => replaceInFile('./index.js', /AppModule/g, 'AppModuleNgFactory')) - .then(() => replaceInFile('./index.js', /renderModule/g, 'renderModuleFactory')) - .then(() => silentExec(normalize('node'), 'index.js')) - .then(() => expectFileToMatch('dist/index.html', - new RegExp('

Here are some links to help you start:

'))); -} diff --git a/tests/e2e/tests/build/poll.ts b/tests/e2e/tests/build/poll.ts index 202eed096541..3b72b7c2d4e6 100644 --- a/tests/e2e/tests/build/poll.ts +++ b/tests/e2e/tests/build/poll.ts @@ -1,37 +1,29 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch -} from '../../utils/process'; -import {appendToFile} from '../../utils/fs'; -import {expectToFail, wait} from '../../utils/utils'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile } from '../../utils/fs'; +import { waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { ngServe } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; -const webpackGoodRegEx = /webpack: Compiled successfully./; +const webpackGoodRegEx = getGlobalVariable('argv')['esbuild'] + ? /Application bundle generation complete\./ + : / Compiled successfully\./; -export default function() { +export default async function () { + await ngServe('--poll=10000'); - // @filipesilva: This test doesn't work correctly on CircleCI while being ran by the test script. - // Polling time seems to be ignored and several builds are fired per second. - // Debugging showed that webpack things the `src/` directory changed on each rebuild. - // Disabling for now. - if (process.env['CIRCLECI']) { - return; - } + // Wait before editing a file. + // Editing too soon seems to trigger a rebuild and throw polling out of whack. + await setTimeout(3000); + await appendToFile('src/main.ts', 'console.log(1);'); + // We have to wait poll time + rebuild build time for the regex match. + await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 14000); - return execAndWaitForOutputToMatch('ng', ['serve', '--poll=10000'], webpackGoodRegEx) - // Wait before editing a file. - // Editing too soon seems to trigger a rebuild and throw polling out of whack. - .then(() => wait(2000)) - .then(() => appendToFile('src/main.ts', 'console.log(1);')) - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 12000)) - .then(() => appendToFile('src/main.ts', 'console.log(1);')) - // No rebuilds should occur for a while - .then(() => expectToFail(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 6000))) - // But a rebuild should happen roughly within the 10 second window. - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 12000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); + // No rebuilds should occur for a while + await appendToFile('src/main.ts', 'console.log(1);'); + await expectToFail(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 7000)); + + // But a rebuild should happen roughly within the 10 second window. + await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 7000); } diff --git a/tests/e2e/tests/build/polyfills.ts b/tests/e2e/tests/build/polyfills.ts deleted file mode 100644 index a00445c34843..000000000000 --- a/tests/e2e/tests/build/polyfills.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expectFileToMatch } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => ng('build')) - // files were created successfully - .then(() => expectFileToMatch('dist/polyfills.bundle.js', 'core-js')) - .then(() => expectFileToMatch('dist/polyfills.bundle.js', 'zone.js')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - `)); -} diff --git a/tests/e2e/tests/build/prerender/discover-routes-ngmodule.ts b/tests/e2e/tests/build/prerender/discover-routes-ngmodule.ts new file mode 100644 index 000000000000..9d4843e00f7d --- /dev/null +++ b/tests/e2e/tests/build/prerender/discover-routes-ngmodule.ts @@ -0,0 +1,150 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile, useSha } from '../../../utils/project'; + +export default async function () { + const projectName = 'test-project-two'; + await ng('generate', 'application', projectName, '--no-standalone', '--skip-install'); + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // Setup webpack builder if esbuild is not requested on the commandline + await updateJsonFile('angular.json', (json) => { + const build = json['projects'][projectName]['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + }); + } + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng( + 'add', + '@angular/ssr', + '--project', + projectName, + '--skip-confirmation', + '--skip-install', + ); + + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + `projects/${projectName}/src/app/app-routing-module.ts`, + ` + import { NgModule } from '@angular/core'; + import { RouterModule, Routes } from '@angular/router'; + import { One} from './one/one'; + import { TwoChildOne } from './two-child-one/two-child-one'; + import { TwoChildTwo } from './two-child-two/two-child-two'; + + const routes: Routes = [ + { + path: '', + component: One, + }, + { + path: 'two', + children: [ + { + path: 'two-child-one', + component: TwoChildOne, + }, + { + path: 'two-child-two', + component: TwoChildTwo, + }, + ], + }, + ]; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule], + }) + export class AppRoutingModule {} + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['one', 'two-child-one', 'two-child-two']; + + for (const componentName of componentNames) { + await ng('generate', 'component', componentName, '--project', projectName); + } + + // Generate lazy routes + const lazyModules: [route: string, moduleName: string][] = [ + ['lazy-one', 'app-module'], + ['lazy-one-child', 'lazy-one/lazy-one-module'], + ['lazy-two', 'app-module'], + ]; + + for (const [route, moduleName] of lazyModules) { + await ng( + 'generate', + 'module', + route, + '--route', + route, + '--module', + moduleName, + '--project', + projectName, + ); + } + + // Prerender pages + if (useWebpackBuilder) { + await ng('run', `${projectName}:prerender:production`); + await runExpects(); + + return; + } + + await ng('build', projectName, '--configuration=production'); + await runExpects(); + + // Test also JIT mode. + await ng('build', projectName, '--configuration=development', '--no-aot'); + + await runExpects(); + + async function runExpects(): Promise { + const expects: Record = { + 'index.html': 'one works!', + 'two/index.html': 'router-outlet', + 'two/two-child-one/index.html': 'two-child-one works!', + 'two/two-child-two/index.html': 'two-child-two works!', + 'lazy-one/index.html': 'lazy-one works!', + 'lazy-one/lazy-one-child/index.html': 'lazy-one-child works!', + 'lazy-two/index.html': 'lazy-two works!', + }; + + if (!useWebpackBuilder) { + expects['index.csr.html'] = ''; + } + + const distPath = 'dist/' + projectName + '/browser'; + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join(distPath, filePath), fileMatch); + } + } +} diff --git a/tests/e2e/tests/build/prerender/discover-routes-standalone.ts b/tests/e2e/tests/build/prerender/discover-routes-standalone.ts new file mode 100644 index 000000000000..71a3ba2fb15d --- /dev/null +++ b/tests/e2e/tests/build/prerender/discover-routes-standalone.ts @@ -0,0 +1,126 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, readFile, writeFile } from '../../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useSha } from '../../../utils/project'; +import { deepStrictEqual } from 'node:assert'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { One } from './one/one'; + import { TwoChildOne } from './two-child-one/two-child-one'; + import { TwoChildTwo } from './two-child-two/two-child-two'; + + export const routes: Routes = [ + { + path: '', + component: One, + }, + { + path: 'two', + children: [ + { + path: 'two-child-one', + component: TwoChildOne, + }, + { + path: 'two-child-two', + component: TwoChildTwo, + }, + ], + }, + { + path: 'lazy-one', + children: [ + { + path: '', + loadComponent: () => import('./lazy-one/lazy-one').then(c => c.LazyOne), + }, + { + path: 'lazy-one-child', + loadComponent: () => import('./lazy-one-child/lazy-one-child').then(c => c.LazyOneChild), + }, + ], + }, + { + path: 'lazy-two', + loadComponent: () => import('./lazy-two/lazy-two').then(c => c.LazyTwo), + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = [ + 'one', + 'two-child-one', + 'two-child-two', + 'lazy-one', + 'lazy-one-child', + 'lazy-two', + ]; + + for (const componentName of componentNames) { + await ng('generate', 'component', componentName); + } + + // Prerender pages + if (useWebpackBuilder) { + await ng('run', 'test-project:prerender:production'); + await runExpects(); + + return; + } + + await ng('build', '--configuration=production', '--prerender'); + await runExpects(); + + // Test also JIT mode. + await ng('build', '--configuration=development', '--prerender', '--no-aot'); + await runExpects(); + + async function runExpects(): Promise { + const expects: Record = { + 'index.html': 'one works!', + 'two/index.html': 'router-outlet', + 'two/two-child-one/index.html': 'two-child-one works!', + 'two/two-child-two/index.html': 'two-child-two works!', + 'lazy-one/index.html': 'lazy-one works!', + 'lazy-one/lazy-one-child/index.html': 'lazy-one-child works!', + 'lazy-two/index.html': 'lazy-two works!', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + if (!useWebpackBuilder) { + // prerendered-routes.json file is only generated when using esbuild. + const generatedRoutesStats = await readFile('dist/test-project/prerendered-routes.json'); + deepStrictEqual(JSON.parse(generatedRoutesStats), { + routes: { + '/': {}, + '/lazy-one': {}, + '/lazy-one/lazy-one-child': {}, + '/lazy-two': {}, + '/two': {}, + '/two/two-child-one': {}, + '/two/two-child-two': {}, + }, + }); + } + } +} diff --git a/tests/e2e/tests/build/prerender/error-component-render.ts b/tests/e2e/tests/build/prerender/error-component-render.ts new file mode 100644 index 000000000000..07c048c71c39 --- /dev/null +++ b/tests/e2e/tests/build/prerender/error-component-render.ts @@ -0,0 +1,72 @@ +import { existsSync } from 'node:fs'; +import assert, { match } from 'node:assert'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileNotToExist, readFile, rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useSha } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + return; + } + + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-home', + standalone: true, + template: '

home works!

', + }) + export class HomeRoute {} + + @Component({ + selector: 'app-second', + standalone: true, + template: '

second works!

', + }) + export class SecondRoute { + constructor() { + throw new Error('render failure'); + } + } + + export const routes: Routes = [ + { path: '', component: HomeRoute }, + { path: 'second', component: SecondRoute }, + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: 'second', renderMode: RenderMode.Prerender }, + { path: '**', renderMode: RenderMode.Prerender }, + ]; + `, + }); + + const { message } = await expectToFail(() => ng('build', '--output-mode=server')); + + match(message, /An error occurred while prerendering route '\/second'\./); + + await expectFileNotToExist('dist/test-project/browser/second/index.html'); + + // prerendered-routes.json should only contain successfully prerendered routes if emitted + const statsPath = 'dist/test-project/prerendered-routes.json'; + if (existsSync(statsPath)) { + const stats = JSON.parse(await readFile(statsPath)); + assert.strictEqual(stats.routes['/second'], undefined); + } +} diff --git a/tests/e2e/tests/build/prerender/error-with-sourcemaps.ts b/tests/e2e/tests/build/prerender/error-with-sourcemaps.ts new file mode 100644 index 000000000000..875638877fbd --- /dev/null +++ b/tests/e2e/tests/build/prerender/error-with-sourcemaps.ts @@ -0,0 +1,52 @@ +import { ng } from '../../../utils/process'; +import { getGlobalVariable } from '../../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { match } from 'node:assert'; +import { expectToFail } from '../../../utils/utils'; +import { useSha } from '../../../utils/project'; +import { installWorkspacePackages } from '../../../utils/packages'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + return; + } + + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + 'src/app/app.ts': ` + import { Component, signal } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + + @Component({ + selector: 'app-root', + imports: [CommonModule, RouterOutlet], + templateUrl: './app.html', + styleUrls: ['./app.css'] + }) + export class App { + protected readonly title = signal('test-ssr'); + + constructor() { + console.log(window) + } + } + `, + }); + + const { message } = await expectToFail(() => + ng('build', '--configuration', 'development', '--prerender'), + ); + match( + message, + // When babel is used it will add names to the sourcemap and `constructor` will be used in the stack trace. + // This will currently only happen if AOT and script optimizations are set which enables advanced optimizations. + /window is not defined[.\s\S]*(?:constructor|_App) \(.*app\.ts\:\d+:\d+\)/, + ); +} diff --git a/tests/e2e/tests/build/prerender/http-requests-assets.ts b/tests/e2e/tests/build/prerender/http-requests-assets.ts new file mode 100644 index 000000000000..71288b3c242e --- /dev/null +++ b/tests/e2e/tests/build/prerender/http-requests-assets.ts @@ -0,0 +1,92 @@ +import { ng } from '../../../utils/process'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // Not supported by the webpack based builder. + return; + } + + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Add http client and route + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import {Home} from './home/home'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideHttpClient, withFetch } from '@angular/common/http'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter([{ + path: '', + component: Home, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + + // Add asset + 'public/media.json': JSON.stringify({ dataFromAssets: true }), + 'public/media with-space.json': JSON.stringify({ dataFromAssetsWithSpace: true }), + + // Update component to do an HTTP call to asset. + 'src/app/app.ts': ` + import { ChangeDetectorRef, Component, inject } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + imports: [CommonModule, RouterOutlet], + template: \` +

{{ data | json }}

+

{{ dataWithSpace | json }}

+ + \`, + }) + export class App { + data: any; + dataWithSpace: any; + private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + const http = inject(HttpClient); + http.get('/media.json').subscribe((d) => { + this.data = d; + this.cdr.markForCheck(); + }); + + http.get('/media%20with-space.json').subscribe((d) => { + this.dataWithSpace = d; + this.cdr.markForCheck(); + }); + } + } + `, + }); + + await ng('generate', 'component', 'home'); + await ng('build', '--configuration=production', '--prerender'); + await expectFileToMatch( + 'dist/test-project/browser/index.html', + /

{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/, + ); + await expectFileToMatch( + 'dist/test-project/browser/index.html', + /

{[\S\s]*"dataFromAssetsWithSpace":[\s\S]*true[\S\s]*}<\/p>/, + ); +} diff --git a/tests/e2e/tests/build/prod-build.ts b/tests/e2e/tests/build/prod-build.ts index 85357ae29203..dee45876e379 100644 --- a/tests/e2e/tests/build/prod-build.ts +++ b/tests/e2e/tests/build/prod-build.ts @@ -1,28 +1,48 @@ -import {join} from 'path'; -import {readdirSync} from 'fs'; -import {expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {expectGitToBeClean} from '../../utils/git'; -import {getGlobalVariable} from '../../utils/env'; +import assert from 'node:assert/strict'; +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, expectFileToMatch, readFile } from '../../utils/fs'; +import { noSilentNg } from '../../utils/process'; +function verifySize(bundle: string, baselineBytes: number) { + const size = statSync(`dist/test-project/browser/${bundle}`).size; + const percentageBaseline = (baselineBytes * 10) / 100; + const maxSize = baselineBytes + percentageBaseline; + const minSize = baselineBytes - percentageBaseline; -export default function() { - // Skip this in ejected tests. - const ejected = getGlobalVariable('argv').eject; + assert( + size < maxSize, + `Expected ${bundle} size to be less than ${maxSize / 1024}Kb but it was ${size / 1024}Kb.`, + ); - // Can't use the `ng` helper because somewhere the environment gets - // stuck to the first build done - return ng('build', '--prod') - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - // Check for cache busting hash script src - .then(() => expectFileToMatch('dist/index.html', /main\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileToMatch('dist/index.html', /styles\.[0-9a-f]{20}\.bundle\.css/)) - .then(() => expectFileToMatch('dist/3rdpartylicenses.txt', /MIT/)) - // Defaults to AoT - .then(() => { - const main = readdirSync('./dist').find(name => !!name.match(/main.[a-z0-9]+\.bundle\.js/)); - expectFileToMatch(`dist/${main}`, /bootstrapModuleFactory\(/); - }) - // Check that the process didn't change local files. - .then(() => !ejected && expectGitToBeClean()); + assert( + size > minSize, + `Expected ${bundle} size to be greater than ${minSize / 1024}Kb but it was ${size / 1024}Kb.`, + ); +} + +export default async function () { + await noSilentNg('build'); + await expectFileToExist(join(process.cwd(), 'dist')); + // Check for cache busting hash script src + if (getGlobalVariable('argv')['esbuild']) { + // esbuild uses an 8 character hash and a dash as separator + await expectFileToMatch('dist/test-project/browser/index.html', /main-[0-9a-zA-Z]{8}\.js/); + await expectFileToMatch('dist/test-project/browser/index.html', /styles-[0-9a-zA-Z]{8}\.css/); + await expectFileToMatch('dist/test-project/3rdpartylicenses.txt', /MIT/); + } else { + await expectFileToMatch('dist/test-project/browser/index.html', /main\.[0-9a-zA-Z]{16}\.js/); + await expectFileToMatch('dist/test-project/browser/index.html', /styles\.[0-9a-zA-Z]{16}\.css/); + await expectFileToMatch('dist/test-project/browser/3rdpartylicenses.txt', /MIT/); + } + + const indexContent = await readFile('dist/test-project/browser/index.html'); + const mainSrcRegExp = getGlobalVariable('argv')['esbuild'] + ? /src="(main-[0-9a-zA-Z]{8}\.js)"/ + : /src="(main\.[0-9a-zA-Z]{16}\.js)"/; + const mainPath = indexContent.match(mainSrcRegExp)![1]; + + // Size checks in bytes + verifySize(mainPath, 210000); } diff --git a/tests/e2e/tests/build/progress-and-stats.ts b/tests/e2e/tests/build/progress-and-stats.ts new file mode 100644 index 000000000000..940179df052e --- /dev/null +++ b/tests/e2e/tests/build/progress-and-stats.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; + +export default async function () { + const { stderr: stderrProgress, stdout } = await ng('build', '--progress'); + assert.match(stdout, /Initial total/); + assert.match(stdout, /Estimated transfer size/); + + let logs; + if (getGlobalVariable('argv')['esbuild']) { + assert.match(stdout, /Building\.\.\./); + + return; + } else { + logs = [ + 'Browser application bundle generation complete', + 'Copying assets complete', + 'Index html generation complete', + ]; + } + + for (const log of logs) { + assert.match(stderrProgress, new RegExp(log)); + } + + const { stderr: stderrNoProgress } = await ng('build', '--no-progress'); + for (const log of logs) { + assert.doesNotMatch(stderrNoProgress, new RegExp(log)); + } +} diff --git a/tests/e2e/tests/build/project-name.ts b/tests/e2e/tests/build/project-name.ts new file mode 100644 index 000000000000..309ba4d8e897 --- /dev/null +++ b/tests/e2e/tests/build/project-name.ts @@ -0,0 +1,8 @@ +import { silentNg } from '../../utils/process'; + +export default async function () { + // Named Development build + await silentNg('build', 'test-project', '--configuration=development'); + await silentNg('build', '--configuration=development', 'test-project', '--no-progress'); + await silentNg('build', '--configuration=development', '--no-progress', 'test-project'); +} diff --git a/tests/e2e/tests/build/rebuild-css-change.ts b/tests/e2e/tests/build/rebuild-css-change.ts deleted file mode 100644 index b652a77b1213..000000000000 --- a/tests/e2e/tests/build/rebuild-css-change.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - killAllProcesses, - exec, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch -} from '../../utils/process'; -import {appendToFile} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - -const webpackGoodRegEx = /webpack: bundle is now VALID|webpack: Compiled successfully./; - -export default function() { - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - - return execAndWaitForOutputToMatch('ng', ['serve'], webpackGoodRegEx) - // Should trigger a rebuild. - .then(() => exec('touch', 'src/main.ts')) - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) - .then(() => appendToFile('src/app/app.component.css', ':host { color: blue; }')) - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/e2e/tests/build/rebuild-deps-type-check.ts b/tests/e2e/tests/build/rebuild-deps-type-check.ts index cb201d24da22..1f4964f6689b 100644 --- a/tests/e2e/tests/build/rebuild-deps-type-check.ts +++ b/tests/e2e/tests/build/rebuild-deps-type-check.ts @@ -1,84 +1,121 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import {writeFile, prependToFile, appendToFile} from '../../utils/fs'; -import {wait} from '../../utils/utils'; -import {getGlobalVariable} from '../../utils/env'; +import assert from 'node:assert/strict'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, prependToFile, writeFile } from '../../utils/fs'; +import { execAndWaitForOutputToMatch, waitForAnyProcessOutputToMatch } from '../../utils/process'; +const doneRe = getGlobalVariable('argv')['esbuild'] + ? /Application bundle generation complete\./ + : / Compiled successfully\.|: Failed to compile\./; +const errorRe = /Error/i; -const doneRe = - /webpack: bundle is now VALID|webpack: Compiled successfully.|webpack: Failed to compile./; +export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. - -export default function() { if (process.platform.startsWith('win')) { return Promise.resolve(); } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - return Promise.resolve() - // Create and import files. - .then(() => writeFile('src/funky2.ts', ` + return ( + Promise.resolve() + // Create and import files. + .then(() => + writeFile( + 'src/funky2.ts', + ` export function funky2(value: string): string { return value + 'hello'; } - `)) - .then(() => writeFile('src/funky.ts', ` + `, + ), + ) + .then(() => + writeFile( + 'src/funky.ts', + ` export * from './funky2'; - `)) - .then(() => prependToFile('src/main.ts', ` + `, + ), + ) + .then(() => + prependToFile( + 'src/main.ts', + ` import { funky2 } from './funky'; - `)) - .then(() => appendToFile('src/main.ts', ` + `, + ), + ) + .then(() => + appendToFile( + 'src/main.ts', + ` console.log(funky2('town')); - `)) - .then(() => wait(2000)) - // Should trigger a rebuild, no error expected. - .then(() => execAndWaitForOutputToMatch('ng', ['serve'], doneRe)) - // Make an invalid version of the file. - .then(() => writeFile('src/funky2.ts', ` - export function funky2(value: number): number { - return value + 1; - } - `)) - // Should trigger a rebuild, this time an error is expected. - .then(() => waitForAnyProcessOutputToMatch(doneRe, 10000)) - .then(({ stderr }) => { - if (!/ERROR in .*\/src\/main\.ts \(/.test(stderr)) { - throw new Error('Expected an error but none happened.'); - } - }) - // Change an UNRELATED file and the error should still happen. - .then(() => wait(2000)) - .then(() => appendToFile('src/app/app.module.ts', ` - function anything(): number {} - `)) - // Should trigger a rebuild, this time an error is expected. - .then(() => waitForAnyProcessOutputToMatch(doneRe, 10000)) - .then(({ stderr }) => { - if (!/ERROR in .*\/src\/main\.ts \(/.test(stderr)) { - throw new Error('Expected an error but none happened.'); - } - }) - // Fix the error! - .then(() => writeFile('src/funky2.ts', ` - export function funky2(value: string): string { - return value + 'hello'; - } - `)) - .then(() => waitForAnyProcessOutputToMatch(doneRe, 10000)) - .then(({ stderr }) => { - if (/ERROR in .*\/src\/main\.ts \(/.test(stderr)) { - throw new Error('Expected no error but an error was shown.'); - } - }) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); + `, + ), + ) + // Should trigger a rebuild, no error expected. + .then(() => execAndWaitForOutputToMatch('ng', ['serve'], doneRe)) + // Make an invalid version of the file. + // Should trigger a rebuild, this time an error is expected. + .then(() => + Promise.all([ + waitForAnyProcessOutputToMatch(errorRe, 20000), + writeFile( + 'src/funky2.ts', + ` + export function funky2(value: number): number { + return value + 1; + } + `, + ), + ]), + ) + .then((results) => { + const { stderr } = results[0]; + assert.match( + stderr, + /Argument of type 'string' is not assignable to parameter of type 'number'/, + ); + }) + // Change an UNRELATED file and the error should still happen. + // Should trigger a rebuild, this time an error is also expected. + .then(() => + Promise.all([ + waitForAnyProcessOutputToMatch(errorRe, 20000), + appendToFile( + 'src/app/app.config.ts', + ` + function anything(): number { return 1; } + `, + ), + ]), + ) + .then((results) => { + const { stderr } = results[0]; + assert.match( + stderr, + /Argument of type 'string' is not assignable to parameter of type 'number'/, + ); + }) + // Fix the error! + .then(() => + Promise.all([ + waitForAnyProcessOutputToMatch(doneRe, 20000), + writeFile( + 'src/funky2.ts', + ` + export function funky2(value: string): string { + return value + 'hello'; + } + `, + ), + ]), + ) + .then((results) => { + const { stderr } = results[0]; + assert.doesNotMatch( + stderr, + /Argument of type 'string' is not assignable to parameter of type 'number'/, + ); + }) + ); } diff --git a/tests/e2e/tests/build/rebuild-dot-dirname.ts b/tests/e2e/tests/build/rebuild-dot-dirname.ts new file mode 100644 index 000000000000..ed5f12ed94c1 --- /dev/null +++ b/tests/e2e/tests/build/rebuild-dot-dirname.ts @@ -0,0 +1,63 @@ +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, createDir, rimraf } from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { killAllProcesses, ng, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; + +const goodRegEx = getGlobalVariable('argv')['esbuild'] + ? /Application bundle generation complete\./ + : / Compiled successfully\./; + +export default async function () { + const originalCwd = process.cwd(); + // Delete angular.json so that we can create a new app. + await rimraf('angular.json'); + await createDir('./.subdirectory'); + + try { + process.chdir('./.subdirectory'); + + await ng('new', 'subdirectory-test-project', '--skip-install'); + process.chdir('./subdirectory-test-project'); + + await useSha(); + await installWorkspacePackages(); + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['subdirectory-test-project']['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + outputPath: 'dist/subdirectory-test-project', + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + + const serve = json['projects']['subdirectory-test-project']['architect']['serve']; + serve.builder = '@angular-devkit/build-angular:dev-server'; + }); + } + + await ngServe(); + + // Wait before editing a file. + await setTimeout(1000); + await appendToFile('src/main.ts', 'console.log(1);'); + await waitForAnyProcessOutputToMatch(goodRegEx); + } finally { + process.chdir(originalCwd); + await killAllProcesses(); + await setTimeout(100); + } +} diff --git a/tests/e2e/tests/build/rebuild-symlink.ts b/tests/e2e/tests/build/rebuild-symlink.ts new file mode 100644 index 000000000000..2cbe41aded25 --- /dev/null +++ b/tests/e2e/tests/build/rebuild-symlink.ts @@ -0,0 +1,41 @@ +import { symlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; +import { execAndWaitForOutputToMatch, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +const buildReadyRegEx = getGlobalVariable('argv')['esbuild'] + ? /Application bundle generation complete\./ + : /Build at: /; + +export default async function () { + // TODO: Disabled pending investigation. Steps work outside of test + if (getGlobalVariable('argv')['esbuild']) { + return; + } + + await updateJsonFile('angular.json', (configJson) => { + configJson.projects['test-project'].architect.build.options.preserveSymlinks = true; + }); + + await writeMultipleFiles({ + 'src/link-source.ts': '// empty file', + 'src/main.ts': `import './link-dest';`, + }); + + await symlink(resolve('src/link-source.ts'), resolve('src/link-dest.ts')); + + await execAndWaitForOutputToMatch( + 'ng', + ['build', '--watch', '--configuration=development'], + buildReadyRegEx, + ); + + // Trigger a rebuild + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + appendToFile('src/link-source.ts', `\nconsole.log('foo-bar');`), + ]); + await expectFileToMatch('dist/test-project/browser/main.js', `console.log('foo-bar')`); +} diff --git a/tests/e2e/tests/build/rebuild.ts b/tests/e2e/tests/build/rebuild.ts deleted file mode 100644 index c69be2658a9a..000000000000 --- a/tests/e2e/tests/build/rebuild.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - killAllProcesses, - exec, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, - ng, -} from '../../utils/process'; -import {writeFile, writeMultipleFiles} from '../../utils/fs'; -import {wait} from '../../utils/utils'; -import {request} from '../../utils/http'; -import {getGlobalVariable} from '../../utils/env'; - -const validBundleRegEx = /webpack: bundle is now VALID|webpack: Compiled successfully./; -const invalidBundleRegEx = /webpack: bundle is now INVALID|webpack: Compiling.../; - -export default function() { - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - let oldNumberOfChunks = 0; - const chunkRegExp = /chunk\s+\{/g; - - return execAndWaitForOutputToMatch('ng', ['serve'], validBundleRegEx) - // Should trigger a rebuild. - .then(() => exec('touch', 'src/main.ts')) - .then(() => waitForAnyProcessOutputToMatch(invalidBundleRegEx, 10000)) - .then(() => waitForAnyProcessOutputToMatch(validBundleRegEx, 10000)) - // Count the bundles. - .then(({ stdout }) => { - oldNumberOfChunks = stdout.split(chunkRegExp).length; - }) - // Add a lazy module. - .then(() => ng('generate', 'module', 'lazy', '--routing')) - // Just wait for the rebuild, otherwise we might be validating the last build. - .then(() => waitForAnyProcessOutputToMatch(validBundleRegEx, 10000)) - .then(() => writeFile('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'; - import { RouterModule } from '@angular/router'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - FormsModule, - HttpModule, - RouterModule.forRoot([ - { path: 'lazy', loadChildren: './lazy/lazy.module#LazyModule' } - ]) - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `)) - // Should trigger a rebuild with a new bundle. - .then(() => waitForAnyProcessOutputToMatch(validBundleRegEx, 10000)) - // Count the bundles. - .then(({ stdout }) => { - let newNumberOfChunks = stdout.split(chunkRegExp).length; - if (oldNumberOfChunks >= newNumberOfChunks) { - throw new Error('Expected webpack to create a new chunk, but did not.'); - } - }) - // Change multiple files and check that all of them are invalidated and recompiled. - .then(() => writeMultipleFiles({ - 'src/app/app.module.ts': ` - console.log('$$_E2E_GOLDEN_VALUE_1'); - export let X = '$$_E2E_GOLDEN_VALUE_2'; - `, - 'src/main.ts': ` - import * as m from './app/app.module'; - console.log(m.X); - console.log('$$_E2E_GOLDEN_VALUE_3'); - ` - })) - .then(() => waitForAnyProcessOutputToMatch(validBundleRegEx, 10000)) - .then(() => wait(2000)) - .then(() => request('http://localhost:4200/main.bundle.js')) - .then((body) => { - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_1/)) { - throw new Error('Expected golden value 1.'); - } - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_2/)) { - throw new Error('Expected golden value 2.'); - } - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_3/)) { - throw new Error('Expected golden value 3.'); - } - }) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/e2e/tests/build/relative-sourcemap.ts b/tests/e2e/tests/build/relative-sourcemap.ts new file mode 100644 index 000000000000..209e29aabd76 --- /dev/null +++ b/tests/e2e/tests/build/relative-sourcemap.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + // General secondary application project + await ng('generate', 'application', 'secondary-project', '--skip-install'); + // Setup webpack builder if esbuild is not requested on the commandline + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['secondary-project']['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + outputPath: 'dist/secondary-project', + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + }); + } + + await ng('build', 'secondary-project', '--configuration=development'); + await ng('build', '--output-hashing=none', '--source-map', '--configuration=development'); + + const sourceMapPath = getGlobalVariable('argv')['esbuild'] + ? './dist/secondary-project/browser/main.js.map' + : './dist/secondary-project/main.js.map'; + const content = fs.readFileSync(sourceMapPath, 'utf8'); + const { sources } = JSON.parse(content) as { sources: string[] }; + let mainFileFound = false; + for (const source of sources) { + assert(!isAbsolute(source), `Expected ${source} to be relative.`); + + if (source.endsWith('main.ts')) { + mainFileFound = true; + assert( + source === 'projects/secondary-project/src/main.ts' || + source === './projects/secondary-project/src/main.ts', + `Expected main file ${source} to be relative to the workspace root.`, + ); + } + } + + assert(mainFileFound, 'Could not find the main file in the application sourcemap sources array.'); +} diff --git a/tests/e2e/tests/build/scripts-array.ts b/tests/e2e/tests/build/scripts-array.ts deleted file mode 100644 index 8cbcc439fdaf..000000000000 --- a/tests/e2e/tests/build/scripts-array.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch, - appendToFile, - expectFileMatchToExist -} from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - return writeMultipleFiles({ - 'src/string-script.js': 'console.log(\'string-script\'); var number = 1+1;', - 'src/input-script.js': 'console.log(\'input-script\');', - 'src/lazy-script.js': 'console.log(\'lazy-script\');', - 'src/pre-rename-script.js': 'console.log(\'pre-rename-script\');', - 'src/pre-rename-lazy-script.js': 'console.log(\'pre-rename-lazy-script\');' - }) - .then(() => appendToFile('src/main.ts', 'import \'./string-script.js\';')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['scripts'] = [ - 'string-script.js', - { input: 'input-script.js' }, - { input: 'lazy-script.js', lazy: true }, - { input: 'pre-rename-script.js', output: 'renamed-script' }, - { input: 'pre-rename-lazy-script.js', output: 'renamed-lazy-script', lazy: true } - ]; - })) - .then(() => ng('build', '--extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/scripts.bundle.js', 'string-script')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', 'input-script')) - .then(() => expectFileToMatch('dist/lazy-script.bundle.js', 'lazy-script')) - .then(() => expectFileToMatch('dist/renamed-script.bundle.js', 'pre-rename-script')) - .then(() => expectFileToMatch('dist/renamed-lazy-script.bundle.js', 'pre-rename-lazy-script')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - - - `)) - // Ensure scripts can be separately imported from the app. - .then(() => expectFileToMatch('dist/main.bundle.js', 'console.log(\'string-script\');')) - // Verify uglify, sourcemaps and hashes. Lazy scripts should not get hashes. - .then(() => ng('build', '--prod', '--sourcemap')) - .then(() => expectFileMatchToExist('dist', /scripts\.[0-9a-f]{20}\.bundle\.js/)) - .then(fileName => expectFileToMatch(`dist/${fileName}`, 'var number=2;')) - .then(() => expectFileMatchToExist('dist', /scripts\.[0-9a-f]{20}\.bundle\.js\.map/)) - .then(() => expectFileMatchToExist('dist', /renamed-script\.[0-9a-f]{20}\.bundle\.js/)) - .then(() => expectFileMatchToExist('dist', /renamed-script\.[0-9a-f]{20}\.bundle\.js.map/)) - .then(() => expectFileToMatch('dist/lazy-script.bundle.js', 'lazy-script')) - .then(() => expectFileToMatch('dist/renamed-lazy-script.bundle.js', 'pre-rename-lazy-script')); -} diff --git a/tests/e2e/tests/build/scripts-output-hashing.ts b/tests/e2e/tests/build/scripts-output-hashing.ts new file mode 100644 index 000000000000..8b34662c485e --- /dev/null +++ b/tests/e2e/tests/build/scripts-output-hashing.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { getGlobalVariable } from '../../utils/env'; +import { + expectFileMatchToExist, + expectFileToMatch, + writeFile, + writeMultipleFiles, +} from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +function getScriptsFilename(): Promise { + if (getGlobalVariable('argv')['esbuild']) { + return expectFileMatchToExist('dist/test-project/browser/', /external-module-[0-9A-Z]{8}\.js/); + } else { + return expectFileMatchToExist( + 'dist/test-project/browser/', + /external-module\.[0-9a-f]{16}\.js/, + ); + } +} + +export default async function () { + // verify content hash is based on code after optimizations + await writeMultipleFiles({ + 'src/script.js': 'try { console.log(); } catch {}', + }); + await updateJsonFile('angular.json', (configJson) => { + const build = configJson.projects['test-project'].architect.build; + build.options['scripts'] = [ + { + input: 'src/script.js', + inject: true, + bundleName: 'external-module', + }, + ]; + build.configurations['production'].outputHashing = 'all'; + configJson['cli'] = { cache: { enabled: 'false' } }; + }); + + // Chrome 65 does not support optional catch in try/catch blocks. + await writeFile('.browserslistrc', 'Chrome 65'); + + await ng('build', '--configuration=production'); + const filenameBuild1 = await getScriptsFilename(); + await expectFileToMatch( + `dist/test-project/browser/${filenameBuild1}`, + 'try{console.log()}catch(c){}', + ); + + await writeFile('.browserslistrc', 'last 1 Chrome version'); + + await ng('build', '--configuration=production'); + const filenameBuild2 = await getScriptsFilename(); + await expectFileToMatch( + `dist/test-project/browser/${filenameBuild2}`, + 'try{console.log()}catch{}', + ); + + assert.notEqual( + filenameBuild1, + filenameBuild2, + 'Contents of the built file changed between builds, but the content hash stayed the same!', + ); +} diff --git a/tests/e2e/tests/build/server-rendering/express-engine-csp-nonce.ts b/tests/e2e/tests/build/server-rendering/express-engine-csp-nonce.ts new file mode 100644 index 000000000000..ad10738c759f --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/express-engine-csp-nonce.ts @@ -0,0 +1,117 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { findFreePort } from '../../../utils/network'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../../utils/process'; +import { updateJsonFile, updateServerFileForEsbuild, useSha } from '../../../utils/project'; +import { executeBrowserTest } from '../../../utils/puppeteer'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + if (!useWebpackBuilder) { + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project']['architect']['build']; + build.options.outputMode = undefined; + build.configurations.production.prerender = false; + }); + + await updateServerFileForEsbuild('src/server.ts'); + } + + await writeMultipleFiles({ + 'src/app/app.css': `div { color: #000 }`, + 'src/styles.css': `* { color: #000 }`, + 'src/main.ts': `import { bootstrapApplication } from '@angular/platform-browser'; + import { App } from './app/app'; + import { appConfig } from './app/app.config'; + + (window as any)['doBootstrap'] = () => { + bootstrapApplication(App, appConfig).catch((err) => console.error(err)); + }; + `, + 'src/index.html': ` + + + + + + + + + + + `, + }); + + async function spawnServer(): Promise { + const port = await findFreePort(); + + const runCommand = useWebpackBuilder ? 'serve:ssr' : 'serve:ssr:test-project'; + + await execAndWaitForOutputToMatch( + 'npm', + ['run', runCommand], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; + } + + await ng('build'); + + if (useWebpackBuilder) { + // Build server code + await ng('run', 'test-project:server'); + } + + const port = await spawnServer(); + await executeBrowserTest({ + baseUrl: `http://localhost:${port}/`, + checkFn: async (page) => { + // Test the contents from the server. + const h1Text = await page.$eval('h1', (el) => el.textContent); + if (!h1Text?.includes('Hello')) { + throw new Error(`Expected h1 to contain 'Hello', but got '${h1Text}'`); + } + + const serverStylePresent = await page.evaluate( + () => !!(globalThis as any).document.querySelector('style[ng-app-id="ng"]'), + ); + if (!serverStylePresent) { + throw new Error('Expected server-side style to be present'); + } + + // style tags all have a nonce attribute + const nonces = await page.$$eval('style', (styles) => + styles.map((s) => s.getAttribute('nonce')), + ); + for (const nonce of nonces) { + if (nonce !== '{% nonce %}') { + throw new Error(`Expected nonce to be '{% nonce %}', but got '${nonce}'`); + } + } + // Bootstrap the client side app. + await page.evaluate('window.doBootstrap()'); + + // Wait for server style to be removed by client + await page.waitForSelector('style[ng-app-id="ng"]', { hidden: true }); + + // Retest the contents after the client bootstraps. + const h1TextPost = await page.$eval('h1', (el) => el.textContent); + if (!h1TextPost?.includes('Hello')) { + throw new Error(`Expected h1 to contain 'Hello' after bootstrap, but got '${h1TextPost}'`); + } + }, + }); +} diff --git a/tests/e2e/tests/build/server-rendering/express-engine-ngmodule.ts b/tests/e2e/tests/build/server-rendering/express-engine-ngmodule.ts new file mode 100644 index 000000000000..8b718c14b162 --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/express-engine-ngmodule.ts @@ -0,0 +1,130 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { findFreePort } from '../../../utils/network'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../../utils/process'; +import { updateJsonFile, updateServerFileForEsbuild, useSha } from '../../../utils/project'; +import { executeBrowserTest } from '../../../utils/puppeteer'; + +export default async function () { + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/ssr'); + + await ng('generate', 'app', 'test-project-two', '--no-standalone', '--skip-install'); + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + + if (useWebpackBuilder) { + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project-two']['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + }); + } + + await ng( + 'add', + '@angular/ssr', + '--skip-confirmation', + '--skip-install', + '--project=test-project-two', + ); + + await useSha(); + await installWorkspacePackages(); + + if (!useWebpackBuilder) { + // Disable prerendering + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project-two']['architect']['build']; + build.configurations.production.prerender = false; + build.options.outputMode = undefined; + }); + + await updateServerFileForEsbuild('projects/test-project-two/src/server.ts'); + } + + await writeMultipleFiles({ + 'projects/test-project-two/src/app/app.css': `div { color: #000 }`, + 'projects/test-project-two/src/styles.css': `* { color: #000 }`, + 'projects/test-project-two/src/main.ts': ` + import { platformBrowser } from '@angular/platform-browser'; + import { AppModule } from './app/app-module'; + + (window as any)['doBootstrap'] = () => { + platformBrowser() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); + }; + `, + }); + + async function spawnServer(): Promise { + const port = await findFreePort(); + + const runCommand = useWebpackBuilder ? 'serve:ssr' : `serve:ssr:test-project-two`; + + await execAndWaitForOutputToMatch( + 'npm', + ['run', runCommand], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; + } + + await ng('build', 'test-project-two'); + + if (useWebpackBuilder) { + // Build server code + await ng('run', `test-project-two:server`); + } + + const port = await spawnServer(); + await executeBrowserTest({ + baseUrl: `http://localhost:${port}/`, + checkFn: async (page) => { + // Test the contents from the server. + const h1Text = await page.$eval('h1', (el) => el.textContent); + if (!h1Text?.includes('Hello')) { + throw new Error(`Expected h1 to contain 'Hello', but got '${h1Text}'`); + } + + const serverStylePresent = await page.evaluate( + `!!document.querySelector('style[ng-app-id="ng"]')`, + ); + if (!serverStylePresent) { + throw new Error('Expected server-side style to be present'); + } + + // Bootstrap the client side app. + await page.evaluate('window.doBootstrap()'); + + // Wait for server style to be removed by client + await page.waitForSelector('style[ng-app-id="ng"]', { hidden: true }); + + // Retest the contents after the client bootstraps. + const h1TextPost = await page.$eval('h1', (el) => el.textContent); + if (!h1TextPost?.includes('Hello')) { + throw new Error(`Expected h1 to contain 'Hello' after bootstrap, but got '${h1TextPost}'`); + } + }, + }); +} diff --git a/tests/e2e/tests/build/server-rendering/express-engine-standalone.ts b/tests/e2e/tests/build/server-rendering/express-engine-standalone.ts new file mode 100644 index 000000000000..ce141c862c30 --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/express-engine-standalone.ts @@ -0,0 +1,96 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { findFreePort } from '../../../utils/network'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../../utils/process'; +import { updateJsonFile, updateServerFileForEsbuild, useSha } from '../../../utils/project'; +import { executeBrowserTest } from '../../../utils/puppeteer'; + +export default async function () { + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/ssr'); + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + + if (!useWebpackBuilder) { + // Disable prerendering + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project']['architect']['build']; + build.options.outputMode = undefined; + }); + + await updateServerFileForEsbuild('src/server.ts'); + } + + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + 'src/app/app.css': `div { color: #000 }`, + 'src/styles.css': `* { color: #000 }`, + 'src/main.ts': `import { bootstrapApplication } from '@angular/platform-browser'; + import { App } from './app/app'; + import { appConfig } from './app/app.config'; + + (window as any)['doBootstrap'] = () => { + bootstrapApplication(App, appConfig).catch((err) => console.error(err)); + }; + `, + }); + + async function spawnServer(): Promise { + const port = await findFreePort(); + const runCommand = useWebpackBuilder ? 'serve:ssr' : 'serve:ssr:test-project'; + + await execAndWaitForOutputToMatch( + 'npm', + ['run', runCommand], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; + } + + await ng('build'); + if (useWebpackBuilder) { + // Build server code + await ng('run', `test-project:server`); + } + + const port = await spawnServer(); + await executeBrowserTest({ + baseUrl: `http://localhost:${port}/`, + checkFn: async (page) => { + // Test the contents from the server. + const h1Text = await page.$eval('h1', (el) => el.textContent); + if (!h1Text?.includes('Hello')) { + throw new Error(`Expected h1 to contain 'Hello', but got '${h1Text}'`); + } + + const serverStylePresent = await page.evaluate( + `!!document.querySelector('style[ng-app-id="ng"]')`, + ); + if (!serverStylePresent) { + throw new Error('Expected server-side style to be present'); + } + + // Bootstrap the client side app. + await page.evaluate('window.doBootstrap()'); + + // Wait for server style to be removed by client + await page.waitForSelector('style[ng-app-id="ng"]', { hidden: true }); + + // Retest the contents after the client bootstraps. + const h1TextPost = await page.$eval('h1', (el) => el.textContent); + if (!h1TextPost?.includes('Hello')) { + throw new Error(`Expected h1 to contain 'Hello' after bootstrap, but got '${h1TextPost}'`); + } + }, + }); +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-external-dependencies.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-external-dependencies.ts new file mode 100644 index 000000000000..52ceafa7b05f --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-external-dependencies.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import { ng } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { updateJsonFile, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project']['architect']['build']; + build.options.externalDependencies = [ + '@angular/platform-browser', + '@angular/core', + '@angular/router', + '@angular/common', + '@angular/common/http', + '@angular/platform-browser/animations', + ]; + }); + + await ng('build'); +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts new file mode 100644 index 000000000000..aa6f7e3426ee --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-base-href.ts @@ -0,0 +1,119 @@ +import { join } from 'node:path'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; +import { findFreePort } from '../../../utils/network'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + import { Ssr } from './ssr/ssr'; + import { Ssg } from './ssg/ssg'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssg', + component: Ssg, + }, + { + path: 'ssr', + component: Ssr, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, + { + path: 'ssg', + renderMode: RenderMode.Prerender, + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'csr', 'ssr']; + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server', '--base-href=/base/'); + + for (const { lang, outputPath } of langTranslations) { + await expectFileToMatch(join(outputPath, 'index.html'), `

${lang}

`); + await expectFileToMatch(join(outputPath, 'ssg/index.html'), `

${lang}

`); + } + + // Tests responses + const port = await spawnServer(); + const pathnamesToVerify = ['/ssr', '/ssg']; + for (const { lang } of langTranslations) { + for (const pathname of pathnamesToVerify) { + const res = await fetch(`http://localhost:${port}/base/${lang}${pathname}`); + const text = await res.text(); + + assert.match( + text, + new RegExp(`

${lang}

`), + `Response for '${lang}${pathname}': '

${lang}

' was not matched in content.`, + ); + } + } +} + +async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts new file mode 100644 index 000000000000..7bc323311b4f --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n-sub-path.ts @@ -0,0 +1,154 @@ +import { join } from 'node:path'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; +import { findFreePort } from '../../../utils/network'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { updateJsonFile, useSha } from '../../../utils/project'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Update angular.json + const URL_SUB_PATH: Record = { + 'en-US': '', + 'fr': 'fr', + 'de': 'deutsche', + }; + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const i18n: Record = appProject.i18n; + i18n.sourceLocale = { + subPath: URL_SUB_PATH['en-US'], + }; + + i18n.locales['fr'] = { + translation: i18n.locales['fr'], + subPath: URL_SUB_PATH['fr'], + }; + + i18n.locales['de'] = { + translation: i18n.locales['de'], + subPath: URL_SUB_PATH['de'], + }; + }); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + import { Ssr } from './ssr/ssr'; + import { Ssg } from './ssg/ssg'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssg', + component: Ssg, + }, + { + path: 'ssr', + component: Ssr, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, + { + path: 'ssg', + renderMode: RenderMode.Prerender, + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssr']; + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server', '--base-href=/base/'); + + const pathToVerify = ['/index.html', '/ssg/index.html']; + for (const { lang } of langTranslations) { + const subPath = URL_SUB_PATH[lang]; + const outputPath = join('dist/test-project/browser', subPath); + + for (const path of pathToVerify) { + await expectFileToMatch(join(outputPath, path), `

${lang}

`); + const baseHref = `/base/${subPath ? `${subPath}/` : ''}`; + await expectFileToMatch(join(outputPath, path), ``); + } + } + + // Tests responses + const port = await spawnServer(); + const pathnamesToVerify = ['/ssr', '/ssg']; + + for (const { lang } of langTranslations) { + for (const pathname of pathnamesToVerify) { + const subPath = URL_SUB_PATH[lang]; + const urlPathname = `/base${subPath ? `/${subPath}` : ''}${pathname}`; + const res = await fetch(`http://localhost:${port}${urlPathname}`); + const text = await res.text(); + + assert.match( + text, + new RegExp(`

${lang}

`), + `Response for '${urlPathname}': '

${lang}

' was not matched in content.`, + ); + } + } +} + +async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n.ts new file mode 100644 index 000000000000..efbff9871bbc --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-i18n.ts @@ -0,0 +1,130 @@ +import { join } from 'node:path'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; +import { findFreePort } from '../../../utils/network'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + import { Ssr } from './ssr/ssr'; + import { Ssg } from './ssg/ssg'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssg', + component: Ssg, + }, + { + path: 'ssr', + component: Ssr, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, + }, + { + path: 'ssg', + renderMode: RenderMode.Prerender, + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssr']; + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await noSilentNg('build', '--output-mode=server'); + + const expects: Record = { + 'index.html': 'home works!', + 'ssg/index.html': 'ssg works!', + }; + + for (const { lang, outputPath } of langTranslations) { + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join(outputPath, filePath), `

${lang}

`); + await expectFileToMatch(join(outputPath, filePath), fileMatch); + } + } + + // Tests responses + const port = await spawnServer(); + const pathname = '/ssr'; + + // We run the tests twice to ensure that the locale ID is set correctly. + for (const iteration of [1, 2]) { + for (const { lang, translation } of langTranslations) { + const res = await fetch(`http://localhost:${port}/${lang}${pathname}`); + const text = await res.text(); + + for (const match of [`

${translation.date}

`, `

${lang}

`]) { + assert.match( + text, + new RegExp(match), + `Response for '${lang}${pathname}': '${match}' was not matched in content. Iteration: ${iteration}.`, + ); + } + } + } +} + +async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts new file mode 100644 index 000000000000..dff15d055943 --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server-platform-neutral.ts @@ -0,0 +1,133 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { + installPackage, + installWorkspacePackages, + uninstallPackage, +} from '../../../utils/packages'; +import { updateJsonFile, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { findFreePort } from '../../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + import { Ssr } from './ssr/ssr'; + import { SsgWithParams } from './ssg-with-params/ssg-with-params'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssr', + component: Ssr, + }, + { + path: 'ssg/:id', + component: SsgWithParams, + }, + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: 'ssg/:id', + renderMode: RenderMode.Prerender, + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + }, + { + path: 'ssr', + renderMode: RenderMode.Server, + }, + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export const app = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.handle(toWebRequest(event))), + ); + + app.use(router); + export const reqHandler = createRequestHandler(toWebHandler(app)); + `, + }); + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssr', 'ssg-with-params']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + await updateJsonFile('angular.json', (json) => { + const buildTarget = json['projects']['test-project']['architect']['build']; + const options = buildTarget['options']; + options['ssr']['platform'] = 'neutral'; + options['outputMode'] = 'server'; + options['security'] ??= {}; + options['security']['allowedHosts'] = ['localhost']; + }); + + await noSilentNg('build'); + + // Valid SSG pages work + const expects: Record = { + 'index.html': 'home works!', + 'ssg/one/index.html': 'ssg-with-params works!', + 'ssg/two/index.html': 'ssg-with-params works!', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + const filesDoNotExist: string[] = ['csr/index.html', 'ssr/index.html', 'redirect/index.html']; + for (const filePath of filesDoNotExist) { + const file = join('dist/test-project/browser', filePath); + assert.equal(existsSync(file), false, `Expected '${file}' to not exist.`); + } + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npx', + ['-y', 'listhen@1', './dist/test-project/server/server.mjs', `--port=${port}`], + /Server initialized/, + ); + + const res = await fetch(`http://localhost:${port}/ssr`); + const text = await res.text(); + assert.match(text, new RegExp('ssr works!')); +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts new file mode 100644 index 000000000000..a922d9e142e7 --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-server.ts @@ -0,0 +1,215 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { expectFileToMatch, readFile, replaceInFile, writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { findFreePort } from '../../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Test scenario to verify that the content length, including \r\n, is accurate + await replaceInFile('src/app/app.ts', "title = signal('", "title = signal('Title\\r\\n"); + + // Ensure text has been updated. + assert.match(await readFile('src/app/app.ts'), /title = signal\('Title/); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + import { Csr } from './csr/csr'; + import { Ssr } from './ssr/ssr'; + import { Ssg } from './ssg/ssg'; + import { SsgWithParams } from './ssg-with-params/ssg-with-params'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssg', + component: Ssg, + }, + { + path: 'ssr', + component: Ssr, + }, + { + path: 'csr', + component: Csr, + }, + { + path: 'redirect', + redirectTo: 'ssg' + }, + { + path: 'ssg/:id', + component: SsgWithParams, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: 'ssg/:id', + renderMode: RenderMode.Prerender, + headers: { 'x-custom': 'ssg-with-params' }, + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}, {id: "customer's-choice"}], + }, + { + path: 'ssr', + renderMode: RenderMode.Server, + headers: { 'x-custom': 'ssr' }, + }, + { + path: 'csr', + renderMode: RenderMode.Client, + headers: { 'x-custom': 'csr' }, + }, + { + path: '**', + renderMode: RenderMode.Prerender, + headers: { 'x-custom': 'ssg' }, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssg-with-params', 'csr', 'ssr']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + // Generate app-shell + await ng('g', 'app-shell'); + + await noSilentNg('build', '--output-mode=server'); + + const expects: Record = { + 'index.html': 'home works!', + 'ssg/index.html': 'ssg works!', + 'ssg/one/index.html': 'ssg-with-params works!', + 'ssg/two/index.html': 'ssg-with-params works!', + "ssg/customer's-choice/index.html": 'ssg-with-params works!', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + const filesDoNotExist: string[] = ['csr/index.html', 'ssr/index.html', 'redirect/index.html']; + for (const filePath of filesDoNotExist) { + const file = join('dist/test-project/browser', filePath); + assert.equal(existsSync(file), false, `Expected '${file}' to not exist.`); + } + + // Tests responses + const responseExpects: Record< + string, + { headers: Record; content: string; serverContext: string } + > = { + '/': { + content: 'home works', + serverContext: 'ng-server-context="ssg"', + headers: { + 'x-custom': 'ssg', + }, + }, + '/ssg': { + content: 'ssg works!', + serverContext: 'ng-server-context="ssg"', + headers: { + 'x-custom': 'ssg', + }, + }, + '/ssr': { + content: 'ssr works!', + serverContext: 'ng-server-context="ssr"', + headers: { + 'x-custom': 'ssr', + }, + }, + '/csr': { + content: 'app-shell works', + serverContext: 'ng-server-context="ssg"', + headers: { + 'x-custom': 'csr', + }, + }, + '/redirect': { + content: 'ssg works!', + serverContext: 'ng-server-context="ssg"', + headers: { + 'x-custom': 'ssg', + }, + }, + }; + + const port = await spawnServer(); + for (const [pathname, { content, headers, serverContext }] of Object.entries(responseExpects)) { + // NOTE: A global 'UND_ERR_SOCKET' may occur due to an incorrect Content-Length header value. + const res = await fetch(`http://localhost:${port}${pathname}`); + const text = await res.text(); + + assert.match( + text, + new RegExp(content), + `Response for '${pathname}': ${content} was not matched in content.`, + ); + + assert.match( + text, + new RegExp(serverContext), + `Response for '${pathname}': ${serverContext} was not matched in content.`, + ); + + for (const [name, value] of Object.entries(headers)) { + assert.equal( + res.headers.get(name), + value, + `Response for '${pathname}': ${name} header value did not match expected.`, + ); + } + } +} + +async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts new file mode 100644 index 000000000000..b565144b37bf --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-http-calls.ts @@ -0,0 +1,130 @@ +import assert, { match } from 'node:assert'; +import { readFile, writeMultipleFiles } from '../../../utils/fs'; +import { ng, noSilentNg, silentNg } from '../../../utils/process'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Add asset + 'public/media.json': JSON.stringify({ dataFromAssets: true }), + // Update component to do an HTTP call to asset and API. + 'src/app/app.ts': ` + import { ChangeDetectorRef, Component, inject } from '@angular/core'; + import { JsonPipe } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + imports: [JsonPipe, RouterOutlet], + template: \` +

{{ assetsData | json }}

+

{{ apiData | json }}

+ + \`, + }) + export class App { + assetsData: any; + apiData: any; + private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + const http = inject(HttpClient); + + http.get('/media.json').toPromise().then((d) => { + this.assetsData = d; + this.cdr.markForCheck(); + }); + + http.get('/api').toPromise().then((d) => { + this.apiData = d; + this.cdr.markForCheck(); + }); + } + } + `, + // Add http client and route + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import { Home } from './home/home'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideHttpClient, withFetch } from '@angular/common/http'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter([{ + path: 'home', + component: Home, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { join } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const browserDistFolder = join(import.meta.dirname, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.get('/api', (req, res) => { + res.json({ dataFromAPI: true }) + }); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.use((req, res, next) => { + angularNodeAppEngine.handle(req) + .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) + .catch(next); + }); + return server; + } + + const server = app(); + + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, (error) => { + if (error) { + throw error; + } + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + await noSilentNg('build', '--output-mode=static'); + + const contents = await readFile('dist/test-project/browser/home/index.html'); + match(contents, /

{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, /

{[\S\s]*"dataFromAPI":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, /home works!/); +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts new file mode 100644 index 000000000000..964c7827c23e --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n-http-calls.ts @@ -0,0 +1,137 @@ +import assert, { match } from 'node:assert'; +import { join } from 'node:path'; +import { readFile, writeMultipleFiles } from '../../../utils/fs'; +import { ng, noSilentNg, silentNg } from '../../../utils/process'; +import { getGlobalVariable } from '../../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Add asset + 'public/media.json': JSON.stringify({ dataFromAssets: true }), + // Update component to do an HTTP call to asset and API. + 'src/app/app.ts': ` + import { ChangeDetectorRef, Component, inject } from '@angular/core'; + import { JsonPipe } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + imports: [JsonPipe, RouterOutlet], + template: \` +

{{ assetsData | json }}

+

{{ apiData | json }}

+ + \`, + }) + export class App { + assetsData: any; + apiData: any; + private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + const http = inject(HttpClient); + + http.get('media.json').toPromise().then((d) => { + this.assetsData = d; + this.cdr.markForCheck(); + }); + + http.get('/api').toPromise().then((d) => { + this.apiData = d; + this.cdr.markForCheck(); + }); + } + } + `, + // Add http client and route + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import { Home } from './home/home'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideHttpClient, withFetch } from '@angular/common/http'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter([{ + path: 'home', + component: Home, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { join } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const browserDistFolder = join(import.meta.dirname, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.get('/api', (req, res) => { + res.json({ dataFromAPI: true }) + }); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.use((req, res, next) => { + angularNodeAppEngine.handle(req) + .then((response) => response ? writeResponseToNodeResponse(response, res) : next()) + .catch(next); + }); + return server; + } + + const server = app(); + + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, (error) => { + if (error) { + throw error; + } + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + await noSilentNg('build', '--output-mode=static'); + + for (const { lang, outputPath } of langTranslations) { + const contents = await readFile(join(outputPath, 'home/index.html')); + match(contents, /

{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, /

{[\S\s]*"dataFromAPI":[\s\S]*true[\S\s]*}<\/p>/); + match(contents, new RegExp(``)); + } +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts new file mode 100644 index 000000000000..245375101946 --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static-i18n_APP_BASE_HREF.ts @@ -0,0 +1,105 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { ng, noSilentNg, silentNg } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { langTranslations, setupI18nConfig } from '../../i18n/setup'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Setup project + await setupI18nConfig(); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + import { Ssg } from './ssg/ssg'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssg', + component: Ssg, + }, + { + path: '**', + component: Home, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + ); + + await writeFile( + 'src/app/app.config.ts', + ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import { routes } from './app.routes'; + import { provideClientHydration } from '@angular/platform-browser'; + import { APP_BASE_HREF } from '@angular/common'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + provideClientHydration(), + { + provide: APP_BASE_HREF, + useValue: '/', + }, + ], + }; + `, + ); + + // Generate components for the above routes + await silentNg('generate', 'component', 'home'); + await silentNg('generate', 'component', 'ssg'); + + await noSilentNg('build', '--output-mode=static'); + + for (const { lang, outputPath } of langTranslations) { + await expectFileToMatch(join(outputPath, 'index.html'), `

${lang}

`); + await expectFileToMatch(join(outputPath, 'ssg/index.html'), `

${lang}

`); + } + + // Check that server directory does not exist + assert( + !existsSync('dist/test-project/server'), + 'Server directory should not exist when output-mode is static', + ); +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts new file mode 100644 index 000000000000..362cdc97e09e --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-output-mode-static.ts @@ -0,0 +1,146 @@ +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import assert from 'node:assert'; +import { + expectFileNotToExist, + expectFileToMatch, + replaceInFile, + writeFile, +} from '../../../utils/fs'; +import { ng, noSilentNg, silentNg } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { inject } from '@angular/core'; + import { Routes, Router } from '@angular/router'; + import { Home } from './home/home'; + import { Ssg } from './ssg/ssg'; + import { SsgWithParams } from './ssg-with-params/ssg-with-params'; + + export const routes: Routes = [ + { + path: '', + component: Home, + }, + { + path: 'ssg', + component: Ssg, + }, + { + path: 'ssg-redirect', + redirectTo: 'ssg' + }, + { + path: 'ssg-redirect-external', + component: Ssg, + }, + { + path: 'ssg-redirect-via-guard', + canActivate: [() => { + return inject(Router).createUrlTree(['ssg'], { queryParams: { foo: 'bar' }}) + }], + }, + { + path: 'ssg/:id', + component: SsgWithParams, + }, + { + path: '**', + component: Home, + }, + ]; + `, + ); + + // Add server routing + await writeFile( + 'src/app/app.routes.server.ts', + ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: 'ssg-redirect-external', + renderMode: RenderMode.Prerender, + headers: { Location: 'https://example.com/docs?from=ssg&next=/ssg' }, + }, + { + path: 'ssg/:id', + renderMode: RenderMode.Prerender, + getPrerenderParams: async() => [{id: 'one'}, {id: 'two'}], + }, + { + path: '**', + renderMode: RenderMode.Server, + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg', 'ssg-with-params']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + // Should error as above we set `RenderMode.Server` + const { message: errorMessage } = await expectToFail(() => + noSilentNg('build', '--output-mode=static'), + ); + assert.match( + errorMessage, + new RegExp( + `Route '/' is configured with server render mode, but the build 'outputMode' is set to 'static'.`, + ), + ); + + // Fix the error + await replaceInFile('src/app/app.routes.server.ts', 'RenderMode.Server', 'RenderMode.Prerender'); + await noSilentNg('build', '--output-mode=static'); + + const expects: Record = { + 'index.html': /ng-server-context="ssg".+home works!/, + 'ssg/index.html': /ng-server-context="ssg".+ssg works!/, + 'ssg/one/index.html': /ng-server-context="ssg".+ssg-with-params works!/, + 'ssg/two/index.html': /ng-server-context="ssg".+ssg-with-params works!/, + // When static redirects are generated as meta tags. + 'ssg-redirect/index.html': '', + // The target of a 'Location' header is HTML escaped before it is written to the page. + 'ssg-redirect-external/index.html': + '', + 'ssg-redirect-via-guard/index.html': + '', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + // Check that server directory does not exist + assert( + !existsSync('dist/test-project/server'), + 'Server directory should not exist when output-mode is static', + ); + + // Should not prerender the catch all + await expectFileNotToExist(join('dist/test-project/browser/**/index.html')); +} diff --git a/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts b/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts new file mode 100644 index 000000000000..697f2234fbf7 --- /dev/null +++ b/tests/e2e/tests/build/server-rendering/server-routes-preload-links.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert'; +import { replaceInFile, writeMultipleFiles } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch, execWithEnv, ng, silentNg } from '../../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; +import { findFreePort } from '../../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + appProject.architect['build'].options.namedChunks = true; + }); + + // Add routes + await writeMultipleFiles({ + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + + export const routes: Routes = [ + { + path: '', + loadComponent: () => import('./home/home').then(c => c.Home), + }, + { + path: 'ssg', + loadChildren: () => import('./ssg.routes').then(m => m.routes), + }, + { + path: 'ssr', + loadComponent: () => import('./ssr/ssr').then(c => c.Ssr), + }, + { + path: 'csr', + loadComponent: () => import('./csr/csr').then(c => c.Csr), + }, + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { + path: 'ssr', + renderMode: RenderMode.Server, + }, + { + path: 'csr', + renderMode: RenderMode.Client, + }, + { + path: '**', + renderMode: RenderMode.Prerender, + }, + ]; + `, + 'src/app/cross-dep.ts': `export const foo = 'foo';`, + 'src/app/ssg.routes.ts': ` + import { Routes } from '@angular/router'; + + export const routes: Routes = [ + { + path: '', + loadComponent: () => import('./ssg-component/ssg-component').then(c => c.SsgComponent), + }, + { + path: 'one', + loadComponent: () => import('./ssg-one/ssg-one').then(c => c.SsgOne), + }, + { + path: 'two', + loadComponent: () => import('./ssg-two/ssg-two').then(c => c.SsgTwo), + }, + ];`, + }); + + // Generate components for the above routes + const componentNames: string[] = ['home', 'ssg-component', 'csr', 'ssr', 'ssg-one', 'ssg-two']; + + for (const componentName of componentNames) { + await silentNg('generate', 'component', componentName); + } + + // Add a cross-dependency + await Promise.all([ + replaceInFile( + 'src/app/ssg-one/ssg-one.ts', + `One {`, + `One { + async ngOnInit() { + await import('../cross-dep'); + } + `, + ), + replaceInFile( + 'src/app/ssg-two/ssg-two.ts', + `Two {`, + `Two { + async ngOnInit() { + await import('../cross-dep'); + } + `, + ), + ]); + + // Test both vite and `ng build` + await runTests(await ngServe()); + + // Test with chunk optimization explicitly disabled + await execWithEnv('ng', ['build', '--output-mode=server'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + await runTests(await spawnServer()); + + // Test with default build behavior (chunk optimization enabled) + await ng('build', '--output-mode=server'); + await runTests(await spawnServer()); +} + +const RESPONSE_EXPECTS: Record< + string, + { + matches: RegExp[]; + notMatches: RegExp[]; + } +> = { + '/': { + matches: [//], + notMatches: [/ssg\-component/, /ssr/, /csr/, /cross-dep-/], + }, + '/ssg': { + matches: [ + //, + //, + ], + notMatches: [/home/, /ssr/, /csr/, /ssg-one/, /ssg-two/, /cross-dep-/], + }, + '/ssg/one': { + matches: [ + //, + //, + ], + notMatches: [/home/, /ssr/, /csr/, /ssg-two/, /ssg\-component/, /cross-dep-/], + }, + '/ssg/two': { + matches: [ + //, + //, + ], + notMatches: [/home/, /ssr/, /csr/, /ssg-one/, /ssg\-component/, /cross-dep-/], + }, + '/ssr': { + matches: [//], + notMatches: [/home/, /ssg\-component/, /csr/], + }, + '/csr': { + matches: [//], + notMatches: [/home/, /ssg\-component/, /ssr/, /cross-dep-/], + }, +}; + +async function runTests(port: number): Promise { + for (const [pathname, { matches, notMatches }] of Object.entries(RESPONSE_EXPECTS)) { + const res = await fetch(`http://localhost:${port}${pathname}`); + const text = await res.text(); + + for (const match of matches) { + assert.match(text, match, `Response for '${pathname}': ${match} was not matched in content.`); + } + + for (const match of notMatches) { + assert.doesNotMatch( + text, + match, + `Response for '${pathname}': ${match} was matched in content.`, + ); + } + + // Every emitted preload link must resolve successfully. + const preloadLinks = [...text.matchAll(//g)].map( + (match) => match[1], + ); + assert.ok( + preloadLinks.length > 0, + `Response for '${pathname}' should contain at least one modulepreload link.`, + ); + + for (const link of preloadLinks) { + const preloadRes = await fetch(new URL(link, `http://localhost:${port}/`)); + assert.equal( + preloadRes.status, + 200, + `Preload link '${link}' for '${pathname}' should resolve.`, + ); + } + } +} + +async function spawnServer(): Promise { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + ...process.env, + 'PORT': String(port), + 'NG_ALLOWED_HOSTS': 'localhost', + }, + ); + + return port; +} diff --git a/tests/e2e/tests/build/service-worker.ts b/tests/e2e/tests/build/service-worker.ts deleted file mode 100644 index aaec76d54c85..000000000000 --- a/tests/e2e/tests/build/service-worker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {join} from 'path'; -import {getGlobalVariable} from '../../utils/env'; -import {expectFileToExist, expectFileToMatch, writeFile, moveFile} from '../../utils/fs'; -import {ng, npm} from '../../utils/process'; - -export default function() { - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - const rootManifest = join(process.cwd(), 'ngsw-manifest.json'); - - // Can't use the `ng` helper because somewhere the environment gets - // stuck to the first build done - return npm('install', '@angular/service-worker') - .then(() => ng('set', 'apps.0.serviceWorker=true')) - .then(() => ng('build', '--prod')) - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - .then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-manifest.json'))) - .then(() => ng('build', '--prod', '--base-href=/foo/bar')) - .then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-manifest.json'))) - .then(() => expectFileToMatch('dist/ngsw-manifest.json', /"\/foo\/bar\/index.html"/)) - .then(() => writeFile(rootManifest, '{"local": true}')) - .then(() => ng('build', '--prod')) - .then(() => expectFileToMatch('dist/ngsw-manifest.json', /\"local\"/)) - .then(() => moveFile(rootManifest, join(process.cwd(), 'src/ngsw-manifest.json'))) - .then(() => ng('build', '--prod')) - .then(() => expectFileToMatch('dist/ngsw-manifest.json', /\"local\"/)); -} diff --git a/tests/e2e/tests/build/sourcemap.ts b/tests/e2e/tests/build/sourcemap.ts index 4e831558d9c4..2e153e637f30 100644 --- a/tests/e2e/tests/build/sourcemap.ts +++ b/tests/e2e/tests/build/sourcemap.ts @@ -1,24 +1,54 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; -import {getGlobalVariable} from '../../utils/env'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } + // The below is needed to cache bundles and verify that sourcemaps are generated + // corretly when output-hashing is disabled. + await ng('build', '--output-hashing=bundles', '--source-map', '--configuration=development'); + + await ng('build', '--output-hashing=none', '--source-map'); + await testForSourceMaps(useWebpackBuilder ? 2 : 1); + + await ng('build', '--output-hashing=none', '--source-map', '--configuration=development'); + await testForSourceMaps(useWebpackBuilder ? 3 : 1); +} - return ng('build', '--sourcemaps') - .then(() => expectFileToExist('dist/main.bundle.js.map')) +async function testForSourceMaps(expectedNumberOfFiles: number): Promise { + await expectFileToExist('dist/test-project/browser/main.js.map'); - .then(() => ng('build', '--no-sourcemap')) - .then(() => expectToFail(() => expectFileToExist('dist/main.bundle.js.map'))) + const files = fs.readdirSync('./dist/test-project/browser'); - .then(() => ng('build', '--prod', '--output-hashing=none')) - .then(() => expectToFail(() => expectFileToExist('dist/main.bundle.js.map'))) + let count = 0; + for (const file of files) { + if (!file.endsWith('.js')) { + continue; + } + + ++count; + + assert(files.includes(file + '.map'), 'Sourcemap not generated for ' + file); + + const content = fs.readFileSync('./dist/test-project/browser/' + file, 'utf8'); + let lastLineIndex = content.lastIndexOf('\n'); + if (lastLineIndex === content.length - 1) { + // Skip empty last line + lastLineIndex = content.lastIndexOf('\n', lastLineIndex - 1); + } + const comment = lastLineIndex !== -1 && content.slice(lastLineIndex).trim(); + assert.equal( + comment, + `//# sourceMappingURL=${file}.map`, + 'Sourcemap comment not generated for ' + file, + ); + } - .then(() => ng('build', '--prod', '--output-hashing=none', '--sourcemap')) - .then(() => expectFileToExist('dist/main.bundle.js.map')); + assert( + count >= expectedNumberOfFiles, + `Javascript file count is low. Expected ${expectedNumberOfFiles} but found ${count}`, + ); } diff --git a/tests/e2e/tests/build/styles/bootstrap.ts b/tests/e2e/tests/build/styles/bootstrap.ts new file mode 100644 index 000000000000..ec8c9f7936be --- /dev/null +++ b/tests/e2e/tests/build/styles/bootstrap.ts @@ -0,0 +1,22 @@ +import { writeMultipleFiles } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + // Install bootstrap + await installPackage('bootstrap@5'); + + await writeMultipleFiles({ + 'src/styles.scss': ` + @import 'bootstrap/scss/bootstrap'; + `, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.scss' }]; + }); + + await ng('build'); +} diff --git a/tests/e2e/tests/build/styles/empty-style-urls.ts b/tests/e2e/tests/build/styles/empty-style-urls.ts deleted file mode 100644 index 7f894371c007..000000000000 --- a/tests/e2e/tests/build/styles/empty-style-urls.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { writeMultipleFiles } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; - - -export default function () { - - return Promise.resolve() - // Write assets. - .then(_ => writeMultipleFiles({ - './src/app/app.component.ts': ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: [] - }) - export class AppComponent { - title = 'app'; - } - ` - })) - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/build/styles/extract-css.ts b/tests/e2e/tests/build/styles/extract-css.ts deleted file mode 100644 index 834b16dbba25..000000000000 --- a/tests/e2e/tests/build/styles/extract-css.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - writeMultipleFiles, - expectFileToExist, - expectFileToMatch -} from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { updateJsonFile } from '../../../utils/project'; -import { expectToFail } from '../../../utils/utils'; -import { getGlobalVariable } from '../../../utils/env'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/string-style.css': '.string-style { color: red }', - 'src/input-style.css': '.input-style { color: red }', - 'src/lazy-style.css': '.lazy-style { color: red }', - 'src/pre-rename-style.css': '.pre-rename-style { color: red }', - 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }' - })) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = [ - 'string-style.css', - { input: 'input-style.css' }, - { input: 'lazy-style.css', lazy: true }, - { input: 'pre-rename-style.css', output: 'renamed-style' }, - { input: 'pre-rename-lazy-style.css', output: 'renamed-lazy-style', lazy: true } - ]; - })) - .then(() => ng('build', '--extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/styles.bundle.css', '.string-style')) - .then(() => expectFileToMatch('dist/styles.bundle.css', '.input-style')) - .then(() => expectFileToMatch('dist/lazy-style.bundle.css', '.lazy-style')) - .then(() => expectFileToMatch('dist/renamed-style.bundle.css', '.pre-rename-style')) - .then(() => expectFileToMatch('dist/renamed-lazy-style.bundle.css', '.pre-rename-lazy-style')) - // there are no js entry points for css only bundles - .then(() => expectToFail(() => expectFileToExist('dist/style.bundle.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/lazy-style.bundle.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/renamed-style.bundle.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/renamed-lazy-style.bundle.js'))) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - `)) - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - `)) - // also check when css isn't extracted - .then(() => ng('build', '--no-extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/styles.bundle.js', '.string-style')) - .then(() => expectFileToMatch('dist/styles.bundle.js', '.input-style')) - .then(() => expectFileToMatch('dist/lazy-style.bundle.js', '.lazy-style')) - .then(() => expectFileToMatch('dist/renamed-style.bundle.js', '.pre-rename-style')) - .then(() => expectFileToMatch('dist/renamed-lazy-style.bundle.js', '.pre-rename-lazy-style')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - - - `)); -} diff --git a/tests/e2e/tests/build/styles/imports.ts b/tests/e2e/tests/build/styles/imports.ts deleted file mode 100644 index 5d7d71b7f077..000000000000 --- a/tests/e2e/tests/build/styles/imports.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; -import { getGlobalVariable } from '../../../utils/env'; - -export default function () { - // Disable parts of it in webpack tests. - const ejected = getGlobalVariable('argv').eject; - - const extensions = ['css', 'scss', 'less', 'styl']; - let promise = Promise.resolve(); - - extensions.forEach(ext => { - promise = promise.then(() => { - return writeMultipleFiles({ - [`src/styles.${ext}`]: stripIndents` - @import './imported-styles.${ext}'; - body { background-color: #00f; } - `, - [`src/imported-styles.${ext}`]: stripIndents` - p { background-color: #f00; } - `, - [`src/app/app.component.${ext}`]: stripIndents` - @import './imported-component-styles.${ext}'; - .outer { - .inner { - background: #fff; - } - } - `, - [`src/app/imported-component-styles.${ext}`]: stripIndents` - h1 { background: #000; } - `}) - // change files to use preprocessor - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = [`styles.${ext}`]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', `./app.component.${ext}`)) - // run build app - .then(() => ng('build', '--extract-css', '--sourcemap')) - // verify global styles - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: #00f;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: #f00;\s*}/)) - // verify global styles sourcemap - .then(() => expectToFail(() => - expectFileToMatch('dist/styles.bundle.css', '"mappings":""'))) - // verify component styles - .then(() => expectFileToMatch('dist/main.bundle.js', - /.outer.*.inner.*background:\s*#[fF]+/)) - .then(() => expectFileToMatch('dist/main.bundle.js', - /h1.*background:\s*#000+/)) - // Also check imports work on ng test - .then(() => !ejected && ng('test', '--single-run')) - // change files back - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.css']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - `./app.component.${ext}`, './app.component.css')); - }); - }); - - return promise; -} diff --git a/tests/e2e/tests/build/styles/include-paths.ts b/tests/e2e/tests/build/styles/include-paths.ts index 754ee04da397..fb1a0326ed9f 100644 --- a/tests/e2e/tests/build/styles/include-paths.ts +++ b/tests/e2e/tests/build/styles/include-paths.ts @@ -1,74 +1,60 @@ -import { - writeMultipleFiles, - expectFileToMatch, - replaceInFile, - createDir -} from '../../../utils/fs'; +import { writeMultipleFiles, expectFileToMatch, replaceInFile, createDir } from '../../../utils/fs'; import { ng } from '../../../utils/process'; import { updateJsonFile } from '../../../utils/project'; -export default function () { - return Promise.resolve() - .then(() => createDir('src/style-paths')) - .then(() => writeMultipleFiles({ - 'src/style-paths/_variables.scss': '$primary-color: red;', - 'src/styles.scss': ` +export default async function () { + await createDir('src/style-paths'); + await writeMultipleFiles({ + 'src/style-paths/_variables.scss': '$primary-color: red;', + 'src/styles.scss': ` @import 'variables'; h1 { color: $primary-color; } - `, - 'src/app/app.component.scss': ` + `, + 'src/app/app.scss': ` @import 'variables'; - h2 { background-color: $primary-color; } - `, - 'src/style-paths/variables.styl': '$primary-color = green', - 'src/styles.styl': ` - @import 'variables' - h3 - color: $primary-color - `, - 'src/app/app.component.styl': ` - @import 'variables' - h4 - background-color: $primary-color - `, - 'src/style-paths/variables.less': '@primary-color: #ADDADD;', - 'src/styles.less': ` + h2 { color: $primary-color; } + `, + 'src/style-paths/variables.less': '@primary-color: #ADDADD;', + 'src/styles.less': ` @import 'variables'; h5 { color: @primary-color; } - `, - 'src/app/app.component.less': ` + `, + 'src/app/app.less': ` @import 'variables'; h6 { color: @primary-color; } - ` - })) - .then(() => replaceInFile('src/app/app.component.ts', `'./app.component.css\'`, - `'./app.component.scss', './app.component.styl', './app.component.less'`)) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = [ - 'styles.scss', - 'styles.styl', - 'styles.less' - ]; - app['stylePreprocessorOptions'] = { - includePaths: [ - 'style-paths' - ] - }; - })) - // files were created successfully - .then(() => ng('build', '--extract-css')) - .then(() => expectFileToMatch('dist/styles.bundle.css', /h1\s*{\s*color: red;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /h2.*{.*color: red;.*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', /h3\s*{\s*color: #008000;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /h4.*{.*color: #008000;.*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', /h5\s*{\s*color: #ADDADD;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /h6.*{.*color: #ADDADD;.*}/)) - .then(() => ng('build', '--extract-css', '--aot')) - .then(() => expectFileToMatch('dist/styles.bundle.css', /h1\s*{\s*color: red;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /h2.*{.*color: red;.*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', /h3\s*{\s*color: #008000;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /h4.*{.*color: #008000;.*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', /h5\s*{\s*color: #ADDADD;\s*}/)) - .then(() => expectFileToMatch('dist/main.bundle.js', /h6.*{.*color: #ADDADD;.*}/)); + `, + }); + + await replaceInFile( + 'src/app/app.ts', + `styleUrl: './app.css\'`, + `styleUrls: ['./app.scss', './app.less']`, + ); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [ + { input: 'src/styles.scss' }, + { input: 'src/styles.less' }, + ]; + appArchitect.build.options.stylePreprocessorOptions = { + includePaths: ['src/style-paths'], + }; + }); + + await ng('build', '--configuration=development'); + await expectFileToMatch('dist/test-project/browser/styles.css', /h1\s*{\s*color: red;\s*}/); + await expectFileToMatch('dist/test-project/browser/main.js', /h2.*{.*color: red;.*}/); + // These checks are for the less files + await expectFileToMatch('dist/test-project/browser/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/); + await expectFileToMatch('dist/test-project/browser/main.js', /h6.*{.*color: #ADDADD;.*}/); + + await ng('build', '--no-aot', '--configuration=development'); + await expectFileToMatch('dist/test-project/browser/styles.css', /h1\s*{\s*color: red;\s*}/); + await expectFileToMatch('dist/test-project/browser/main.js', /h2.*{[\S\s]*color: red;[\S\s]*}/); + await expectFileToMatch('dist/test-project/browser/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /h6.*{[\S\s]*color: #ADDADD;[\S\s]*}/, + ); } diff --git a/tests/e2e/tests/build/styles/less.ts b/tests/e2e/tests/build/styles/less.ts index 10d191c5f648..c5d58d2d3b08 100644 --- a/tests/e2e/tests/build/styles/less.ts +++ b/tests/e2e/tests/build/styles/less.ts @@ -2,41 +2,59 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - replaceInFile + replaceInFile, } from '../../../utils/fs'; import { expectToFail } from '../../../utils/utils'; import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; import { updateJsonFile } from '../../../utils/project'; export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. + return writeMultipleFiles({ - 'src/styles.less': stripIndents` + 'src/styles.less': ` @import './imported-styles.less'; body { background-color: blue; } `, - 'src/imported-styles.less': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.less': stripIndents` + 'src/imported-styles.less': 'p { background-color: red; }', + 'src/app/app.less': ` .outer { .inner { background: #fff; } } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.less']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.less')) - .then(() => ng('build', '--extract-css', '--sourcemap')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectToFail(() => expectFileToMatch('dist/styles.bundle.css', '"mappings":""'))) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); + `, + }) + .then(() => deleteFile('src/app/app.css')) + .then(() => + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.less' }]; + }), + ) + .then(() => replaceInFile('src/app/app.ts', './app.css', './app.less')) + .then(() => ng('build', '--source-map', '--configuration=development')) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /body\s*{\s*background-color: blue;\s*}/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /p\s*{\s*background-color: red;\s*}/, + ), + ) + .then(() => + expectToFail(() => + expectFileToMatch('dist/test-project/browser/styles.css', '"mappings":""'), + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /.outer.*.inner.*background:\s*#[fF]+/, + ), + ); } diff --git a/tests/e2e/tests/build/styles/loaders.ts b/tests/e2e/tests/build/styles/loaders.ts index 703497b2f555..cbb602ece07b 100644 --- a/tests/e2e/tests/build/styles/loaders.ts +++ b/tests/e2e/tests/build/styles/loaders.ts @@ -2,38 +2,42 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - replaceInFile + replaceInFile, } from '../../../utils/fs'; import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; import { updateJsonFile } from '../../../utils/project'; import { expectToFail } from '../../../utils/utils'; -export default function () { - return writeMultipleFiles({ - 'src/styles.scss': stripIndents` +export default async function () { + await writeMultipleFiles({ + 'src/styles.scss': ` @import './imported-styles.scss'; body { background-color: blue; } `, - 'src/imported-styles.scss': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.scss': stripIndents` - .outer { - .inner { - background: #fff; - } + 'src/imported-styles.scss': 'p { background-color: red; }', + 'src/app/app.scss': ` + .outer { + .inner { + background: #fff; } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.scss']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) - .then(() => ng('build')) - .then(() => expectToFail(() => expectFileToMatch('dist/styles.bundle.css', /exports/))) - .then(() => expectToFail(() => expectFileToMatch('dist/main.bundle.js', - /".*module\.exports.*\.outer.*background:/))); + } + `, + }); + + await deleteFile('src/app/app.css'); + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.scss' }]; + }); + await replaceInFile('src/app/app.ts', './app.css', './app.scss'); + + await ng('build', '--configuration=development'); + + await expectToFail(() => expectFileToMatch('dist/test-project/browser/styles.css', /exports/)); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /".*module\.exports.*\.outer.*background:/, + ), + ); } diff --git a/tests/e2e/tests/build/styles/postcss.ts b/tests/e2e/tests/build/styles/postcss.ts deleted file mode 100644 index 08600f69d6eb..000000000000 --- a/tests/e2e/tests/build/styles/postcss.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as glob from 'glob'; -import { writeFile, expectFileToMatch } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { getGlobalVariable } from '../../../utils/env'; -import { stripIndents } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return writeFile('src/styles.css', stripIndents` - /* normal-comment */ - /*! important-comment */ - div { flex: 1 } - `) - // uses autoprefixer plugin for all builds - .then(() => ng('build', '--extract-css')) - .then(() => expectFileToMatch('dist/styles.bundle.css', stripIndents` - /* normal-comment */ - /*! important-comment */ - div { -webkit-box-flex: 1; -ms-flex: 1; flex: 1 } - `)) - // uses postcss-discard-comments plugin for prod - .then(() => ng('build', '--prod')) - .then(() => glob.sync('dist/styles.*.bundle.css').find(file => !!file)) - .then((stylesBundle) => expectFileToMatch(stylesBundle, stripIndents` - /*! important-comment */div{-webkit-box-flex:1;-ms-flex:1;flex:1} - `)); -} diff --git a/tests/e2e/tests/build/styles/sass-pkg-importer.ts b/tests/e2e/tests/build/styles/sass-pkg-importer.ts new file mode 100644 index 000000000000..8fbb6e74310f --- /dev/null +++ b/tests/e2e/tests/build/styles/sass-pkg-importer.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert'; +import { writeFile } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { isPrereleaseCli, updateJsonFile } from '../../../utils/project'; +import { appendFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // forcibly remove in case another test doesn't clean itself up + await uninstallPackage('@angular/material'); + + const isPrerelease = await isPrereleaseCli(); + const tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + await Promise.all([ + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = ['src/styles.scss']; + }), + writeFile('src/styles.scss', `@use 'pkg:@angular/material' as mat;`), + ]); + + await ng('build'); +} diff --git a/tests/e2e/tests/build/styles/sass.ts b/tests/e2e/tests/build/styles/sass.ts new file mode 100644 index 000000000000..45fd969b4c7b --- /dev/null +++ b/tests/e2e/tests/build/styles/sass.ts @@ -0,0 +1,54 @@ +import { + writeMultipleFiles, + deleteFile, + expectFileToMatch, + replaceInFile, +} from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/styles.sass': ` +@import './imported-styles.sass' +body + background-color: blue +`, + 'src/imported-styles.sass': ` +p + background-color: red +`, + 'src/app/app.sass': ` +.outer + .inner + background: #fff +`, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.sass' }]; + }); + + await deleteFile('src/app/app.css'); + await replaceInFile('src/app/app.ts', './app.css', './app.sass'); + + await ng('build', '--source-map', '--configuration=development'); + + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /body\s*{\s*background-color: blue;\s*}/, + ); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /p\s*{\s*background-color: red;\s*}/, + ); + await expectToFail(() => + expectFileToMatch('dist/test-project/browser/styles.css', '"mappings":""'), + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /.outer.*.inner.*background:\s*#[fF]+/, + ); +} diff --git a/tests/e2e/tests/build/styles/scss-partial-resolution.ts b/tests/e2e/tests/build/styles/scss-partial-resolution.ts new file mode 100644 index 000000000000..1a555b26e23b --- /dev/null +++ b/tests/e2e/tests/build/styles/scss-partial-resolution.ts @@ -0,0 +1,35 @@ +import { installPackage } from '../../../utils/packages'; +import { writeMultipleFiles, deleteFile, replaceInFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + // Supports resolving node_modules with are pointing to partial files partial files. + // @material/button/button below points to @material/button/_button.scss + // https://unpkg.com/browse/@material/button@14.0.0/_button.scss + + await installPackage('@material/button@14.0.0'); + + await writeMultipleFiles({ + 'src/styles.scss': ` + @use '@material/button/button'; + + @include button.core-styles; + `, + 'src/app/app.scss': ` + @use '@material/button/button'; + + @include button.core-styles; + `, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = ['src/styles.scss']; + }); + + await deleteFile('src/app/app.css'); + await replaceInFile('src/app/app.ts', './app.css', './app.scss'); + + await ng('build', '--configuration=development'); +} diff --git a/tests/e2e/tests/build/styles/scss.ts b/tests/e2e/tests/build/styles/scss.ts index 24a018d45eb0..69fcc1c0f060 100644 --- a/tests/e2e/tests/build/styles/scss.ts +++ b/tests/e2e/tests/build/styles/scss.ts @@ -2,41 +2,51 @@ import { writeMultipleFiles, deleteFile, expectFileToMatch, - replaceInFile + replaceInFile, } from '../../../utils/fs'; import { expectToFail } from '../../../utils/utils'; import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; import { updateJsonFile } from '../../../utils/project'; -export default function () { - return writeMultipleFiles({ - 'src/styles.scss': stripIndents` +export default async function () { + await writeMultipleFiles({ + 'src/styles.scss': ` @import './imported-styles.scss'; body { background-color: blue; } `, - 'src/imported-styles.scss': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.scss': stripIndents` + 'src/imported-styles.scss': 'p { background-color: red; }', + 'src/app/app.scss': ` .outer { .inner { background: #fff; } } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.scss']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) - .then(() => ng('build', '--extract-css', '--sourcemap')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectToFail(() => expectFileToMatch('dist/styles.bundle.css', '"mappings":""'))) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); + `, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [{ input: 'src/styles.scss' }]; + }); + + await deleteFile('src/app/app.css'); + await replaceInFile('src/app/app.ts', './app.css', './app.scss'); + + await ng('build', '--source-map', '--configuration=development'); + + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /body\s*{\s*background-color: blue;\s*}/, + ); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /p\s*{\s*background-color: red;\s*}/, + ); + await expectToFail(() => + expectFileToMatch('dist/test-project/browser/styles.css', '"mappings":""'), + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /.outer.*.inner.*background:\s*#[fF]+/, + ); } diff --git a/tests/e2e/tests/build/styles/styles-array.ts b/tests/e2e/tests/build/styles/styles-array.ts deleted file mode 100644 index 8149363993f3..000000000000 --- a/tests/e2e/tests/build/styles/styles-array.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch -} from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { updateJsonFile } from '../../../utils/project'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - return writeMultipleFiles({ - 'src/string-style.css': '.string-style { color: red }', - 'src/input-style.css': '.input-style { color: red }', - 'src/lazy-style.css': '.lazy-style { color: red }', - 'src/pre-rename-style.css': '.pre-rename-style { color: red }', - 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }' - }) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = [ - 'string-style.css', - { input: 'input-style.css' }, - { input: 'lazy-style.css', lazy: true }, - { input: 'pre-rename-style.css', output: 'renamed-style' }, - { input: 'pre-rename-lazy-style.css', output: 'renamed-lazy-style', lazy: true } - ]; - })) - .then(() => ng('build', '--extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/styles.bundle.css', '.string-style')) - .then(() => expectFileToMatch('dist/styles.bundle.css', '.input-style')) - .then(() => expectFileToMatch('dist/lazy-style.bundle.css', '.lazy-style')) - .then(() => expectFileToMatch('dist/renamed-style.bundle.css', '.pre-rename-style')) - .then(() => expectFileToMatch('dist/renamed-lazy-style.bundle.css', '.pre-rename-lazy-style')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - `)) - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - `)); -} diff --git a/tests/e2e/tests/build/styles/stylus.ts b/tests/e2e/tests/build/styles/stylus.ts deleted file mode 100644 index c73923a596ad..000000000000 --- a/tests/e2e/tests/build/styles/stylus.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - writeMultipleFiles, - deleteFile, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; - -export default function () { - return writeMultipleFiles({ - 'src/styles.styl': stripIndents` - @import './imported-styles.styl'; - body { background-color: blue; } - `, - 'src/imported-styles.styl': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.styl': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'] = ['styles.styl']; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.styl')) - .then(() => ng('build', '--extract-css', '--sourcemap')) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /body\s*{\s*background-color: #00f;\s*}/)) - .then(() => expectFileToMatch('dist/styles.bundle.css', - /p\s*{\s*background-color: #f00;\s*}/)) - .then(() => expectToFail(() => expectFileToMatch('dist/styles.bundle.css', '"mappings":""'))) - .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)); -} diff --git a/tests/e2e/tests/build/styles/symlinked-global.ts b/tests/e2e/tests/build/styles/symlinked-global.ts new file mode 100644 index 000000000000..b38663b321e9 --- /dev/null +++ b/tests/e2e/tests/build/styles/symlinked-global.ts @@ -0,0 +1,27 @@ +import { symlinkSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/styles.scss': `p { color: red }`, + 'src/styles-for-link.scss': `p { color: blue }`, + }); + + symlinkSync(resolve('src/styles-for-link.scss'), resolve('src/styles-linked.scss')); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = ['src/styles.scss', 'src/styles-linked.scss']; + }); + + await ng('build', '--configuration=development'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'red'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'blue'); + + await ng('build', '--preserve-symlinks', '--configuration=development'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'red'); + await expectFileToMatch('dist/test-project/browser/styles.css', 'blue'); +} diff --git a/tests/e2e/tests/build/styles/tailwind-v2.ts b/tests/e2e/tests/build/styles/tailwind-v2.ts new file mode 100644 index 000000000000..f081e040dcb5 --- /dev/null +++ b/tests/e2e/tests/build/styles/tailwind-v2.ts @@ -0,0 +1,74 @@ +import { deleteFile, expectFileToMatch, writeFile } from '../../../utils/fs'; +import { installPackage, uninstallPackage } from '../../../utils/packages'; +import { ng, silentExec } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + // Temporarily turn off caching until the build cache accounts for the presence of tailwind + // and its configuration file. Otherwise cached builds without tailwind will cause test failures. + await ng('cache', 'off'); + + // Create configuration file + await silentExec('npx', 'tailwindcss@2', 'init'); + + // Add Tailwind directives to a component style + await writeFile('src/app/app.css', '@tailwind base; @tailwind components;'); + + // Add Tailwind directives to a global style + await writeFile('src/styles.css', '@tailwind base; @tailwind components;'); + + // Ensure installation warning is present + const { stderr } = await ng('build', '--configuration=development'); + if (!stderr.includes("To enable Tailwind CSS, please install the 'tailwindcss' package.")) { + throw new Error(`Expected tailwind installation warning. STDERR:\n${stderr}`); + } + + // Tailwind directives should be unprocessed with missing package + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Install Tailwind + await installPackage('tailwindcss@2'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectFileToMatch('dist/test-project/browser/main.js', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ), + ); + + // Remove configuration file + await deleteFile('tailwind.config.js'); + + // Ensure Tailwind is disabled when no configuration file is present + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Uninstall Tailwind + await uninstallPackage('tailwindcss'); +} diff --git a/tests/e2e/tests/build/styles/tailwind-v3-cjs.ts b/tests/e2e/tests/build/styles/tailwind-v3-cjs.ts new file mode 100644 index 000000000000..c69f56aa7af4 --- /dev/null +++ b/tests/e2e/tests/build/styles/tailwind-v3-cjs.ts @@ -0,0 +1,40 @@ +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { installPackage, uninstallPackage } from '../../../utils/packages'; +import { ng, silentExec } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + // Temporarily turn off caching until the build cache accounts for the presence of tailwind + // and its configuration file. Otherwise cached builds without tailwind will cause test failures. + await ng('cache', 'off'); + + // Add type module in package.json. + await updateJsonFile('package.json', (json) => { + json['type'] = 'module'; + }); + + // Install Tailwind + await installPackage('tailwindcss@3'); + + // Create configuration file + await silentExec('npx', 'tailwindcss', 'init'); + + // Add Tailwind directives to a global style + await writeFile('src/styles.css', '@tailwind base; @tailwind components;'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + + // Uninstall Tailwind + await uninstallPackage('tailwindcss'); +} diff --git a/tests/e2e/tests/build/styles/tailwind-v3.ts b/tests/e2e/tests/build/styles/tailwind-v3.ts new file mode 100644 index 000000000000..97700a4c4b3e --- /dev/null +++ b/tests/e2e/tests/build/styles/tailwind-v3.ts @@ -0,0 +1,108 @@ +import { deleteFile, expectFileToMatch, rimraf, writeFile } from '../../../utils/fs'; +import { installPackage, uninstallPackage } from '../../../utils/packages'; +import { ng, silentExec } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + // Temporarily turn off caching until the build cache accounts for the presence of tailwind + // and its configuration file. Otherwise cached builds without tailwind will cause test failures. + await ng('cache', 'off'); + + // In case a previous test installed tailwindcss, clear it. + // (we don't clear node module directories between tests) + await rimraf('node_modules/tailwindcss'); + + // Create configuration file + await silentExec('npx', 'tailwindcss@3', 'init'); + + // Add Tailwind directives to a component style + await writeFile('src/app/app.css', '@tailwind base; @tailwind components;'); + + // Add Tailwind directives to a global style + await writeFile( + 'src/styles.css', + ` + @import url(https://fonts.googleapis.com/css?family=Roboto:400); + @tailwind base; + @tailwind components; + `, + ); + + // Ensure installation warning is present + const { stderr } = await ng('build', '--configuration=development'); + if (!stderr.includes("To enable Tailwind CSS, please install the 'tailwindcss' package.")) { + throw new Error('Expected tailwind installation warning'); + } + + // Tailwind directives should be unprocessed with missing package + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Install Tailwind + await installPackage('tailwindcss@3'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectFileToMatch('dist/test-project/browser/main.js', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ), + ); + + // Add Tailwind directives to an imported global style + await writeFile('src/tailwind.scss', '@tailwind base; @tailwind components;'); + await writeFile('src/styles.css', '@import "./tailwind.scss";'); + + // Build should succeed and process Tailwind directives + await ng('build', '--configuration=development'); + + // Check for Tailwind output + await expectFileToMatch('dist/test-project/browser/styles.css', /::placeholder/); + await expectFileToMatch('dist/test-project/browser/main.js', /::placeholder/); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ), + ); + await expectToFail(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ), + ); + + // Remove configuration file + await deleteFile('tailwind.config.js'); + + // Ensure Tailwind is disabled when no configuration file is present + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/styles.css', + /@tailwind base;\s+@tailwind components;/, + ); + await expectFileToMatch( + 'dist/test-project/browser/main.js', + /@tailwind base;(?:\\n|\s*)@tailwind components;/, + ); + + // Uninstall Tailwind + await uninstallPackage('tailwindcss'); +} diff --git a/tests/e2e/tests/build/ts-paths.ts b/tests/e2e/tests/build/ts-paths.ts index 4978528c0795..1461aaef2636 100644 --- a/tests/e2e/tests/build/ts-paths.ts +++ b/tests/e2e/tests/build/ts-paths.ts @@ -1,44 +1,39 @@ -import {updateTsConfig} from '../../utils/project'; -import {writeMultipleFiles, appendToFile, createDir, replaceInFile} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {stripIndents} from 'common-tags'; +import { appendToFile, createDir, replaceInFile, rimraf, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateTsConfig } from '../../utils/project'; - -export default function() { - return updateTsConfig(json => { - json['compilerOptions']['baseUrl'] = './'; +export default async function () { + await updateTsConfig((json) => { json['compilerOptions']['paths'] = { - '@shared': [ - 'app/shared' - ], - '@shared/*': [ - 'app/shared/*' - ], - '@root/*': [ - './*' - ] + '@shared': ['./src/app/shared'], + '@shared/*': ['./src/app/shared/*'], + '@root/*': ['./src/*'], + 'src/*': ['./src/*'], }; - }) - .then(() => createDir('src/app/shared')) - .then(() => writeMultipleFiles({ + }); + + await createDir('src/app/shared'); + await writeMultipleFiles({ 'src/meaning-too.ts': 'export var meaning = 42;', 'src/app/shared/meaning.ts': 'export var meaning = 42;', 'src/app/shared/index.ts': `export * from './meaning'`, - })) - .then(() => replaceInFile('src/app/app.module.ts', './app.component', '@root/app/app.component')) - .then(() => ng('build')) - .then(() => updateTsConfig(json => { - json['compilerOptions']['paths']['*'] = [ - '*', - 'app/shared/*' - ]; - })) - .then(() => appendToFile('src/app/app.component.ts', stripIndents` - import { meaning } from 'app/shared/meaning'; + }); + + await replaceInFile('src/main.ts', './app/app', '@root/app/app'); + await ng('build', '--configuration=development'); + + await updateTsConfig((json) => { + json['compilerOptions']['paths']['*'] = ['./*', './src/app/shared/*']; + }); + + await appendToFile( + 'src/app/app.ts', + ` + import { meaning } from 'src/app/shared/meaning'; import { meaning as meaning2 } from '@shared'; import { meaning as meaning3 } from '@shared/meaning'; import { meaning as meaning4 } from 'meaning'; - import { meaning as meaning5 } from 'meaning-too'; + import { meaning as meaning5 } from 'src/meaning-too'; // need to use imports otherwise they are ignored and // no error is outputted, even if baseUrl/paths don't work @@ -47,6 +42,12 @@ export default function() { console.log(meaning3) console.log(meaning4) console.log(meaning5) - `)) - .then(() => ng('build')); + `, + ); + + await ng('build', '--configuration=development'); + + // Simulate no package.json file which causes Webpack to have an undefined 'descriptionFileData'. + await rimraf('package.json'); + await ng('build', '--configuration=development'); } diff --git a/tests/e2e/tests/build/ts-standard-decorators.ts b/tests/e2e/tests/build/ts-standard-decorators.ts new file mode 100644 index 000000000000..05d056675d3b --- /dev/null +++ b/tests/e2e/tests/build/ts-standard-decorators.ts @@ -0,0 +1,37 @@ +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; +import { updateJsonFile, updateTsConfig } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Update project to disable experimental decorators + await updateTsConfig((json) => { + json['compilerOptions']['experimentalDecorators'] = false; + }); + + // Default production build + await ng('build'); + + // Production build with JIT + await updateJsonFile('angular.json', (json) => { + // Remove bundle budgets to avoid a build error due to the expected increased output size + // of a JIT production build. + json.projects['test-project'].architect.build.configurations.production.budgets = []; + }); + + if (!getGlobalVariable('argv')['esbuild']) { + await ng('build', '--no-aot', '--no-build-optimizer'); + } + + // Default development build + await ng('build', '--configuration=development'); + + // Development build with JIT + await ng('build', '--configuration=development', '--no-aot'); + + // Unit tests (JIT only) + await ng('test', '--no-watch'); + + // Ensure application functions in a browser + await executeBrowserTest(); +} diff --git a/tests/e2e/tests/build/type-check-disabled-emit.ts b/tests/e2e/tests/build/type-check-disabled-emit.ts new file mode 100644 index 000000000000..e2b831e1360e --- /dev/null +++ b/tests/e2e/tests/build/type-check-disabled-emit.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { readFile, writeFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +const OUTPUT_DIR = 'dist/test-project/browser'; + +async function readEmittedJs(): Promise> { + const contents = new Map(); + for (const file of (await readdir(OUTPUT_DIR)).sort()) { + if (file.endsWith('.js')) { + contents.set(file, await readFile(`${OUTPUT_DIR}/${file}`)); + } + } + + return contents; +} + +/** + * Verifies that disabling type checking (`NG_BUILD_TYPE_CHECK=0`) does not change the + * emitted JavaScript. When type checking is disabled, the AOT compilation skips the + * semantic "affected files" walk in the isolatedModules fast path (its only remaining + * consumers are diagnostics, which are already suppressed). The emitted output must remain + * byte-for-byte identical to a type-checked build. + */ +export default async function () { + // Disable the persistent disk cache so both builds are cold and independent, keeping the + // comparison free of incremental state carried over between the two runs. + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = { enabled: false }; + }); + + // A type-only cross-file dependency. The interface is used solely as a type (and in the + // template via `user.name`), so it is fully erased from the emitted JS. This is exactly the + // kind of cross-file type relationship that the affected-file walk would type-check. + await writeFile( + 'src/app/user.model.ts', + 'export interface User {\n id: number;\n name: string;\n}\n', + ); + + // Root component with both a template and the type-only dependency above. + await writeFile( + 'src/app/app.ts', + [ + `import { Component, signal } from '@angular/core';`, + `import { RouterOutlet } from '@angular/router';`, + `import type { User } from './user.model';`, + ``, + `@Component({`, + ` selector: 'app-root',`, + ` imports: [RouterOutlet],`, + ` template: '

Hello, {{ user.name }}

',`, + `})`, + `export class App {`, + ` protected readonly title = signal('test-project');`, + ` protected readonly user: User = { id: 1, name: 'Angular' };`, + `}`, + ``, + ].join('\n'), + ); + + // Baseline: type checking enabled (default). The affected-file walk runs. + await ng('build', '--configuration=development', '--output-hashing=none'); + const withTypeChecking = await readEmittedJs(); + + // Sanity check that the component (and therefore real emit) is present in the baseline, + // so an all-empty comparison cannot pass trivially. + assert.ok( + [...withTypeChecking.values()].some((contents) => contents.includes('Angular')), + 'Expected the baseline build to emit the component.', + ); + + // Type checking disabled: the affected-file walk is skipped in the isolatedModules fast + // path. The emitted output must be byte-for-byte identical to the baseline. + await execWithEnv('ng', ['build', '--configuration=development', '--output-hashing=none'], { + ...process.env, + NG_BUILD_TYPE_CHECK: '0', + }); + const withoutTypeChecking = await readEmittedJs(); + + assert.deepStrictEqual( + [...withoutTypeChecking.keys()], + [...withTypeChecking.keys()], + 'Disabling type checking must not change the set of emitted JS files.', + ); + + for (const [file, baseline] of withTypeChecking) { + assert.strictEqual( + withoutTypeChecking.get(file), + baseline, + `Emitted output for "${file}" changed when type checking was disabled.`, + ); + } +} diff --git a/tests/e2e/tests/build/unix-esbuild-unsupported.ts b/tests/e2e/tests/build/unix-esbuild-unsupported.ts new file mode 100644 index 000000000000..a7e491aebb02 --- /dev/null +++ b/tests/e2e/tests/build/unix-esbuild-unsupported.ts @@ -0,0 +1,16 @@ +import { join } from 'node:path'; +import { execWithEnv } from '../../utils/process'; + +export default async function () { + // TODO(bazel): fails with bazel on windows + if (process.platform.startsWith('win')) { + return; + } + + // Set the esbuild native binary path to a non-existent file to simulate a spawn error. + // The build should still succeed by falling back to the WASM variant of esbuild. + await execWithEnv('ng', ['build'], { + ...process.env, + 'ESBUILD_BINARY_PATH': join(__dirname, 'esbuild-bin-no-exist-xyz'), + }); +} diff --git a/tests/e2e/tests/build/unix-rebuild-replacements.ts b/tests/e2e/tests/build/unix-rebuild-replacements.ts new file mode 100644 index 000000000000..8f2286ae36b7 --- /dev/null +++ b/tests/e2e/tests/build/unix-rebuild-replacements.ts @@ -0,0 +1,41 @@ +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, createDir, writeMultipleFiles } from '../../utils/fs'; +import { waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { ngServe, updateJsonFile } from '../../utils/project'; + +const webpackGoodRegEx = getGlobalVariable('argv')['esbuild'] + ? /Application bundle generation complete\./ + : / Compiled successfully./; + +export default async function () { + if (process.platform.startsWith('win')) { + return; + } + + await createDir('src/environments'); + + await writeMultipleFiles({ + 'src/environments/environment.ts': `export const env = 'dev';`, + 'src/environments/environment.prod.ts': `export const env = 'prod';`, + 'src/main.ts': ` + import { env } from './environments/environment'; + console.log(env); + `, + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.configurations.production.fileReplacements = [ + { + replace: 'src/environments/environment.ts', + with: 'src/environments/environment.prod.ts', + }, + ]; + }); + + await ngServe('--configuration=production'); + + // Should trigger a rebuild. + await appendToFile('src/environments/environment.prod.ts', `console.log('PROD');`); + await waitForAnyProcessOutputToMatch(webpackGoodRegEx); +} diff --git a/tests/e2e/tests/build/vendor-chunk-symlink-node-module.ts b/tests/e2e/tests/build/vendor-chunk-symlink-node-module.ts deleted file mode 100644 index 4d928279f084..000000000000 --- a/tests/e2e/tests/build/vendor-chunk-symlink-node-module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../utils/process'; -import {deleteFile, expectFileToExist, moveFile, symlinkFile} from '../../utils/fs'; - - -// THIS TEST REQUIRES TO MOVE NODE_MODULES AND MOVE IT BACK. -export default function() { - return Promise.resolve() - .then(() => moveFile('node_modules', '../node_modules')) - .then(() => symlinkFile('../node_modules', 'node_modules', 'dir')) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/vendor.bundle.js')) - // Cleanup - .then(() => { - return deleteFile('node_modules') - .then(() => moveFile('../node_modules', 'node_modules')); - }, (err: any) => { - return deleteFile('node_modules') - .then(() => moveFile('../node_modules', 'node_modules')) - .then(() => { throw err; }); - }) -} diff --git a/tests/e2e/tests/build/vendor-chunk.ts b/tests/e2e/tests/build/vendor-chunk.ts deleted file mode 100644 index cafc19e3371b..000000000000 --- a/tests/e2e/tests/build/vendor-chunk.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return ng('build') - .then(() => expectFileToExist('dist/vendor.bundle.js')) - .then(() => ng('build', '--no-vendor-chunk')) - .then(() => expectToFail(() => expectFileToExist('dist/vendor.bundle.js'))); -} diff --git a/tests/e2e/tests/build/wasm-esm.ts b/tests/e2e/tests/build/wasm-esm.ts new file mode 100644 index 000000000000..70633a1021c1 --- /dev/null +++ b/tests/e2e/tests/build/wasm-esm.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ +import { readFile, writeFile } from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; +import { prependToFile, replaceInFile } from '../../utils/fs'; +import { updateJsonFile, useSha } from '../../utils/project'; +import { installWorkspacePackages } from '../../utils/packages'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +export default async function () { + // Add WASM file to project + await writeFile('src/app/multiply.wasm', importWasmBytes); + await writeFile( + 'src/app/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number; export declare function subtract1(a: number): number;', + ); + + // Add requested WASM import file + await writeFile('src/app/values.js', 'export function getValue() { return 100; }'); + + // Use WASM file in project + await prependToFile( + 'src/app/app.ts', + ` + import { multiply, subtract1 } from './multiply.wasm'; + `, + ); + await replaceInFile('src/app/app.ts', "'test-project'", 'multiply(4, 5) + subtract1(88)'); + + // Remove Zone.js from polyfills and make zoneless + await updateJsonFile('angular.json', (json) => { + // Remove bundle budgets to avoid a build error due to the expected increased output size + // of a JIT production build. + json.projects['test-project'].architect.build.options.polyfills = []; + }); + + await ng('build'); + + // Update E2E test to check for WASM execution + await executeBrowserTest({ expectedTitleText: 'Hello, 32' }); + + // Setup prerendering and build to test Node.js functionality + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await ng('build', '--configuration', 'development', '--prerender'); + const content = await readFile('dist/test-project/browser/index.html', 'utf-8'); + assert.match(content, /Hello, 32/); +} diff --git a/tests/e2e/tests/build/watch.ts b/tests/e2e/tests/build/watch.ts deleted file mode 100644 index 850a32f0a0c3..000000000000 --- a/tests/e2e/tests/build/watch.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - killAllProcesses, - exec, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch -} from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - - -const webpackGoodRegEx = /webpack: bundle is now VALID|webpack: Compiled successfully./; - -export default function () { - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - - return execAndWaitForOutputToMatch('ng', ['serve'], webpackGoodRegEx) - // Should trigger a rebuild. - .then(() => exec('touch', 'src/main.ts')) - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 5000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }) - .then(() => execAndWaitForOutputToMatch('ng', ['serve', '--no-watch'], webpackGoodRegEx)) - // Should not trigger a rebuild when not watching files. - .then(() => exec('touch', 'src/main.ts')) - .then(() => expectToFail(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 5000))) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }) -} diff --git a/tests/e2e/tests/build/worker.ts b/tests/e2e/tests/build/worker.ts new file mode 100644 index 000000000000..be81aae7dfd4 --- /dev/null +++ b/tests/e2e/tests/build/worker.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, expectFileToMatch, replaceInFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + + const workerPath = 'src/app/app.worker.ts'; + const snippetPath = 'src/app/app.ts'; + const projectTsConfig = 'tsconfig.json'; + const workerTsConfig = 'tsconfig.worker.json'; + + await ng('generate', 'web-worker', 'app'); + await expectFileToExist(workerPath); + await expectFileToExist(projectTsConfig); + await expectFileToExist(workerTsConfig); + await expectFileToMatch(snippetPath, `new Worker(new URL('./app.worker', import.meta.url)`); + + await ng('build', '--configuration=development'); + if (useWebpackBuilder) { + await expectFileToExist('dist/test-project/browser/src_app_app_worker_ts.js'); + await expectFileToMatch('dist/test-project/browser/main.js', 'src_app_app_worker_ts'); + } else { + const workerOutputFile = await getWorkerOutputFile(false); + await expectFileToExist(`dist/test-project/browser/${workerOutputFile}`); + await expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile); + await expectToFail(() => + expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile + '.map'), + ); + } + + await ng('build', '--output-hashing=none'); + + const workerOutputFile = await getWorkerOutputFile(useWebpackBuilder); + await expectFileToExist(`dist/test-project/browser/${workerOutputFile}`); + if (useWebpackBuilder) { + // Check Webpack builds for the numeric chunk identifier + await expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile.substring(0, 3)); + } else { + await expectFileToMatch('dist/test-project/browser/main.js', workerOutputFile); + } + + // console.warn has to be used because chrome only captures warnings and errors by default + // https://github.com/angular/protractor/issues/2207 + await replaceInFile('src/app/app.ts', 'console.log', 'console.warn'); + + await executeBrowserTest({ + checkFn: async (page) => { + const messages: string[] = []; + page.on('console', (msg) => { + messages.push(msg.text()); + }); + + // Reload to ensure we capture messages from the start if needed, + // although executeBrowserTest already navigated. + await page.reload(); + + // Wait for the worker message + let retries = 50; + while ( + !messages.some((m) => m.includes('page got message: worker response to hello')) && + retries > 0 + ) { + await setTimeout(100); + retries--; + } + + if (!messages.some((m) => m.includes('page got message: worker response to hello'))) { + assert.fail(`Expected worker message not found in console. Got:\n${messages.join('\n')}`); + } + }, + }); +} + +async function getWorkerOutputFile(useWebpackBuilder: boolean): Promise { + const files = await readdir('dist/test-project/browser'); + let fileName; + if (useWebpackBuilder) { + fileName = files.find((f) => /^\d{3}\.js$/.test(f)); + } else { + fileName = files.find((f) => /worker-[\dA-Z]{8}\.js/.test(f)); + } + + assert(fileName, 'Cannot determine worker output file name.'); + + return fileName; +} diff --git a/tests/e2e/tests/commands/add/add-material.ts b/tests/e2e/tests/commands/add/add-material.ts new file mode 100644 index 000000000000..a7453f035a6f --- /dev/null +++ b/tests/e2e/tests/commands/add/add-material.ts @@ -0,0 +1,46 @@ +import { assertIsError } from '../../../utils/utils'; +import { readFile, rimraf } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { isPrereleaseCli } from '../../../utils/project'; +import { appendFile } from 'node:fs/promises'; +import assert from 'node:assert'; + +export default async function () { + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/material'); + + const isPrerelease = await isPrereleaseCli(); + const tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + try { + await ng('add', `@angular/material${tag}`, '--unknown', '--skip-confirmation'); + } catch (error) { + assertIsError(error); + if (!(error as Error).message.includes(`Unknown option: '--unknown'`)) { + throw error; + } + } + + await ng( + 'add', + `@angular/material${tag}`, + '--theme', + 'azure-blue', + '--verbose', + '--skip-confirmation', + ); + + const { dependencies } = JSON.parse(await readFile('package.json')); + assert.ok( + dependencies['@angular/material'], + '`@angular/material` was not found added to dependencies', + ); + + // Clean up existing cdk package + // Not doing so can cause adding material to fail if an incompatible cdk is present + await uninstallPackage('@angular/cdk'); +} diff --git a/tests/e2e/tests/commands/add/add-pwa.ts b/tests/e2e/tests/commands/add/add-pwa.ts new file mode 100644 index 000000000000..c01cc185c84b --- /dev/null +++ b/tests/e2e/tests/commands/add/add-pwa.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist, readFile, rimraf } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + // forcibly remove in case another test doesn't clean itself up + await rimraf('node_modules/@angular/pwa'); + await ng('add', '@angular/pwa', '--skip-confirmation'); + await expectFileToExist('public/manifest.webmanifest'); + + // Angular PWA doesn't install as a dependency + const { dependencies, devDependencies } = JSON.parse(await readFile('package.json')); + const hasPWADep = Object.keys({ ...dependencies, ...devDependencies }).some( + (d) => d === '@angular/pwa', + ); + assert.ok(!hasPWADep, `Expected 'package.json' not to contain a dependency on '@angular/pwa'.`); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + let needInstall = false; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries(snapshots.dependencies)) { + if (name in dependencies && dependencies[name] !== version) { + dependencies[name] = version; + needInstall = true; + } + } + }); + + if (needInstall) { + await installWorkspacePackages(); + } + } + + // It should generate a SW configuration file (`ngsw.json`). + const ngswPath = 'dist/test-project/browser/ngsw.json'; + + await ng('build'); + await expectFileToExist(ngswPath); + + // It should correctly generate assetGroups and include at least one URL in each group. + const ngswJson = JSON.parse(await readFile(ngswPath)); + // @ts-ignore + const assetGroups: any[] = ngswJson.assetGroups.map(({ name, urls }) => ({ + name, + urlCount: urls.length, + })); + const emptyAssetGroups = assetGroups.filter(({ urlCount }) => urlCount === 0); + + assert.ok(assetGroups.length > 0, "Expected 'ngsw.json' to contain at least one asset-group."); + assert.ok( + emptyAssetGroups.length === 0, + 'Expected all asset-groups to contain at least one URL, but the following groups are empty: ' + + emptyAssetGroups.map(({ name }) => name).join(', '), + ); +} diff --git a/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts b/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts new file mode 100644 index 000000000000..c6356dc8d92c --- /dev/null +++ b/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts @@ -0,0 +1,121 @@ +import { join } from 'node:path'; +import { promises as fs } from 'node:fs'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist, expectFileNotToExist, rimraf } from '../../../utils/fs'; +import { getActivePackageManager } from '../../../utils/packages'; +import { ng, silentNpm } from '../../../utils/process'; +import { mktempd } from '../../../utils/utils'; + +export default async function () { + const testRegistry = getGlobalVariable('package-registry'); + const tmpRoot = getGlobalVariable('tmp-root'); + + // 1. Create a temp directory for the custom package + const pkgDir = await mktempd('registry-stripped-pkg-', tmpRoot); + + try { + // 2. Write the package files + const packageJson = { + name: '@angular-devkit/ng-add-registry-stripped', + version: '1.0.0', + schematics: './collection.json', + 'ng-add': { + save: false, + }, + }; + + const collectionJson = { + schematics: { + 'ng-add': { + factory: './index.js', + description: 'Add test empty file to your application.', + }, + }, + }; + + const indexJs = ` + exports.default = function() { + return function(tree) { + tree.create('schematic-executed-successfully.txt', 'Registry Stripped schematic works!'); + return tree; + }; + }; + `; + + await fs.writeFile(join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + await fs.writeFile(join(pkgDir, 'collection.json'), JSON.stringify(collectionJson, null, 2)); + await fs.writeFile(join(pkgDir, 'index.js'), indexJs); + + // Write a temporary .npmrc with a fake authentication token so that npm publish succeeds + // without needing real credentials or throwing ENEEDAUTH. + const npmrcContent = ` +${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret +registry=${testRegistry} +`; + await fs.writeFile(join(pkgDir, '.npmrc'), npmrcContent); + + // 3. Pack the package + const packResult = await silentNpm(['pack'], { cwd: pkgDir }); + const tarballName = packResult.stdout.trim().split('\n').pop() || ''; + + // 4. Publish the package to the local verdaccio registry + // Verdaccio has publish: $all for @angular-devkit/* so this will succeed + await silentNpm(['publish'], { cwd: pkgDir }); + + // 5. Strip "schematics" and "ng-add" from Verdaccio's metadata on disk + const verdaccioDbPath = join( + tmpRoot, + 'registry', + 'storage', + '@angular-devkit', + 'ng-add-registry-stripped', + 'package.json', + ); + + const verdaccioDb = JSON.parse(await fs.readFile(verdaccioDbPath, 'utf-8')); + + // Strip from the top-level versions list + if (verdaccioDb.versions) { + for (const versionKey of Object.keys(verdaccioDb.versions)) { + delete verdaccioDb.versions[versionKey].schematics; + delete verdaccioDb.versions[versionKey]['ng-add']; + } + } + + // Write back the modified metadata + await fs.writeFile(verdaccioDbPath, JSON.stringify(verdaccioDb, null, 2), 'utf-8'); + + // 6. Execute `ng add` on the registry-stripped package + // Ensure file doesn't already exist + await expectFileNotToExist('schematic-executed-successfully.txt'); + + await ng('add', '@angular-devkit/ng-add-registry-stripped', '--skip-confirmation'); + + // 7. Assertions + // A. The schematic executed successfully + await expectFileToExist('schematic-executed-successfully.txt'); + + // B. The dependency was pruned from package.json since save: false + const rootPackageJson = JSON.parse(await fs.readFile('package.json', 'utf-8')); + const hasDep = + (rootPackageJson.dependencies && + rootPackageJson.dependencies['@angular-devkit/ng-add-registry-stripped']) || + (rootPackageJson.devDependencies && + rootPackageJson.devDependencies['@angular-devkit/ng-add-registry-stripped']); + + if (hasDep) { + throw new Error( + 'Package @angular-devkit/ng-add-registry-stripped was not cleaned up from package.json dependencies!', + ); + } + + // C. The dependency was pruned from node_modules physical folder + // Bun intentionally does not prune unreferenced packages from node_modules automatically. + if (getActivePackageManager() !== 'bun') { + await expectFileNotToExist('node_modules/@angular-devkit/ng-add-registry-stripped'); + } + } finally { + // Cleanup temp package source folder + await rimraf(pkgDir); + } +} diff --git a/tests/e2e/tests/commands/add/add-tailwindcss.ts b/tests/e2e/tests/commands/add/add-tailwindcss.ts new file mode 100644 index 000000000000..fa42815d2fdf --- /dev/null +++ b/tests/e2e/tests/commands/add/add-tailwindcss.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert'; +import { expectFileToExist, expectFileToMatch, readFile, rimraf } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; + +export default async function () { + // In case a previous test installed tailwindcss, clear it. + // (we don't clear node module directories between tests) + // npm does not appear to fully uninstall sometimes + if (getActivePackageManager() === 'npm') { + await rimraf('node_modules/tailwindcss'); + } + + try { + await ng('add', 'tailwindcss', '--skip-confirmation'); + await expectFileToExist('.postcssrc.json'); + await expectFileToMatch('src/styles.css', /@import 'tailwindcss';/); + + const { dependencies, devDependencies } = JSON.parse(await readFile('package.json')); + assert.strictEqual( + dependencies?.tailwindcss, + undefined, + 'tailwindcss should not be added to dependencies.', + ); + assert.ok(devDependencies?.tailwindcss, 'tailwindcss should be added to devDependencies.'); + assert.ok( + devDependencies?.['@tailwindcss/postcss'], + '@tailwindcss/postcss should be added to devDependencies.', + ); + assert.ok(devDependencies?.postcss, 'postcss should be added to devDependencies.'); + + // Ensure the project builds + await ng('build', '--configuration=development'); + } finally { + await uninstallPackage('tailwindcss'); + await uninstallPackage('@tailwindcss/postcss'); + await uninstallPackage('postcss'); + } +} diff --git a/tests/e2e/tests/commands/add/add-version.ts b/tests/e2e/tests/commands/add/add-version.ts new file mode 100644 index 000000000000..02d63eb66e0b --- /dev/null +++ b/tests/e2e/tests/commands/add/add-version.ts @@ -0,0 +1,9 @@ +import { expectFileToExist, expectFileToMatch, rimraf } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('add', '@angular-devkit-tests/ng-add-simple@^1.0.0', '--skip-confirmation'); + await expectFileToMatch('package.json', /\/ng-add-simple.*\^1\.0\.0/); + await expectFileToExist('ng-add-test'); + await rimraf('node_modules/@angular-devkit-tests/ng-add-simple'); +} diff --git a/tests/e2e/tests/commands/add/add-vitest-browser.ts b/tests/e2e/tests/commands/add/add-vitest-browser.ts new file mode 100644 index 000000000000..7a2b780c0d8f --- /dev/null +++ b/tests/e2e/tests/commands/add/add-vitest-browser.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert'; +import { expectFileToMatch, readFile } from '../../../utils/fs'; +import { uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { applyVitestBuilder } from '../../../utils/vitest'; + +export default async function () { + await applyVitestBuilder(); + + try { + await ng('add', '@vitest/browser-playwright', '--skip-confirmation'); + + const { dependencies, devDependencies } = JSON.parse(await readFile('package.json')); + assert.strictEqual( + dependencies?.['@vitest/browser-playwright'], + undefined, + '@vitest/browser-playwright should not be added to dependencies.', + ); + assert.ok( + devDependencies?.['@vitest/browser-playwright'], + '@vitest/browser-playwright should be added to devDependencies.', + ); + assert.ok(devDependencies?.playwright, 'playwright should be added to devDependencies.'); + + await expectFileToMatch('tsconfig.spec.json', /"vitest\/globals"/); + await expectFileToMatch('tsconfig.spec.json', /"@vitest\/browser-playwright"/); + } finally { + await uninstallPackage('@vitest/browser-playwright'); + await uninstallPackage('playwright'); + } +} diff --git a/tests/e2e/tests/commands/add/add.ts b/tests/e2e/tests/commands/add/add.ts new file mode 100644 index 000000000000..6d9827013dc4 --- /dev/null +++ b/tests/e2e/tests/commands/add/add.ts @@ -0,0 +1,9 @@ +import { expectFileToExist, expectFileToMatch, rimraf } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('add', '@angular-devkit-tests/ng-add-simple', '--skip-confirmation'); + await expectFileToMatch('package.json', /@angular-devkit-tests\/ng-add-simple/); + await expectFileToExist('ng-add-test'); + await rimraf('node_modules/@angular-devkit-tests/ng-add-simple'); +} diff --git a/tests/e2e/tests/commands/add/base.ts b/tests/e2e/tests/commands/add/base.ts new file mode 100644 index 000000000000..d31210c6c242 --- /dev/null +++ b/tests/e2e/tests/commands/add/base.ts @@ -0,0 +1,19 @@ +import { assetDir } from '../../../utils/assets'; +import { deleteFile, expectFileToExist, symlinkFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await symlinkFile(assetDir('add-collection-dir'), `./node_modules/add-collection`, 'dir'); + + await ng('add', 'add-collection'); + await expectFileToExist('empty-file'); + + await ng('add', 'add-collection', '--name=blah'); + await expectFileToExist('blah'); + + await expectToFail(() => ng('add', 'add-collection')); // File already exists. + + // Cleanup the package + await deleteFile('node_modules/add-collection'); +} diff --git a/tests/e2e/tests/commands/add/dir.ts b/tests/e2e/tests/commands/add/dir.ts new file mode 100644 index 000000000000..7cb00704cc8e --- /dev/null +++ b/tests/e2e/tests/commands/add/dir.ts @@ -0,0 +1,19 @@ +import { cp } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { assetDir } from '../../../utils/assets'; +import { expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + const collectionName = 'add-collection-dir'; + const dirCollectionPath = resolve(collectionName); + + // Copy locally as bun doesn't install the dependency correctly if it has symlinks. + await cp(assetDir(collectionName), dirCollectionPath, { + recursive: true, + dereference: true, + }); + + await ng('add', dirCollectionPath, '--name=blah', '--skip-confirmation'); + await expectFileToExist('blah'); +} diff --git a/tests/e2e/tests/commands/add/file.ts b/tests/e2e/tests/commands/add/file.ts new file mode 100644 index 000000000000..5b22b4211ceb --- /dev/null +++ b/tests/e2e/tests/commands/add/file.ts @@ -0,0 +1,13 @@ +import { copyFile } from 'node:fs/promises'; +import { assetDir } from '../../../utils/assets'; +import { expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + // Avoids ERR_PNPM_ENAMETOOLONG errors. + const tarball = './add-collection.tgz'; + await copyFile(assetDir(tarball), tarball); + + await ng('add', tarball, '--name=blah', '--skip-confirmation'); + await expectFileToExist('blah'); +} diff --git a/tests/e2e/tests/commands/add/npm-config.ts b/tests/e2e/tests/commands/add/npm-config.ts new file mode 100644 index 000000000000..6f7599e8c00c --- /dev/null +++ b/tests/e2e/tests/commands/add/npm-config.ts @@ -0,0 +1,8 @@ +import { writeFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + // Works with before option + await writeFile('.npmrc', `before=${new Date().toISOString()}`); + await ng('add', '@angular/pwa', '--skip-confirmation'); +} diff --git a/tests/e2e/tests/commands/add/peer.ts b/tests/e2e/tests/commands/add/peer.ts new file mode 100644 index 000000000000..143542e4533c --- /dev/null +++ b/tests/e2e/tests/commands/add/peer.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import { cp } from 'node:fs/promises'; +import { assetDir } from '../../../utils/assets'; +import { ng } from '../../../utils/process'; + +export default async function (): Promise { + const warning = /Adding the package may not succeed/; + + const stdout1 = await runNgAdd('add-collection-peer-bad'); + assert.match( + stdout1, + warning, + `Peer warning should be shown for add-collection-peer-bad but was not.`, + ); + + const stdout2 = await runNgAdd('add-collection-dir'); + assert.doesNotMatch( + stdout2, + warning, + `Peer warning should NOT be shown for add-collection-dir but was.`, + ); + + const stdout3 = await runNgAdd('add-collection-peer-good'); + assert.doesNotMatch( + stdout3, + warning, + `Peer warning should NOT be shown for add-collection-peer-good but was.`, + ); +} + +async function runNgAdd(collectionName: string): Promise { + const collectionPath = resolve(collectionName); + + // Copy locally as bun doesn't install the dependency correctly if it has symlinks. + await cp(assetDir(collectionName), collectionPath, { + recursive: true, + dereference: true, + }); + + const { stdout } = await ng('add', collectionPath, '--skip-confirmation'); + + return stdout; +} diff --git a/tests/e2e/tests/commands/add/registry-option.ts b/tests/e2e/tests/commands/add/registry-option.ts new file mode 100644 index 000000000000..1343cebf4dd1 --- /dev/null +++ b/tests/e2e/tests/commands/add/registry-option.ts @@ -0,0 +1,42 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist } from '../../../utils/fs'; +import { execAndCaptureError, ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const testRegistry = getGlobalVariable('package-registry'); + + // Validate that a non-URL registry string fails with a validation error + const error = await execAndCaptureError('ng', [ + 'add', + '--registry=not-a-valid-url', + '@angular/pwa', + '--skip-confirmation', + ]); + if (!error.message.includes('Option --registry must be a valid URL.')) { + throw new Error( + `Expected registry validation error, but got different error: ${error.message}`, + ); + } + + // Validate that an empty registry string fails with a validation error + const errorEmpty = await execAndCaptureError('ng', [ + 'add', + '--registry=', + '@angular/pwa', + '--skip-confirmation', + ]); + if (!errorEmpty.message.includes('Option --registry must be a valid URL.')) { + throw new Error( + `Expected registry validation error for empty string, but got: ${errorEmpty.message}`, + ); + } + + // Set an invalid registry + process.env['NPM_CONFIG_REGISTRY'] = 'http://127.0.0.1:9999'; + + await expectToFail(() => ng('add', '@angular/pwa', '--skip-confirmation')); + + await ng('add', `--registry=${testRegistry}`, '@angular/pwa', '--skip-confirmation'); + await expectFileToExist('public/manifest.webmanifest'); +} diff --git a/tests/e2e/tests/commands/add/secure-registry.ts b/tests/e2e/tests/commands/add/secure-registry.ts new file mode 100644 index 000000000000..e585444b02e4 --- /dev/null +++ b/tests/e2e/tests/commands/add/secure-registry.ts @@ -0,0 +1,30 @@ +import { expectFileNotToExist, expectFileToExist } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { git, ng } from '../../../utils/process'; +import { createNpmConfigForAuthentication } from '../../../utils/registry'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const originalNpmConfigRegistry = process.env['NPM_CONFIG_REGISTRY']; + try { + // The environment variable has priority over the .npmrc + delete process.env['NPM_CONFIG_REGISTRY']; + const command = ['add', '@angular/pwa', '--skip-confirmation']; + + // Works with scoped registry authentication details + await expectFileNotToExist('public/manifest.webmanifest'); + + await createNpmConfigForAuthentication(); + await ng(...command); + await expectFileToExist('public/manifest.webmanifest'); + await git('clean', '-dxf'); + + // Invalid authentication token + await createNpmConfigForAuthentication(true, true); + await expectToFail(() => ng(...command)); + } finally { + process.env['NPM_CONFIG_REGISTRY'] = originalNpmConfigRegistry; + await git('clean', '-dxf'); + await installWorkspacePackages(); + } +} diff --git a/tests/e2e/tests/commands/add/version-specifier.ts b/tests/e2e/tests/commands/add/version-specifier.ts new file mode 100644 index 000000000000..f88d60a51ec9 --- /dev/null +++ b/tests/e2e/tests/commands/add/version-specifier.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { appendFile } from 'node:fs/promises'; +import { expectFileToMatch } from '../../../utils/fs'; +import { getActivePackageManager, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { isPrereleaseCli } from '../../../utils/project'; + +export default async function () { + // forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/localize'); + + // If using npm, enable the legacy-peer-deps option to allow testing the output behavior of the + // `ng add` command itself and not the behavior of npm which may otherwise fail depending + // on the npm version in use and the version specifier supplied in each test. + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true\n'); + } + + const tag = isPrereleaseCli() ? '@next' : ''; + + await ng('add', `@angular/localize${tag}`, '--skip-confirmation'); + await expectFileToMatch('package.json', /@angular\/localize/); + + const output1 = await ng('add', '@angular/localize', '--skip-confirmation'); + assert.match( + output1.stdout, + /Skipping installation: Package already installed/, + 'Installation was not skipped', + ); + + const output2 = await ng('add', '@angular/localize@latest', '--skip-confirmation'); + assert.doesNotMatch( + output2.stdout, + /Skipping installation: Package already installed/, + 'Installation should not have been skipped', + ); + + const output3 = await ng('add', '@angular/localize@19.1.0', '--skip-confirmation'); + assert.doesNotMatch( + output3.stdout, + /Skipping installation: Package already installed/, + 'Installation should not have been skipped', + ); + + const output4 = await ng('add', '@angular/localize@19', '--skip-confirmation'); + assert.match( + output4.stdout, + /Skipping installation: Package already installed/, + 'Installation was not skipped', + ); + + await uninstallPackage('@angular/localize'); +} diff --git a/tests/e2e/tests/commands/add/yarn-env-vars.ts b/tests/e2e/tests/commands/add/yarn-env-vars.ts new file mode 100644 index 000000000000..103a99c04056 --- /dev/null +++ b/tests/e2e/tests/commands/add/yarn-env-vars.ts @@ -0,0 +1,29 @@ +import { expectFileNotToExist, expectFileToExist } from '../../../utils/fs'; +import { getActivePackageManager } from '../../../utils/packages'; +import { git, ng } from '../../../utils/process'; +import { + createNpmConfigForAuthentication, + setNpmEnvVarsForAuthentication, +} from '../../../utils/registry'; + +export default async function () { + // Yarn specific test that tests YARN_ env variables. + // https://classic.yarnpkg.com/en/docs/envvars/ + if (getActivePackageManager() !== 'yarn') { + return; + } + const command = ['add', '@angular/pwa', '--skip-confirmation']; + + // Environment variables only + await expectFileNotToExist('public/manifest.webmanifest'); + setNpmEnvVarsForAuthentication(false, true); + await ng(...command); + await expectFileToExist('public/manifest.webmanifest'); + await git('clean', '-dxf'); + + // Mix of config file and env vars works + await expectFileNotToExist('public/manifest.webmanifest'); + await createNpmConfigForAuthentication(false, true); + await ng(...command); + await expectFileToExist('public/manifest.webmanifest'); +} diff --git a/tests/e2e/tests/commands/additional-properties.ts b/tests/e2e/tests/commands/additional-properties.ts new file mode 100644 index 000000000000..a53006853fca --- /dev/null +++ b/tests/e2e/tests/commands/additional-properties.ts @@ -0,0 +1,31 @@ +import { createDir, rimraf, writeMultipleFiles } from '../../utils/fs'; +import { execAndWaitForOutputToMatch } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await createDir('example-builder'); + await writeMultipleFiles({ + 'example-builder/package.json': '{ "builders": "./builders.json" }', + 'example-builder/schema.json': + '{ "$schema": "http://json-schema.org/draft-07/schema", "type": "object", "additionalProperties": true }', + 'example-builder/builders.json': + '{ "$schema": "@angular-devkit/architect/src/builders-schema.json", "builders": { "example": { "implementation": "./example", "schema": "./schema.json" } } }', + 'example-builder/example.js': + 'module.exports.default = require("@angular-devkit/architect").createBuilder((options) => { console.log(options); return { success: true }; });', + }); + + await updateJsonFile('angular.json', (json) => { + const appArchitect = json.projects['test-project'].architect; + appArchitect.example = { + builder: './example-builder:example', + }; + }); + + await execAndWaitForOutputToMatch( + 'ng', + ['run', 'test-project:example', '--additional', 'property'], + /Unknown argument: additional/, + ); + + await rimraf('example-builder'); +} diff --git a/tests/e2e/tests/commands/analytics/analytics-enable-disable.ts b/tests/e2e/tests/commands/analytics/analytics-enable-disable.ts new file mode 100644 index 000000000000..94bd8f95edb2 --- /dev/null +++ b/tests/e2e/tests/commands/analytics/analytics-enable-disable.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert'; +import { readFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('analytics', 'enable'); + assert.ok(JSON.parse(await readFile('angular.json')).cli.analytics); + + await ng('analytics', 'disable'); + assert.strictEqual(JSON.parse(await readFile('angular.json')).cli.analytics, false); +} diff --git a/tests/e2e/tests/commands/analytics/analytics-info.ts b/tests/e2e/tests/commands/analytics/analytics-info.ts new file mode 100644 index 000000000000..d92dfd2ffde6 --- /dev/null +++ b/tests/e2e/tests/commands/analytics/analytics-info.ts @@ -0,0 +1,30 @@ +import { execAndWaitForOutputToMatch } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +export default async function () { + // Should be disabled by default. + await configureTest(undefined /** analytics */); + await execAndWaitForOutputToMatch('ng', ['analytics', 'info'], /Effective status: disabled/, { + ...process.env, + NG_FORCE_TTY: '0', // Disable prompts + }); + + await configureTest('1dba0835-38a3-4957-bf34-9974e2df0df3' /** analytics */); + await execAndWaitForOutputToMatch('ng', ['analytics', 'info'], /Effective status: enabled/, { + ...process.env, + NG_FORCE_TTY: '0', // Disable prompts + }); + + await configureTest(false /** analytics */); + await execAndWaitForOutputToMatch('ng', ['analytics', 'info'], /Effective status: disabled/, { + ...process.env, + NG_FORCE_TTY: '0', // Disable prompts + }); +} + +async function configureTest(analytics: false | string | undefined): Promise { + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.analytics = analytics; + }); +} diff --git a/tests/e2e/tests/commands/analytics/ask-analytics-command.ts b/tests/e2e/tests/commands/analytics/ask-analytics-command.ts new file mode 100644 index 000000000000..ae20bb21f2fa --- /dev/null +++ b/tests/e2e/tests/commands/analytics/ask-analytics-command.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { execWithEnv } from '../../../utils/process'; +import { mockHome } from '../../../utils/utils'; + +const ANALYTICS_PROMPT = /Would you like to share pseudonymous usage data/; + +export default async function () { + // CLI should prompt for analytics permissions. + await mockHome(async () => { + const { stdout } = await execWithEnv( + 'ng', + ['config'], + { + ...process.env, + NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', + }, + 'n\n' /* stdin */, + ); + + assert.match(stdout, ANALYTICS_PROMPT, 'CLI did not prompt for analytics permission.'); + }); + + // CLI should skip analytics prompt with `NG_CLI_ANALYTICS=false`. + await mockHome(async () => { + const { stdout } = await execWithEnv('ng', ['config'], { + ...process.env, + NG_FORCE_TTY: '1', + NG_CLI_ANALYTICS: 'false', + NG_FORCE_AUTOCOMPLETE: 'false', + }); + + assert.doesNotMatch( + stdout, + ANALYTICS_PROMPT, + 'CLI prompted for analytics permission when it should be forced off.', + ); + }); + + // CLI should skip analytics prompt during `ng update`. + await mockHome(async () => { + const { stdout } = await execWithEnv('ng', ['update', '--help'], { + ...process.env, + NG_FORCE_TTY: '1', + NG_FORCE_AUTOCOMPLETE: 'false', + }); + + assert.doesNotMatch( + stdout, + ANALYTICS_PROMPT, + 'CLI prompted for analytics permission during an update where it should not have.', + ); + }); +} diff --git a/tests/e2e/tests/commands/build/build-outdir.ts b/tests/e2e/tests/commands/build/build-outdir.ts deleted file mode 100644 index 6259cec5299d..000000000000 --- a/tests/e2e/tests/commands/build/build-outdir.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ng} from '../../../utils/process'; -import {updateJsonFile} from '../../../utils/project'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['outDir'] = './'; - })) - .then(() => expectToFail(() => ng('build'))) - .then(() => expectToFail(() => ng('serve'))) - .then(() => expectToFail(() => ng('eject'))); -} - - - diff --git a/tests/e2e/tests/commands/builder-not-found.ts b/tests/e2e/tests/commands/builder-not-found.ts new file mode 100644 index 000000000000..ec0002ef65d3 --- /dev/null +++ b/tests/e2e/tests/commands/builder-not-found.ts @@ -0,0 +1,45 @@ +import { moveFile } from '../../utils/fs'; +import { getActivePackageManager, installPackage, uninstallPackage } from '../../utils/packages'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + try { + await uninstallPackage('@angular-devkit/build-angular'); + + await expectToFail(() => ng('build')); + await execAndWaitForOutputToMatch( + 'ng', + ['build'], + /Could not find the '@angular-devkit\/build-angular:browser' builder's node package\./, + ); + await expectToFail(() => + execAndWaitForOutputToMatch( + 'ng', + ['build'], + new RegExp( + `Node packages may not be installed\\. Try installing with '${getActivePackageManager()} install'\\.`, + ), + ), + ); + + await moveFile('node_modules', 'temp_node_modules'); + + await expectToFail(() => ng('build')); + await execAndWaitForOutputToMatch( + 'ng', + ['build'], + /Could not find the '@angular-devkit\/build-angular:browser' builder's node package\./, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['build'], + new RegExp( + `Node packages may not be installed\\. Try installing with '${getActivePackageManager()} install'\\.`, + ), + ); + } finally { + await moveFile('temp_node_modules', 'node_modules'); + await installPackage('@angular-devkit/build-angular'); + } +} diff --git a/tests/e2e/tests/commands/builder-project-by-cwd.ts b/tests/e2e/tests/commands/builder-project-by-cwd.ts new file mode 100644 index 000000000000..6033f4542391 --- /dev/null +++ b/tests/e2e/tests/commands/builder-project-by-cwd.ts @@ -0,0 +1,21 @@ +import { join } from 'node:path'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await ng('generate', 'app', 'second-app', '--skip-install'); + await ng('generate', 'app', 'third-app', '--skip-install'); + const startCwd = process.cwd(); + + try { + // When no project is provided it should favor the project that is located in the current working directory. + process.chdir(join(startCwd, 'projects/second-app')); + await ng('build', '--configuration=development'); + + process.chdir(startCwd); + await expectFileToExist('dist/second-app'); + } finally { + // restore path + process.chdir(startCwd); + } +} diff --git a/tests/e2e/tests/commands/cache/cache-clean.ts b/tests/e2e/tests/commands/cache/cache-clean.ts new file mode 100644 index 000000000000..004c5a069dfc --- /dev/null +++ b/tests/e2e/tests/commands/cache/cache-clean.ts @@ -0,0 +1,11 @@ +import { createDir, expectFileNotToExist, expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + const cachePath = '.angular/cache'; + await createDir(cachePath); + await expectFileToExist(cachePath); + + await ng('cache', 'clean'); + await expectFileNotToExist(cachePath); +} diff --git a/tests/e2e/tests/commands/cache/cache-enable-disable.ts b/tests/e2e/tests/commands/cache/cache-enable-disable.ts new file mode 100644 index 000000000000..1cfbf705787e --- /dev/null +++ b/tests/e2e/tests/commands/cache/cache-enable-disable.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('cache', 'enable'); + assert.strictEqual( + JSON.parse(await readFile('angular.json')).cli.cache.enabled, + true, + `Expected 'cli.cache.enable' to be true.`, + ); + + await ng('cache', 'disable'); + assert.strictEqual( + JSON.parse(await readFile('angular.json')).cli.cache.enabled, + false, + `Expected 'cli.cache.enable' to be false.`, + ); +} diff --git a/tests/e2e/tests/commands/cache/cache-info.ts b/tests/e2e/tests/commands/cache/cache-info.ts new file mode 100644 index 000000000000..bad2df6c3bdc --- /dev/null +++ b/tests/e2e/tests/commands/cache/cache-info.ts @@ -0,0 +1,78 @@ +import { execAndWaitForOutputToMatch } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const ENV_NO_COLOR = { + 'NO_COLOR': '1', +}; + +export default async function () { + const originalCIValue = process.env['CI']; + + try { + // Should be enabled by default for local builds. + await configureTest('0' /** envCI */); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Enabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + + // Should be disabled by default for CI builds. + await configureTest('1' /** envCI */, { enabled: true }); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Disabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + + // Should be enabled by when environment is local and env is not CI. + await configureTest('0' /** envCI */, { environment: 'local' }); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Enabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + + // Should be disabled by when environment is local and env is CI. + await configureTest('1' /** envCI */, { environment: 'local' }); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Disabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + + // Effective status should be enabled when 'environment' is set to 'all' or 'ci'. + await configureTest('1' /** envCI */, { environment: 'all' }); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Enabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + + // Effective status should be enabled when 'environment' is set to 'ci' and run is in ci + await configureTest('1' /** envCI */, { environment: 'ci' }); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Enabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + + // Effective status should be disabled when 'enabled' is set to false + await configureTest('1' /** envCI */, { environment: 'all', enabled: false }); + await execAndWaitForOutputToMatch('ng', ['cache', 'info'], /Effective Status\s*: Disabled/, { + ...process.env, + ...ENV_NO_COLOR, + }); + } finally { + process.env['CI'] = originalCIValue; + } +} + +async function configureTest( + envCI: '1' | '0', + cacheOptions?: { + environment?: 'ci' | 'local' | 'all'; + enabled?: boolean; + }, +): Promise { + process.env['CI'] = envCI; + + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = cacheOptions; + }); +} diff --git a/tests/e2e/tests/commands/completion/completion-prompt.ts b/tests/e2e/tests/commands/completion/completion-prompt.ts new file mode 100644 index 000000000000..424b373e47a6 --- /dev/null +++ b/tests/e2e/tests/commands/completion/completion-prompt.ts @@ -0,0 +1,470 @@ +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { env } from 'node:process'; +import { getGlobalVariable } from '../../../utils/env'; +import { mockHome } from '../../../utils/utils'; +import assert from 'node:assert/strict'; + +import { + execAndCaptureError, + execAndWaitForOutputToMatch, + execWithEnv, + silentNpm, +} from '../../../utils/process'; + +const AUTOCOMPLETION_PROMPT = /Would you like to enable autocompletion\?/; +const DEFAULT_ENV = Object.freeze({ + ...env, + // Shell should be mocked for each test that cares about it. + SHELL: '/bin/bash', + // Even if the actual test process is run on CI, we're testing user flows which aren't on CI. + CI: undefined, + // Tests run on CI technically don't have a TTY, but the autocompletion prompt requires it, so we + // force a TTY by default. + NG_FORCE_TTY: '1', + // Analytics wants to prompt for a first command as well, but we don't care about that here. + NG_CLI_ANALYTICS: 'false', +}); + +const testRegistry = getGlobalVariable('package-registry'); + +export default async function () { + // Windows Cmd and Powershell do not support autocompletion. Run a different set of tests to + // confirm autocompletion skips the prompt appropriately. + if (process.platform === 'win32') { + await windowsTests(); + return; + } + + // Sets up autocompletion after user accepts a prompt from any command. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + const { stdout } = await execWithEnv( + 'ng', + ['config'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y\n' /* stdin: accept prompt */, + ); + + assert.match( + stdout, + AUTOCOMPLETION_PROMPT, + 'CLI execution did not prompt for autocompletion setup when it should have.', + ); + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + assert.match( + bashrcContents, + /source <\(ng completion script\)/, + 'Autocompletion was *not* added to `~/.bashrc` after accepting the setup prompt.', + ); + + assert.match( + stdout, + /Appended `source <\(ng completion script\)`/, + 'CLI did not print that it successfully set up autocompletion.', + ); + }); + + // Does nothing if the user rejects the autocompletion prompt. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + const { stdout } = await execWithEnv( + 'ng', + ['config'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'n\n' /* stdin: reject prompt */, + ); + + assert.match( + stdout, + AUTOCOMPLETION_PROMPT, + 'CLI execution did not prompt for autocompletion setup when it should have.', + ); + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + assert.doesNotMatch( + bashrcContents, + /ng completion/, + 'Autocompletion was incorrectly added to `~/.bashrc` after refusing the setup prompt.', + ); + + assert.doesNotMatch( + stdout, + /Appended `source <\(ng completion script\)`/, + "CLI printed that it successfully set up autocompletion when it actually didn't.", + ); + + assert.match( + stdout, + /Ok, you won't be prompted again\./, + 'CLI did not inform the user they will not be prompted again.', + ); + }); + + // Does *not* prompt if the user already accepted (even if they delete the completion config). + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + + const { stdout: stdout1 } = await execWithEnv( + 'ng', + ['config'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y\n' /* stdin: accept prompt */, + ); + + assert.match( + stdout1, + AUTOCOMPLETION_PROMPT, + 'First execution did not prompt for autocompletion setup.', + ); + + const bashrcContents1 = await fs.readFile(bashrc, 'utf-8'); + assert.match( + bashrcContents1, + /source <\(ng completion script\)/, + '`~/.bashrc` file was not updated after the user accepted the autocompletion' + + ` prompt. Contents:\n${bashrcContents1}`, + ); + + // User modifies their configuration and removes `ng completion`. + await fs.writeFile(bashrc, '# Some new commands...'); + + const { stdout: stdout2 } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + + assert.doesNotMatch( + stdout2, + AUTOCOMPLETION_PROMPT, + 'Subsequent execution after rejecting autocompletion setup prompted again' + + ' when it should not have.', + ); + + const bashrcContents2 = await fs.readFile(bashrc, 'utf-8'); + assert.strictEqual( + bashrcContents2, + '# Some new commands...', + '`~/.bashrc` file was incorrectly modified when using a modified `~/.bashrc`' + + ` after previously accepting the autocompletion prompt. Contents:\n${bashrcContents2}`, + ); + }); + + // Does *not* prompt if the user already rejected. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + + const { stdout: stdout1 } = await execWithEnv( + 'ng', + ['config'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'n\n' /* stdin: reject prompt */, + ); + + assert.match( + stdout1, + AUTOCOMPLETION_PROMPT, + 'First execution did not prompt for autocompletion setup.', + ); + + const { stdout: stdout2 } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + + assert.doesNotMatch( + stdout2, + AUTOCOMPLETION_PROMPT, + 'Subsequent execution after rejecting autocompletion setup prompted again' + + ' when it should not have.', + ); + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + assert.strictEqual( + bashrcContents, + '# Other commands...', + '`~/.bashrc` file was incorrectly modified when the user never accepted the' + + ` autocompletion prompt. Contents:\n${bashrcContents}`, + ); + }); + + // Prompts user again on subsequent execution after accepting prompt but failing to setup. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + + // Make `~/.bashrc` readonly. This is enough for the CLI to verify that the file exists and + // `ng completion` is not in it, but will fail when actually trying to modify the file. + await fs.chmod(bashrc, 0o444); + + const err = await execAndCaptureError( + 'ng', + ['config'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y\n' /* stdin: accept prompt */, + ); + + assert.match( + err.message, + /Failed to append autocompletion setup/, + `Failed first execution did not print the expected error message. Actual:\n${err.message}`, + ); + + // User corrects file permissions between executions. + await fs.chmod(bashrc, 0o777); + + const { stdout: stdout2 } = await execWithEnv( + 'ng', + ['config'], + { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }, + 'y\n' /* stdin: accept prompt */, + ); + + assert.match( + stdout2, + AUTOCOMPLETION_PROMPT, + 'Subsequent execution after failed autocompletion setup did not prompt again when it should' + + ' have.', + ); + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + assert.match( + bashrcContents, + /ng completion script/, + '`~/.bashrc` file does not include `ng completion` after the user never accepted the' + + ` autocompletion prompt a second time. Contents:\n${bashrcContents}`, + ); + }); + + // Does *not* prompt for `ng update` commands. + await mockHome(async (home) => { + // Use `ng update --help` so it's actually a no-op and we don't need to setup a project. + const { stdout } = await execWithEnv('ng', ['update', '--help'], { + ...DEFAULT_ENV, + HOME: home, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + '`ng update` command incorrectly prompted for autocompletion setup.', + ); + }); + + // Does *not* prompt for `ng completion` commands. + await mockHome(async (home) => { + const { stdout } = await execWithEnv('ng', ['completion'], { + ...DEFAULT_ENV, + HOME: home, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + '`ng completion` command incorrectly prompted for autocompletion setup.', + ); + }); + + // Does *not* prompt user for CI executions. + { + const { stdout } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + CI: 'true', + NG_FORCE_TTY: undefined, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'CI execution prompted for autocompletion setup but should not have.', + ); + } + + // Does *not* prompt user for non-TTY executions. + { + const { stdout } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + NG_FORCE_TTY: 'false', + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'Non-TTY execution prompted for autocompletion setup but should not have.', + ); + } + + // Does *not* prompt user for executions without a `$HOME`. + { + const { stdout } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + HOME: undefined, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'Execution without a `$HOME` value prompted for autocompletion setup but' + + ' should not have.', + ); + } + + // Does *not* prompt user for executions without a `$SHELL`. + { + const { stdout } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + SHELL: undefined, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'Execution without a `$SHELL` value prompted for autocompletion setup but' + + ' should not have.', + ); + } + + // Does *not* prompt user for executions from unknown shells. + { + const { stdout } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + SHELL: '/usr/bin/unknown', + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'Execution with an unknown `$SHELL` value prompted for autocompletion setup' + + ' but should not have.', + ); + } + + // Does *not* prompt user when an RC file already uses `ng completion`. + await mockHome(async (home) => { + await fs.writeFile( + path.join(home, '.bashrc'), + ` +# Some stuff... + +source <(ng completion script) + +# Some other stuff... + `.trim(), + ); + + const { stdout } = await execWithEnv('ng', ['config'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + "Execution with an existing `ng completion` line in the user's RC file" + + ' prompted for autocompletion setup but should not have.', + ); + }); + + // Prompts when a global CLI install is present on the system. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + await execAndWaitForOutputToMatch('ng', ['config'], AUTOCOMPLETION_PROMPT, { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + }); + }); + + // Does *not* prompt when a global CLI install is missing from the system. + await mockHome(async (home) => { + try { + // Temporarily uninstall the global CLI binary from the system. + await silentNpm(['uninstall', '--global', '@angular/cli', `--registry=${testRegistry}`]); + + // Setup a fake project directory with a local install of the CLI. + const projectDir = path.join(home, 'project'); + await fs.mkdir(projectDir); + await silentNpm(['init', '-y', `--registry=${testRegistry}`], { cwd: projectDir }); + await silentNpm(['install', '@angular/cli', `--registry=${testRegistry}`], { + cwd: projectDir, + }); + + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + const localCliDir = path.join(projectDir, 'node_modules', '.bin'); + const localCliBinary = path.join(localCliDir, 'ng'); + const pathDirs = process.env['PATH']!.split(':'); + const pathEnvVar = [...pathDirs, localCliDir].join(':'); + const { stdout } = await execWithEnv(localCliBinary, ['config'], { + ...DEFAULT_ENV, + SHELL: '/bin/bash', + HOME: home, + PATH: pathEnvVar, + }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'Execution without a global CLI install prompted for autocompletion setup but should' + + ' not have.', + ); + } finally { + // Reinstall global CLI for remainder of the tests. + await silentNpm(['install', '--global', '@angular/cli', `--registry=${testRegistry}`]); + } + }); +} + +async function windowsTests(): Promise { + // Should *not* prompt on Windows, autocompletion isn't supported. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, `# Other content...`); + + const { stdout } = await execWithEnv('ng', ['config'], { ...env }); + + assert.doesNotMatch( + stdout, + AUTOCOMPLETION_PROMPT, + 'Execution prompted to set up autocompletion on Windows despite not actually being' + + ' supported.', + ); + }); +} diff --git a/tests/e2e/tests/commands/completion/completion-script.ts b/tests/e2e/tests/commands/completion/completion-script.ts new file mode 100644 index 000000000000..421763478950 --- /dev/null +++ b/tests/e2e/tests/commands/completion/completion-script.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { exec, execAndWaitForOutputToMatch } from '../../../utils/process'; + +export default async function () { + // ng build + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'b', ''], + /test-project/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'build', ''], + /test-project/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'build', '--a'], + /--aot/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'build', '--configuration'], + /production/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'b', '--configuration'], + /production/, + ); + + // ng run + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', ''], + /test-project\\:build\\:development/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', ''], + /test-project\\:build/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', ''], + /test-project\\:test/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', 'test-project:build'], + /test-project\\:build\\:development/, + ); + await execAndWaitForOutputToMatch( + 'ng', + ['--get-yargs-completions', 'ng', 'run', 'test-project:'], + /test-project\\:test/, + ); + + const { stdout: noServeStdout } = await exec( + 'ng', + '--get-yargs-completions', + 'ng', + 'run', + 'test-project:build', + ); + assert.doesNotMatch( + noServeStdout, + /:serve/, + `':serve' should not have been listed as a completion option.\nSTDOUT:\n${noServeStdout}`, + ); +} diff --git a/tests/e2e/tests/commands/completion/completion.ts b/tests/e2e/tests/commands/completion/completion.ts index b88821e8c3ab..5b81490c8400 100644 --- a/tests/e2e/tests/commands/completion/completion.ts +++ b/tests/e2e/tests/commands/completion/completion.ts @@ -1,9 +1,350 @@ -import {silentNg} from '../../../utils/process'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { getGlobalVariable } from '../../../utils/env'; +import { mockHome } from '../../../utils/utils'; +import { + execAndCaptureError, + execAndWaitForOutputToMatch, + execWithEnv, + silentNpm, +} from '../../../utils/process'; +const testRegistry = getGlobalVariable('package-registry'); -export default function() { - return Promise.resolve() - .then(() => silentNg('completion')) - .then(() => process.chdir('/')) - .then(() => silentNg('completion')); +const AUTOCOMPLETION_SNIPPET = ` +# Load Angular CLI autocompletion for interactive shells. +if [[ $- == *i* ]]; then + source <(ng completion script) +fi +`.trim(); + +export default async function () { + // Windows Cmd and Powershell do not support autocompletion. Run a different set of tests to + // confirm autocompletion fails gracefully. + if (process.platform === 'win32') { + await windowsTests(); + + return; + } + + // Generates new `.bashrc` file. + await mockHome(async (home) => { + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + }, + ); + + const rcContents = await fs.readFile(path.join(home, '.bashrc'), 'utf-8'); + if (!rcContents.includes(AUTOCOMPLETION_SNIPPET)) { + throw new Error(`~/.bashrc does not contain autocompletion script. Contents:\n${rcContents}`); + } + }); + + // Generates new `.zshrc` file. + await mockHome(async (home) => { + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }, + ); + + const rcContents = await fs.readFile(path.join(home, '.zshrc'), 'utf-8'); + if (!rcContents.includes(AUTOCOMPLETION_SNIPPET)) { + throw new Error(`~/.zshrc does not contain autocompletion script. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.bashrc` file. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + }, + ); + + const rcContents = await fs.readFile(bashrc, 'utf-8'); + const expected = `# Other commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (rcContents !== expected) { + throw new Error(`~/.bashrc does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.bash_profile` file. + await mockHome(async (home) => { + const bashProfile = path.join(home, '.bash_profile'); + await fs.writeFile(bashProfile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + }, + ); + + const rcContents = await fs.readFile(bashProfile, 'utf-8'); + const expected = `# Other commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (rcContents !== expected) { + throw new Error(`~/.bash_profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.profile` file (using Bash). + await mockHome(async (home) => { + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + }, + ); + + const rcContents = await fs.readFile(profile, 'utf-8'); + const expected = `# Other commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (rcContents !== expected) { + throw new Error(`~/.profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Bash shell prefers `.bashrc`. + await mockHome(async (home) => { + const bashrc = path.join(home, '.bashrc'); + await fs.writeFile(bashrc, '# `.bashrc` commands...'); + const bashProfile = path.join(home, '.bash_profile'); + await fs.writeFile(bashProfile, '# `.bash_profile` commands...'); + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# `.profile` commands...'); + + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/bin/bash', + }, + ); + + const bashrcContents = await fs.readFile(bashrc, 'utf-8'); + const bashrcExpected = `# \`.bashrc\` commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (bashrcContents !== bashrcExpected) { + throw new Error(`~/.bashrc does not match expectation. Contents:\n${bashrcContents}`); + } + const bashProfileContents = await fs.readFile(bashProfile, 'utf-8'); + if (bashProfileContents !== '# `.bash_profile` commands...') { + throw new Error( + `~/.bash_profile does not match expectation. Contents:\n${bashProfileContents}`, + ); + } + const profileContents = await fs.readFile(profile, 'utf-8'); + if (profileContents !== '# `.profile` commands...') { + throw new Error(`~/.profile does not match expectation. Contents:\n${profileContents}`); + } + }); + + // Appends to existing `.zshrc` file. + await mockHome(async (home) => { + const zshrc = path.join(home, '.zshrc'); + await fs.writeFile(zshrc, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }, + ); + + const rcContents = await fs.readFile(zshrc, 'utf-8'); + const expected = `# Other commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (rcContents !== expected) { + throw new Error(`~/.zshrc does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.zsh_profile` file. + await mockHome(async (home) => { + const zshProfile = path.join(home, '.zsh_profile'); + await fs.writeFile(zshProfile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }, + ); + + const rcContents = await fs.readFile(zshProfile, 'utf-8'); + const expected = `# Other commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (rcContents !== expected) { + throw new Error(`~/.zsh_profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Appends to existing `.profile` file (using Zsh). + await mockHome(async (home) => { + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# Other commands...'); + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }, + ); + + const rcContents = await fs.readFile(profile, 'utf-8'); + const expected = `# Other commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (rcContents !== expected) { + throw new Error(`~/.profile does not match expectation. Contents:\n${rcContents}`); + } + }); + + // Zsh prefers `.zshrc`. + await mockHome(async (home) => { + const zshrc = path.join(home, '.zshrc'); + await fs.writeFile(zshrc, '# `.zshrc` commands...'); + const zshProfile = path.join(home, '.zsh_profile'); + await fs.writeFile(zshProfile, '# `.zsh_profile` commands...'); + const profile = path.join(home, '.profile'); + await fs.writeFile(profile, '# `.profile` commands...'); + + await execAndWaitForOutputToMatch( + 'ng', + ['completion'], + /Appended `source <\(ng completion script\)`/, + { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }, + ); + + const zshrcContents = await fs.readFile(zshrc, 'utf-8'); + const zshrcExpected = `# \`.zshrc\` commands...\n\n${AUTOCOMPLETION_SNIPPET}\n`; + if (zshrcContents !== zshrcExpected) { + throw new Error(`~/.zshrc does not match expectation. Contents:\n${zshrcContents}`); + } + + const zshProfileContents = await fs.readFile(zshProfile, 'utf-8'); + if (zshProfileContents !== '# `.zsh_profile` commands...') { + throw new Error( + `~/.zsh_profile does not match expectation. Contents:\n${zshProfileContents}`, + ); + } + const profileContents = await fs.readFile(profile, 'utf-8'); + if (profileContents !== '# `.profile` commands...') { + throw new Error(`~/.profile does not match expectation. Contents:\n${profileContents}`); + } + }); + + // Fails for no `$HOME` directory. + { + const err = await execAndCaptureError('ng', ['completion'], { + ...process.env, + SHELL: '/bin/bash', + HOME: undefined, + }); + if (!err.message.includes('`$HOME` environment variable not set.')) { + throw new Error(`Expected unset \`$HOME\` error message, but got:\n\n${err.message}`); + } + } + + // Fails for no `$SHELL`. + await mockHome(async (home) => { + const err = await execAndCaptureError('ng', ['completion'], { + ...process.env, + SHELL: undefined, + }); + if (!err.message.includes('`$SHELL` environment variable not set.')) { + throw new Error(`Expected unset \`$SHELL\` error message, but got:\n\n${err.message}`); + } + }); + + // Fails for unknown `$SHELL`. + await mockHome(async (home) => { + const err = await execAndCaptureError('ng', ['completion'], { + ...process.env, + SHELL: '/usr/bin/unknown', + }); + if (!err.message.includes('Unknown `$SHELL` environment variable')) { + throw new Error(`Expected unknown \`$SHELL\` error message, but got:\n\n${err.message}`); + } + }); + + // Does *not* warn when a global CLI install is present on the system. + await mockHome(async (home) => { + const { stdout } = await execWithEnv('ng', ['completion'], { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }); + + if (stdout.includes('there does not seem to be a global install of the Angular CLI')) { + throw new Error(`CLI warned about missing global install, but one should exist.`); + } + }); + + // Warns when a global CLI install is *not* present on the system. + await mockHome(async (home) => { + try { + // Temporarily uninstall the global CLI binary from the system. + await silentNpm(['uninstall', '--global', '@angular/cli', `--registry=${testRegistry}`]); + + // Setup a fake project directory with a local install of the CLI. + const projectDir = path.join(home, 'project'); + await fs.mkdir(projectDir); + await silentNpm(['init', '-y', `--registry=${testRegistry}`], { cwd: projectDir }); + await silentNpm(['install', '@angular/cli', `--registry=${testRegistry}`], { + cwd: projectDir, + }); + + // Invoke the local CLI binary. + const localCliBinary = path.join(projectDir, 'node_modules', '.bin', 'ng'); + const { stdout } = await execWithEnv(localCliBinary, ['completion'], { + ...process.env, + 'SHELL': '/usr/bin/zsh', + }); + + if (stdout.includes('there does not seem to be a global install of the Angular CLI')) { + throw new Error(`CLI warned about missing global install, but one should exist.`); + } + } finally { + // Reinstall global CLI for remainder of the tests. + await silentNpm(['install', '--global', '@angular/cli', `--registry=${testRegistry}`]); + } + }); +} + +async function windowsTests(): Promise { + // Should fail with a clear error message. + const err = await execAndCaptureError('ng', ['completion']); + if (!err.message.includes("Cmd and Powershell don't support command autocompletion")) { + throw new Error( + `Expected Windows autocompletion to fail with custom error, but got:\n\n${err.message}`, + ); + } } diff --git a/tests/e2e/tests/commands/config/config-get.ts b/tests/e2e/tests/commands/config/config-get.ts new file mode 100644 index 000000000000..d64e0a630af0 --- /dev/null +++ b/tests/e2e/tests/commands/config/config-get.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await expectToFail(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle')); + await ng('config', 'schematics.@schematics/angular.component.inlineStyle', 'false'); + const { stdout } = await ng('config', 'schematics.@schematics/angular.component.inlineStyle'); + assert.match(stdout, /false\n?/); + + await ng('config', 'schematics.@schematics/angular.component.inlineStyle', 'true'); + const { stdout: stdout1 } = await ng( + 'config', + 'schematics.@schematics/angular.component.inlineStyle', + ); + assert.match(stdout1, /true\n?/); + + await ng('config', 'schematics.@schematics/angular.component.inlineStyle', 'false'); + const { stdout: stdout2 } = await ng( + 'config', + `projects.test-project.architect.build.options.assets[0]`, + ); + assert.ok(stdout2.includes('"input": "public"')); + + const { stdout: stdout3 } = await ng( + 'config', + `projects["test-project"].architect.build.options.assets[0]`, + ); + assert.ok(stdout3.includes('"input": "public"')); + + // should print all config when no positional args are provided. + const { stdout: stdout4 } = await ng('config'); + assert.ok(stdout4.includes('$schema')); +} diff --git a/tests/e2e/tests/commands/config/config-global-validation.ts b/tests/e2e/tests/commands/config/config-global-validation.ts new file mode 100644 index 000000000000..39fa611075a9 --- /dev/null +++ b/tests/e2e/tests/commands/config/config-global-validation.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { homedir } from 'node:os'; +import * as path from 'node:path'; +import { deleteFile, expectFileToExist } from '../../../utils/fs'; +import { ng, silentNg } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + let ngError: Error; + + ngError = await expectToFail(() => silentNg('config', 'cli.completion.prompted', 'true')); + assert.match(ngError.message, /Unknown option "completion" at "\/cli"\./); + + ngError = await expectToFail(() => + silentNg('config', '--global', 'cli.completion.invalid', 'true'), + ); + assert.match(ngError.message, /Unknown option "invalid" at "\/cli\/completion"\./); + + ngError = await expectToFail(() => silentNg('config', '--global', 'cli.cache.enabled', 'true')); + assert.match(ngError.message, /Unknown option "cache" at "\/cli"\./); + + ngError = await expectToFail(() => silentNg('config', 'cli.completion.prompted')); + assert.match(ngError.message, /Value cannot be found\./); + + await ng('config', '--global', 'cli.completion.prompted', 'true'); + const { stdout } = await silentNg('config', '--global', 'cli.completion.prompted'); + assert.match(stdout, /true/); + + await expectFileToExist(path.join(homedir(), '.angular-config.json')); + await deleteFile(path.join(homedir(), '.angular-config.json')); +} diff --git a/tests/e2e/tests/commands/config/config-global.ts b/tests/e2e/tests/commands/config/config-global.ts new file mode 100644 index 000000000000..030d4b583d2c --- /dev/null +++ b/tests/e2e/tests/commands/config/config-global.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { homedir } from 'node:os'; +import * as path from 'node:path'; +import { deleteFile, expectFileToExist } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await expectToFail(() => + ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle'), + ); + + await ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle', 'false'); + let output = await ng( + 'config', + '--global', + 'schematics.@schematics/angular.component.inlineStyle', + ); + assert.match(output.stdout, /false\n?/); + + // This test requires schema querying capabilities + // .then(() => expectToFail(() => { + // return ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle', 'INVALID_BOOLEAN'); + // })) + + const cwd = process.cwd(); + process.chdir('/'); + try { + await ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle', 'true'); + } finally { + process.chdir(cwd); + } + + output = await ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle'); + assert.match(output.stdout, /true\n?/); + + await expectToFail(() => ng('config', '--global', 'cli.warnings.notreal', 'true')); + + await ng('config', '--global', 'cli.warnings.versionMismatch', 'false'); + await expectFileToExist(path.join(homedir(), '.angular-config.json')); + await deleteFile(path.join(homedir(), '.angular-config.json')); +} diff --git a/tests/e2e/tests/commands/config/config-set-enum-check.ts b/tests/e2e/tests/commands/config/config-set-enum-check.ts new file mode 100644 index 000000000000..571dc74cdd14 --- /dev/null +++ b/tests/e2e/tests/commands/config/config-set-enum-check.ts @@ -0,0 +1,15 @@ +import { ng } from '../../../utils/process'; + +export default async function () { + // These tests require schema querying capabilities + // .then(() => expectToFail( + // () => ng('config', 'schematics.@schematics/angular.component.aaa', 'bbb')), + // ) + // .then(() => expectToFail(() => ng( + // 'config', + // 'schematics.@schematics/angular.component.viewEncapsulation', + // 'bbb', + // ))) + + await ng('config', 'schematics.@schematics/angular.component.viewEncapsulation', 'Emulated'); +} diff --git a/tests/e2e/tests/commands/config/config-set-prefix.ts b/tests/e2e/tests/commands/config/config-set-prefix.ts new file mode 100644 index 000000000000..40f22699baed --- /dev/null +++ b/tests/e2e/tests/commands/config/config-set-prefix.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { ng } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default function () { + return Promise.resolve() + .then(() => expectToFail(() => ng('config', 'schematics.@schematics/angular.component.prefix'))) + .then(() => ng('config', 'schematics.@schematics/angular.component.prefix', 'new-prefix')) + .then(() => ng('config', 'schematics.@schematics/angular.component.prefix')) + .then(({ stdout }) => { + assert.match(stdout, /new-prefix/); + }); +} diff --git a/tests/e2e/tests/commands/config/config-set-serve-port.ts b/tests/e2e/tests/commands/config/config-set-serve-port.ts new file mode 100644 index 000000000000..125d21d7e66f --- /dev/null +++ b/tests/e2e/tests/commands/config/config-set-serve-port.ts @@ -0,0 +1,8 @@ +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; + +export default function () { + return Promise.resolve() + .then(() => ng('config', 'projects.test-project.architect.serve.options.port', '1234')) + .then(() => expectFileToMatch('angular.json', /"port": 1234/)); +} diff --git a/tests/e2e/tests/commands/config/config-set.ts b/tests/e2e/tests/commands/config/config-set.ts new file mode 100644 index 000000000000..05fa364bcb37 --- /dev/null +++ b/tests/e2e/tests/commands/config/config-set.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { ng, silentNg } from '../../../utils/process'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + let ngError: Error; + + ngError = await expectToFail(() => silentNg('config', 'cli.warnings.zzzz', 'true')); + assert.match(ngError.message, /Unknown option "zzzz" at "\/cli\/warnings"\./); + + ngError = await expectToFail(() => silentNg('config', 'cli.warnings.zzzz')); + assert.match(ngError.message, /Value cannot be found\./); + + await ng('config', 'cli.warnings.versionMismatch', 'false'); + const { stdout } = await ng('config', 'cli.warnings.versionMismatch'); + assert.match(stdout, /false/); + + await ng('config', 'cli.packageManager', 'yarn'); + const { stdout: stdout2 } = await ng('config', 'cli.packageManager'); + assert.match(stdout2, /yarn/); + + await ng('config', 'schematics', '{"@schematics/angular:component":{"style": "scss"}}'); + const { stdout: stdout3 } = await ng('config', 'schematics.@schematics/angular:component.style'); + assert.match(stdout3, /scss/); + + await ng('config', 'schematics'); + await ng('config', 'schematics', 'undefined'); + await expectToFail(() => ng('config', 'schematics')); +} diff --git a/tests/e2e/tests/commands/config/get.ts b/tests/e2e/tests/commands/config/get.ts deleted file mode 100644 index e866c561040f..000000000000 --- a/tests/e2e/tests/commands/config/get.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {ng, silentNg} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => process.chdir('/')) - .then(() => expectToFail(() => ng('get', 'defaults.component.inlineStyle'))) - .then(() => ng('get', '--global', 'defaults.component.inlineStyle')) - .then(({ stdout }) => { - if (!stdout.match(/false\n?/)) { - throw new Error(`Expected "false", received "${JSON.stringify(stdout)}".`); - } - }) - .then(() => expectToFail(() => { - return ng('set', '--global', 'defaults.component.inlineStyle', 'INVALID_BOOLEAN'); - })) - .then(() => ng('set', '--global', 'defaults.component.inlineStyle', 'true')) - .then(() => ng('get', '--global', 'defaults.component.inlineStyle')) - .then(({ stdout }) => { - if (!stdout.match(/true\n?/)) { - throw new Error(`Expected "true", received "${JSON.stringify(stdout)}".`); - } - }) - .then(() => ng('set', '--global', 'defaults.component.inlineStyle', 'false')); -} diff --git a/tests/e2e/tests/commands/eject/eject.ts b/tests/e2e/tests/commands/eject/eject.ts deleted file mode 100644 index 0205f675cf85..000000000000 --- a/tests/e2e/tests/commands/eject/eject.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as path from 'path'; - -import {ng, silentNpm, exec} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; -import {expectGitToBeClean} from '../../../utils/git'; - - -export default function() { - return ng('eject') - .then(() => expectToFail(() => ng('build'))) - .then(() => expectToFail(() => ng('test'))) - .then(() => expectToFail(() => ng('e2e'))) - .then(() => expectToFail(() => ng('serve'))) - .then(() => expectToFail(() => expectGitToBeClean())) - .then(() => silentNpm('install')) - .then(() => exec(path.join('node_modules', '.bin', 'webpack'))); -} diff --git a/tests/e2e/tests/commands/get/get.ts b/tests/e2e/tests/commands/get/get.ts deleted file mode 100644 index 34c70b12f197..000000000000 --- a/tests/e2e/tests/commands/get/get.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('get', 'apps.zzz.prefix'))) - .then(() => ng('get')) - .then(() => ng('get', 'apps.0.prefix')) - .then(({ stdout }) => { - if (!stdout.match(/app/)) { - throw new Error(`Expected "app", received "${JSON.stringify(stdout)}".`); - } - }); -} diff --git a/tests/e2e/tests/commands/help/help-hidden.ts b/tests/e2e/tests/commands/help/help-hidden.ts index 5f88a787257c..bd972d26a942 100644 --- a/tests/e2e/tests/commands/help/help-hidden.ts +++ b/tests/e2e/tests/commands/help/help-hidden.ts @@ -1,26 +1,14 @@ -import { oneLine } from 'common-tags'; - +import assert from 'node:assert/strict'; import { silentNg } from '../../../utils/process'; +export default async function () { + const { stdout: stdoutNew } = await silentNg('--help'); + assert.doesNotMatch( + stdoutNew, + /(easter-egg)|(ng make-this-awesome)|(ng init)/, + 'Expected to not match "(easter-egg)|(ng make-this-awesome)|(ng init)" in help output.', + ); -export default function() { - return Promise.resolve() - .then(() => silentNg('--help')) - .then(({ stdout }) => { - if (stdout.match(/(easter-egg)|(ng make-this-awesome)|(ng init)/)) { - throw new Error(oneLine` - Expected to not match "(easter-egg)|(ng make-this-awesome)|(ng init)" - in help output. - `); - } - }) - .then(() => silentNg('--help', 'new')) - .then(({ stdout }) => { - if (stdout.match(/--link-cli/)) { - throw new Error(oneLine` - Expected to not match "--link-cli" - in help output. - `); - } - }) + const { stdout: ngGenerate } = await silentNg('--help', 'generate', 'component'); + assert.doesNotMatch(ngGenerate, /--path/, 'Expected to not match "--path" in help output.'); } diff --git a/tests/e2e/tests/commands/help/help-json.ts b/tests/e2e/tests/commands/help/help-json.ts new file mode 100644 index 000000000000..6f1b8e89db49 --- /dev/null +++ b/tests/e2e/tests/commands/help/help-json.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { silentNg } from '../../../utils/process'; + +export default async function () { + // This test is use as a sanity check. + const addHelpOutputSnapshot = JSON.stringify({ + 'name': 'config', + 'command': 'ng config [json-path] [value]', + 'shortDescription': + 'Retrieves or sets Angular configuration values in the angular.json file for the workspace.', + 'longDescriptionRelativePath': '@angular/cli/src/commands/config/long-description.md', + 'longDescription': + 'A workspace has a single CLI configuration file, `angular.json`, at the top level.\nThe `projects` object contains a configuration object for each project in the workspace.\n\nYou can edit the configuration directly in a code editor,\nor indirectly on the command line using this command.\n\nThe configurable property names match command option names,\nexcept that in the configuration file, all names must use camelCase,\nwhile on the command line options can be given dash-case.\n\nFor further details, see [Workspace Configuration](reference/configs/workspace-config).\n\nFor configuration of CLI usage analytics, see [ng analytics](cli/analytics).\n', + 'options': [ + { + 'name': 'global', + 'type': 'boolean', + 'aliases': ['g'], + 'default': false, + 'description': "Access the global configuration in the caller's home directory.", + }, + { + 'name': 'help', + 'type': 'boolean', + 'description': 'Shows a help message for this command in the console.', + }, + { + 'name': 'json-path', + 'type': 'string', + 'description': + 'The configuration key to set or query, in JSON path format. For example: "a[3].foo.bar[2]". If no new value is provided, returns the current value of this key.', + 'positional': 0, + }, + { + 'name': 'value', + 'type': 'string', + 'description': 'If provided, a new value for the given configuration key.', + 'positional': 1, + }, + ], + }); + + const { stdout } = await silentNg('config', '--help', '--json-help'); + const output = JSON.stringify(JSON.parse(stdout.trim())); + + assert.strictEqual( + output, + addHelpOutputSnapshot, + `ng config JSON help output didn\'t match snapshot.`, + ); + + const { stdout: stdout2 } = await silentNg('--help', '--json-help'); + assert.doesNotThrow( + () => JSON.parse(stdout2.trim()), + `'ng --help ---json-help' failed to return JSON.`, + ); + + const { stdout: stdout3 } = await silentNg('generate', '--help', '--json-help'); + assert.doesNotThrow( + () => JSON.parse(stdout3.trim()), + `'ng generate --help ---json-help' failed to return JSON.`, + ); +} diff --git a/tests/e2e/tests/commands/help/help-option-command.ts b/tests/e2e/tests/commands/help/help-option-command.ts deleted file mode 100644 index 73537f7ed8e2..000000000000 --- a/tests/e2e/tests/commands/help/help-option-command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help', 'build')) - .then(() => process.chdir('/')) - .then(() => silentNg('--help', 'build')); -} diff --git a/tests/e2e/tests/commands/help/help-option.ts b/tests/e2e/tests/commands/help/help-option.ts deleted file mode 100644 index 03b96b5758d9..000000000000 --- a/tests/e2e/tests/commands/help/help-option.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help')) - .then(() => process.chdir('/')) - .then(() => silentNg('--help')); -} diff --git a/tests/e2e/tests/commands/help/help.ts b/tests/e2e/tests/commands/help/help.ts deleted file mode 100644 index f326f6a81ff8..000000000000 --- a/tests/e2e/tests/commands/help/help.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('help')) - .then(() => process.chdir('/')) - .then(() => silentNg('help')); -} diff --git a/tests/e2e/tests/commands/new/check-yarn.ts b/tests/e2e/tests/commands/new/check-yarn.ts deleted file mode 100644 index 22826ee17926..000000000000 --- a/tests/e2e/tests/commands/new/check-yarn.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ng} from '../../../utils/process'; -import {getGlobalVariable} from '../../../utils/env'; - -const yarnRegEx = /You can `ng set --global packageManager=yarn`./; - -export default function() { - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => ng('set', '--global', 'packageManager=default')) - .then(() => ng('new', 'foo')) - .then(({ stdout }) => { - // Assuming yarn is installed and checking for message with yarn. - if (!stdout.match(yarnRegEx)) { - throw new Error('Should display message to use yarn packageManager'); - } - }); -} diff --git a/tests/e2e/tests/commands/new/existing-directory.ts b/tests/e2e/tests/commands/new/existing-directory.ts deleted file mode 100644 index 8563040822c8..000000000000 --- a/tests/e2e/tests/commands/new/existing-directory.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as fs from 'fs'; -import denodeify = require('denodeify'); - -import {ng} from '../../../utils/process'; -import {getGlobalVariable} from '../../../utils/env'; - -const mkdir = denodeify(fs.mkdir); - - -export default function() { - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => mkdir('empty-directory')) - .then(() => ng('new', 'foo', '--dir=empty-directory', '--skip-install', '--skip-git')); -} diff --git a/tests/e2e/tests/commands/new/new-minimal.ts b/tests/e2e/tests/commands/new/new-minimal.ts deleted file mode 100644 index d3b8c2e5e327..000000000000 --- a/tests/e2e/tests/commands/new/new-minimal.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import {expectFileNotToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => createProject('minimal-project', '--minimal')) - .then(() => expectFileNotToExist('.editorconfig')) - .then(() => expectFileNotToExist('README.md')) - .then(() => expectFileNotToExist('karma.conf.js')) - .then(() => expectFileNotToExist('protractor.conf.js')) - .then(() => expectFileNotToExist('src/test.ts')) - .then(() => expectFileNotToExist('src/tsconfig.spec.json')) - .then(() => expectFileNotToExist('tslint.json')) - .then(() => expectFileNotToExist('src/app/app.component.html')) - .then(() => expectFileNotToExist('src/app/app.component.css')) - .then(() => expectFileNotToExist('src/app/app.component.spec.ts')) - .then(() => expectFileNotToExist('src/app/favicon.ico')) - - .then(() => expectToFail(() => expectFileToMatch('package.json', '"protractor":'))) - .then(() => expectToFail(() => expectFileToMatch('package.json', '"karma":'))) - .then(() => expectToFail(() => expectFileToMatch('package.json', '"jasmine-core":'))) - - // Try to run a build. - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/commands/new/new-routing.ts b/tests/e2e/tests/commands/new/new-routing.ts deleted file mode 100644 index 953aa16190cc..000000000000 --- a/tests/e2e/tests/commands/new/new-routing.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import { getGlobalVariable } from '../../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => createProject('routing-project', '--routing')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/commands/new/new-skip-commit.ts b/tests/e2e/tests/commands/new/new-skip-commit.ts deleted file mode 100644 index c57e34b67f48..000000000000 --- a/tests/e2e/tests/commands/new/new-skip-commit.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import {expectToFail} from '../../../utils/utils'; -import {expectGitToBeClean} from '../../../utils/git'; -import {getGlobalVariable} from '../../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => createProject('new-project', '--skip-commit')) - .then(() => expectToFail(() => expectGitToBeClean())) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/commands/new/new-skip-tests.ts b/tests/e2e/tests/commands/new/new-skip-tests.ts deleted file mode 100644 index 62c5328b8575..000000000000 --- a/tests/e2e/tests/commands/new/new-skip-tests.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {createProject} from '../../../utils/project'; -import {expectFileNotToExist} from '../../../utils/fs'; -import {getGlobalVariable} from '../../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => createProject('new-project-skip-tests', '--skip-tests')) - .then(() => expectFileNotToExist('src/app/app.component.spec.ts')); -} diff --git a/tests/e2e/tests/commands/new/new-style.ts b/tests/e2e/tests/commands/new/new-style.ts deleted file mode 100644 index 882bab3a88e5..000000000000 --- a/tests/e2e/tests/commands/new/new-style.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import {expectFileToExist} from '../../../utils/fs'; - - -export default function() { - return Promise.resolve() - .then(() => ng('set', 'defaults.styleExt', 'scss', '--global')) - .then(() => createProject('style-project')) - .then(() => expectFileToExist('src/app/app.component.scss')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/commands/ng-new-collection.ts b/tests/e2e/tests/commands/ng-new-collection.ts new file mode 100644 index 000000000000..e55c75ee69b4 --- /dev/null +++ b/tests/e2e/tests/commands/ng-new-collection.ts @@ -0,0 +1,19 @@ +import { execAndWaitForOutputToMatch } from '../../utils/process'; + +export default async function () { + const currentDirectory = process.cwd(); + + try { + process.chdir('..'); + + // The below is a way to validate that the `--collection` option is being considered. + await execAndWaitForOutputToMatch( + 'ng', + ['new', '--collection', 'invalid-schematic'], + /Collection "invalid-schematic" cannot be resolved/, + ); + } finally { + // Change directory back + process.chdir(currentDirectory); + } +} diff --git a/tests/e2e/tests/commands/project-cannot-be-determined-by-cwd.ts b/tests/e2e/tests/commands/project-cannot-be-determined-by-cwd.ts new file mode 100644 index 000000000000..b4b572bfa3ee --- /dev/null +++ b/tests/e2e/tests/commands/project-cannot-be-determined-by-cwd.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const errorMessage = + 'Cannot determine project for command.\n' + + 'This is a multi-project workspace and more than one project supports this command.'; + + // Delete root project + await updateJsonFile('angular.json', (workspaceJson) => { + delete workspaceJson.projects['test-project']; + }); + + await ng('generate', 'app', 'second-app', '--skip-install'); + await ng('generate', 'app', 'third-app', '--skip-install'); + + const startCwd = process.cwd(); + + try { + const { message } = await expectToFail(() => ng('build')); + assert.match(message, new RegExp(errorMessage)); + + // Help should still work + await execAndWaitForOutputToMatch('ng', ['build', '--help'], /--configuration/); + + // Yargs allows positional args to be passed as flags. Verify that in this case the project can be determined. + await ng('build', '--project=third-app', '--configuration=development'); + + process.chdir(join(startCwd, 'projects/second-app')); + await ng('build', '--configuration=development'); + } finally { + // Restore path + process.chdir(startCwd); + } +} diff --git a/tests/e2e/tests/commands/run-configuration-option.ts b/tests/e2e/tests/commands/run-configuration-option.ts new file mode 100644 index 000000000000..ab2761dce531 --- /dev/null +++ b/tests/e2e/tests/commands/run-configuration-option.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { silentNg } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const errorMatch = `Provide the configuration as part of the target 'ng run test-project:build:production`; + + { + const { message } = await expectToFail(() => + silentNg('run', 'test-project:build:development', '--configuration=production'), + ); + + assert.ok(message.includes(errorMatch)); + } + + { + const { message } = await expectToFail(() => + silentNg('run', 'test-project:build', '--configuration=production'), + ); + + assert.ok(message.includes(errorMatch)); + } +} diff --git a/tests/e2e/tests/commands/serve/assets.ts b/tests/e2e/tests/commands/serve/assets.ts new file mode 100644 index 000000000000..dba1abea37cb --- /dev/null +++ b/tests/e2e/tests/commands/serve/assets.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert'; +import { ngServe } from '../../../utils/project'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + const port = await ngServe(); + + let response = await fetch(`http://localhost:${port}/favicon.ico`); + assert.strictEqual(response.status, 200, 'favicon.ico response should be ok'); + + // A non-existent HTML file request with accept header should fallback to the index HTML + response = await fetch(`http://localhost:${port}/does-not-exist.html`, { + headers: { accept: 'text/html' }, + }); + assert.strictEqual(response.status, 200, 'non-existent file response should fallback and be ok'); + assert.match( + await response.text(), + / { + const result = await fetch(url, { method: 'HEAD' }); + const content = await result.blob(); + + assert.strictEqual(content.size, 0, `Expected "size" to be "0" but got "${content.size}".`); + assert.strictEqual( + result.status, + 200, + `Expected "status" to be "200" but got "${result.status}".`, + ); +} diff --git a/tests/e2e/tests/commands/serve/preflight-request.ts b/tests/e2e/tests/commands/serve/preflight-request.ts new file mode 100644 index 000000000000..02a763dce197 --- /dev/null +++ b/tests/e2e/tests/commands/serve/preflight-request.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { ngServe } from '../../../utils/project'; + +export default async function () { + const port = await ngServe(); + const result = await fetch(`http://localhost:${port}/main.js`, { method: 'OPTIONS' }); + const content = await result.blob(); + + assert.strictEqual(content.size, 0, `Expected "size" to be "0" but got "${content.size}".`); + assert.strictEqual( + result.status, + 204, + `Expected "status" to be "204" but got "${result.status}".`, + ); +} diff --git a/tests/e2e/tests/commands/serve/reload-shims.ts b/tests/e2e/tests/commands/serve/reload-shims.ts new file mode 100644 index 000000000000..345979da5cc2 --- /dev/null +++ b/tests/e2e/tests/commands/serve/reload-shims.ts @@ -0,0 +1,21 @@ +import { prependToFile, writeFile } from '../../../utils/fs'; +import { execAndWaitForOutputToMatch } from '../../../utils/process'; + +export default async function () { + // Simulate a JS library using a Node.js specific module + await writeFile('src/node-usage.js', `const path = require('path');\n`); + await prependToFile('src/main.ts', `import './node-usage';\n`); + + // Make sure serve is consistent with build + await execAndWaitForOutputToMatch( + 'ng', + ['build'], + /Module not found: Error: Can't resolve 'path'/, + ); + // The Node.js specific module should not be found + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port=0'], + /Module not found: Error: Can't resolve 'path'/, + ); +} diff --git a/tests/e2e/tests/commands/serve/serve-path.ts b/tests/e2e/tests/commands/serve/serve-path.ts index 3a58e9cca5f6..397a6c2f973f 100644 --- a/tests/e2e/tests/commands/serve/serve-path.ts +++ b/tests/e2e/tests/commands/serve/serve-path.ts @@ -1,29 +1,20 @@ -import { request } from '../../../utils/http'; -import { killAllProcesses } from '../../../utils/process'; +import * as assert from 'node:assert'; import { ngServe } from '../../../utils/project'; -export default function () { +export default async function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. + + const port = await ngServe('--serve-path', 'test/'); + return Promise.resolve() - .then(() => ngServe('--serve-path', 'test/')) - .then(() => request('http://localhost:4200/test')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => request('http://localhost:4200/test/abc')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--base-href', 'test/')) - .then(() => request('http://localhost:4200/test')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } + .then(() => fetch(`http://localhost:${port}/test`, { headers: { 'Accept': 'text/html' } })) + .then(async (response) => { + assert.strictEqual(response.status, 200); + assert.match(await response.text(), /<\/app-root>/); }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); + .then(() => fetch(`http://localhost:${port}/test/abc`, { headers: { 'Accept': 'text/html' } })) + .then(async (response) => { + assert.strictEqual(response.status, 200); + assert.match(await response.text(), /<\/app-root>/); + }); } diff --git a/tests/e2e/tests/commands/serve/ssr-http-requests-assets.ts b/tests/e2e/tests/commands/serve/ssr-http-requests-assets.ts new file mode 100644 index 000000000000..1e8e7853a718 --- /dev/null +++ b/tests/e2e/tests/commands/serve/ssr-http-requests-assets.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert'; +import { Agent, fetch } from 'undici'; +import { killAllProcesses, ng } from '../../../utils/process'; +import { writeMultipleFiles } from '../../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ngServe, useSha } from '../../../utils/project'; + +export default async function () { + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Add http client and route + 'src/app/app.config.ts': ` + import { ApplicationConfig } from '@angular/core'; + import { provideRouter } from '@angular/router'; + + import { Home } from './home/home'; + import { provideClientHydration } from '@angular/platform-browser'; + import { provideHttpClient, withFetch } from '@angular/common/http'; + + export const appConfig: ApplicationConfig = { + providers: [ + provideRouter([{ + path: '', + component: Home, + }]), + provideClientHydration(), + provideHttpClient(withFetch()), + ], + }; + `, + // Add asset + 'public/media.json': JSON.stringify({ dataFromAssets: true }), + // Update component to do an HTTP call to asset. + 'src/app/app.ts': ` + import { ChangeDetectorRef, Component, inject } from '@angular/core'; + import { CommonModule } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + import { HttpClient } from '@angular/common/http'; + + @Component({ + selector: 'app-root', + imports: [CommonModule, RouterOutlet], + template: \` +

{{ data | json }}

+ + \`, + }) + export class App { + data: any; + private readonly cdr: ChangeDetectorRef = inject(ChangeDetectorRef); + + constructor() { + const http = inject(HttpClient); + http.get('/media.json').toPromise().then((d) => { + this.data = d; + this.cdr.markForCheck(); + }); + } + } + `, + }); + + await ng('generate', 'component', 'home'); + const match = /

{[\S\s]*"dataFromAssets":[\s\S]*true[\S\s]*}<\/p>/; + const port = await ngServe('--no-ssl'); + assert.match(await (await fetch(`http://localhost:${port}/`)).text(), match); + + await killAllProcesses(); + + const sslPort = await ngServe('--ssl'); + assert.match( + await ( + await fetch(`https://localhost:${sslPort}/`, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false, + }, + }), + }) + ).text(), + match, + ); + + await killAllProcesses(); + + // With OpenSSl cert+key + writeMultipleFiles({ + 'server.key': `-----BEGIN PRIVATE KEY----- +MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDOyBmVy61zEqfs +oTPQ9gTX233/nlrVXtaUGJkbDR5actq0X+XQtZuIoO4JgRpiYz5/8XiY8AiaMdt3 +0abugO5AhyIbsGyQxvz2si7yKQ+WUdF/DRTpfTq76E8EXR8W9BI+DTpG/1nNGBd1 +lpMa8NMfHqLvhtpebHuBcb1BCRely+FILHVDGf+dfCIvR0Zvt8Ah3qLL6vvX3pzF +qM9XrXfUgWKqpbz+L1BeCPILsH+UaOOCzzbyrLoY6fnawjUkc/ieHWycro7dBUlu +JJ/kcGtpPYD/hsMFcXz6D67qbIFuc6Dz9CrWIagAFMqK91FAtUpyfP7+jLikQOST +pFDLgJ+klADGCiZ6C/dvjUsYM+4ML9dX6Q9rj6sQo/oj0Dsdj39J5mVmklkbRP5q +heMGTyc09/ambiYFfzWEMMnEcCT/CS/r1q93oRDG02Cx6F1B05mtR+/AFxAJ6UxL +2u3oMPVY179FWjx2+YvbfrdNrFnWb8eRMiRZIW8O8ptKkrh+LL1Rhep+W/1Nxdrp +g7E2rWP8AWr3mdd+cnauvF/2yMecBDLVnk3OOSjcuLc+i9ngOD0xHdcRfO89mryj +IewEIrUQ4U0ZgyHMi99qV4wyXhd9HzTUgT01QofsiuF9xyVfnansQOj3oqOgCS92 +VEeqZnLXgaVoh/++/FV7r4C5zxLzLwIDAQABAoICAAeKSqD98iE3o5qc6AAiqj79 +r8L2dJ+0F9cDF4Bh6aLFYBGUoS/Sr38Cm7m0/3qiiEKvbpM9/0QVfHLRoBNcJnBk +0mrp1yD1tfEOUPcJ12D/3XJ2zlIv+7oUn97Ia9h4NCzBv5zw7lTsrjHenDMSZ7XD +PR6qb064XfiRETKFeCJk64Godj/3QkmX2FApCMDwXJttynLQseK5RZnDHojhuDuR +vgfC+aOCTit8GOkxi1Hdppxm8tmMwfqyJmAJh5IdKkNA3MHtbyPCxSXRRIUdwMXT +bhhVCh9/W3prv/vEYSPfRGs9WdtrTBj/U8GlgGlxa87h1i/i8N4I5RP+8lic6zVL +BIIPamkRFRNUmV7ZzpWsrLl1TUUcQJ1UsjNqaLD7jl+l0IaUta8I9crJQWIuQu+G +5C0XJQPZrqGkZfLSMvi08S+8myCzf+3P3ayUHAKz4Q1pTeM2BbHQi1HbT+WUsA5G +DD4xBwc8VJXOy0dB4v4e5eK8aZaJZroR5LJT7bvKw1MNpyAt6w2Z17eSSuEE0x6u +4uzOfHRaWiKH9gXVSKyo8xM08wiKAJIpDg4fDsu/XPjfPzV3eSHwin6ADw7rcOrW +j4Ca43Ts7Fz0Y40dtUyrrQ3f7WSQ9C+M88NuI9WYPWmXqPQY9+b5Au0Q8rq1j3dW +1YB3vYd6ElaLI6k7c5OhAoIBAQDt+Dgi2jrx2Xol0Per/cIFyG/hX/h+tavj++xl +gIMLLwhFmBVIkkXHjG5v5rZFCY7giQgdy+JHAIDUg3Ae3K7zSYidkMwQzLJ9udaT +nJEybY4RlEJZVBs58pkjevqTD/pZ+Kj09/VLAJIhOInFQHQ+ZVn4uHF+NO4tcsH+ +Wtsyyf8tFMkoNQ38o2oTnJtsotssKGdXCgi36BCCCUQk98113RK9dBTi+2iB59qr +WczAb6Jl5cs1j/2IC3z9KilZ3/ww4Bshs5LThIGR66KZIfApzf8XQzHM9mhiLgRU +thUZ0a/ougqf4FovLAezsNM7kYqbPDPOh/CayN5KZ8pHNLt1AoIBAQDecvFejv3u +Lm9kf2xRv06HTsLeEUSgRVoWdKidwW3tXOkl8vuBTzeFl9yrgtMBbSgcFASbEKPP +uPc6g+zkcakUB+FLGGNwNFKhdGPUMI7u8i9WeWH+e3Aios7n0tCPP0Xv6d1Lhcyw +X1nz07hZ+sT40nLGyf/6vfg8LFGSBrr3YQLseodKGTC9jc5yJqEX16cqHppkwaJT +Elsona7PZGFm/WFGWn4wZiPpd9P5lnxP+KrI+m84z4Gw5txcJsE8WiUrrQYHG3+2 +yeztwYl+JGHcspsU4WTPCupyVRHt0uuGVN+UhLKgER8wghc6fL08jGkHgVLrStnN +ekRA0gEZRzOTAoIBAGuQMheW2uPssGidfwXP6r5gbinKDnF/vpWLjrwGjbUlajDC +4IPwEfhzwot0Flk4S8u0ROXq/XmogZMNYkWg7LdtOoI2K/c//0ITGSmZsIvBt2C8 +ygzElpXn0U6XTOHia//1BLHNzqM7O9ImUyfEzYZSm4twG2S3mh0S7RsCiGf5pA0F +gzNYX90dJFp/BEXjivv3u1Y9Y9l03NlaROIM3GL1LX5TFQnQJ9noKhAfxAwLqbUz +XFn2ntu6jaGFSDGmq8CP29Os7qYLE+IYR2O+UmcjBLXIGp+RlXcjY7PCpeEIxeGF +Dj5b04fU+BpByAj57VPjr2sgSSI9vzSUm3r6G+0CggEBALK7JgZ028BxHN1hqHWy +QXVkKhxlQX+I2Y5rY0OFtD5gRZBRQBUwwgqb7xj7P3DI9M5Co0S4RPZUxogEkeUn +EdPfVPySdusjjzTcoI1QCrggbTqMwtjG811Q9O+9Kge+rgHLJRxWQBWCN3M6rMfX +PkYySThB+2PLGVW3wj6TG8xB7Sh2dpdp0AitlK+RLCRNCKpF9oV4M2WNvSLQNzG5 +lK08btkpQnS+zKH8vpuudumGgiqDVbQOvkSV6X49QUutnmoOVmaFiMMkUTLjKwbo +Up0SAJrxUp8sRR1iDsrIiqbfMNlTGXaU6zt9ew5qRV4N7yGxnh8hgAih8Y8nbOyT +kfMCggEBAMVOGy7yzaMQvVSkcFkUAnznI7RwvhUWusomfyaVgHm+j3Y7OPius1ah +4Da3kvb4t8OFUIdnMz/rte8xRKE6lKLNmXG9+8nPkCdrB9ovDr0Pw3ONZ7kKHuhm +75IKV72f3krZK5jJ88p/ruUUotButZb+WlGW5qQOJEJnHi65ABGYchAADAOBflXK +XbklHb6sVmEx6Ds4OMAbEmgH4C7BZuvmVeYMY7ihGIuBF3rE70rc2meQl/fxn0Gd ++/FrHDqCSkXwNT69HEOoLT/hi6Pc3kyn1bFOK+W8AydilI+6yOKkiYTSoCAO/yi/ +xlFXnn9FIQthAEWUhFgqApO+oKBn0hw= +-----END PRIVATE KEY----- +`, + 'server.crt': `-----BEGIN CERTIFICATE----- +MIIFCTCCAvGgAwIBAgIUd0CiuFYYUTnnfB/Q6lijpEZJy4wwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MTEwNzEwMTE0NFoXDTI2MTEw +NzEwMTE0NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF +AAOCAg8AMIICCgKCAgEAzsgZlcutcxKn7KEz0PYE19t9/55a1V7WlBiZGw0eWnLa +tF/l0LWbiKDuCYEaYmM+f/F4mPAImjHbd9Gm7oDuQIciG7BskMb89rIu8ikPllHR +fw0U6X06u+hPBF0fFvQSPg06Rv9ZzRgXdZaTGvDTHx6i74baXmx7gXG9QQkXpcvh +SCx1Qxn/nXwiL0dGb7fAId6iy+r7196cxajPV6131IFiqqW8/i9QXgjyC7B/lGjj +gs828qy6GOn52sI1JHP4nh1snK6O3QVJbiSf5HBraT2A/4bDBXF8+g+u6myBbnOg +8/Qq1iGoABTKivdRQLVKcnz+/oy4pEDkk6RQy4CfpJQAxgomegv3b41LGDPuDC/X +V+kPa4+rEKP6I9A7HY9/SeZlZpJZG0T+aoXjBk8nNPf2pm4mBX81hDDJxHAk/wkv +69avd6EQxtNgsehdQdOZrUfvwBcQCelMS9rt6DD1WNe/RVo8dvmL2363TaxZ1m/H +kTIkWSFvDvKbSpK4fiy9UYXqflv9TcXa6YOxNq1j/AFq95nXfnJ2rrxf9sjHnAQy +1Z5Nzjko3Li3PovZ4Dg9MR3XEXzvPZq8oyHsBCK1EOFNGYMhzIvfaleMMl4XfR80 +1IE9NUKH7IrhfcclX52p7EDo96KjoAkvdlRHqmZy14GlaIf/vvxVe6+Auc8S8y8C +AwEAAaNTMFEwHQYDVR0OBBYEFCOiC0xvMbfCFzmseoMDht+ydKBbMB8GA1UdIwQY +MBaAFCOiC0xvMbfCFzmseoMDht+ydKBbMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggIBAJSiQwcaGVhwUorkb062cyyZOAstEJ5meg6H2g3nL894oWEU +FLc/S20z2tqO1It4rZB3cRKmB0RvH78eh4aUPAh0lPa/bm/h7WrgdEAJUmlNuZV3 +Hitd/c1d2OVzx6w+CFYd/G5GW3sWblYiH0paIN6s4TqHFY/IAzzZKQB7Ud7FJagM +KMkEP8RFDm7iRcENuSf51LtZb2NjN1TM5CK5sVXu62dvPYZC6SW052/qd1U+1Tyw +EX4fCqUgEoGoU6+Ftz3hCdVy3E4uzFBK1e5wmct6HULBZL51PWpf3BgwneZy0itE +lD6Y0H6m/9KMVcXpAHZK+6YnOOcWxIgfjykjZEO99rx3pVWPw1uSBUJEu1SLknAn +JDe+WLp+xmB8s62EjixZsEGqoQYYrtZ3vz8u4PSSgYPJjdAkFdLOPitf0U8ZW9/7 +hGyHgqd7WQ3toBwwdnPo6fZqHHyN8rXeWcmx8Uj9oyY1uunkSmq3csITPQg/zKBO +6RsO3pPj8mHjeAZCDs+Ks68ccPsn+53fJ9CrjiJlHFIP0ywbEBO1snJDit5q3gQI +/UpClB9Sl+mz4wznOvrKycrxrLEptZeBA5c6M9Qr30YJAb/prxvzSY5FrUGcstkO +CQVzSwZEUXxSo6K4cJ55vC0p3P3aoMvEpHfM+JqL3lCM9qWrxfkhvn8YS+Gg +-----END CERTIFICATE----- +`, + }); + + const sslPortForCerts = await ngServe('--ssl', '--ssl-cert=server.crt', '--ssl-key="server.key"'); + assert.match( + await ( + await fetch(`https://localhost:${sslPortForCerts}/`, { + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false, + }, + }), + }) + ).text(), + match, + ); +} diff --git a/tests/e2e/tests/commands/set/set-enum-check.ts b/tests/e2e/tests/commands/set/set-enum-check.ts deleted file mode 100644 index 33d2847e19ad..000000000000 --- a/tests/e2e/tests/commands/set/set-enum-check.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('set', 'defaults.component.aaa', 'bbb'))) - .then(() => expectToFail(() => ng('set', 'defaults.component.viewEncapsulation', 'bbb'))) - .then(() => ng('set', 'defaults.component.viewEncapsulation', 'Emulated')); -} diff --git a/tests/e2e/tests/commands/set/set-prefix.ts b/tests/e2e/tests/commands/set/set-prefix.ts deleted file mode 100644 index 6c6ac0e2dbb1..000000000000 --- a/tests/e2e/tests/commands/set/set-prefix.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; -import * as fs from 'fs'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('set', 'apps.zzz.prefix'))) - .then(() => ng('set', 'apps.0.prefix' , 'new-prefix')) - .then(() => ng('get', 'apps.0.prefix')) - .then(({ stdout }) => { - if (!stdout.match(/new-prefix/)) { - throw new Error(`Expected "new-prefix", received "${JSON.stringify(stdout)}".`); - } - }) - .then(() => { - const tsLint = JSON.parse(fs.readFileSync(process.cwd() + '/tslint.json', 'utf8')); - if (tsLint.rules['component-selector'][2] !== 'new-prefix') { - throw new Error(`Expected "new-prefix" Found: ${tsLint.rules['component-selector'][2]}.`); - } - }); -} diff --git a/tests/e2e/tests/commands/set/set.ts b/tests/e2e/tests/commands/set/set.ts deleted file mode 100644 index da82c129e46b..000000000000 --- a/tests/e2e/tests/commands/set/set.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('set', 'apps.zzz.prefix'))) - .then(() => ng('set', 'apps.0.prefix' , 'new-prefix')) - .then(() => ng('get', 'apps.0.prefix')) - .then(({ stdout }) => { - if (!stdout.match(/new-prefix/)) { - throw new Error(`Expected "new-prefix", received "${JSON.stringify(stdout)}".`); - } - }); -} diff --git a/tests/e2e/tests/commands/unknown-configuration.ts b/tests/e2e/tests/commands/unknown-configuration.ts new file mode 100644 index 000000000000..c74d6547de59 --- /dev/null +++ b/tests/e2e/tests/commands/unknown-configuration.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const error = await expectToFail(() => ng('build', '--configuration', 'invalid')); + assert.match( + error.message, + /Configuration 'invalid' for target 'build' in project 'test-project' is not set in the workspace/, + ); +} diff --git a/tests/e2e/tests/commands/unknown-option.ts b/tests/e2e/tests/commands/unknown-option.ts new file mode 100644 index 000000000000..f0f4cde0693f --- /dev/null +++ b/tests/e2e/tests/commands/unknown-option.ts @@ -0,0 +1,17 @@ +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await expectToFail(() => ng('build', '--notanoption')); + + await execAndWaitForOutputToMatch( + 'ng', + ['build', '--notanoption'], + /Unknown argument: notanoption/, + ); + + const ngGenerateArgs = ['generate', 'component', 'component-name', '--notanoption']; + await expectToFail(() => ng(...ngGenerateArgs)); + + await execAndWaitForOutputToMatch('ng', ngGenerateArgs, /Unknown argument: notanoption/); +} diff --git a/tests/e2e/tests/generate/application/application-no-zoneless-ng-module.ts b/tests/e2e/tests/generate/application/application-no-zoneless-ng-module.ts new file mode 100644 index 000000000000..b475ed03b3a7 --- /dev/null +++ b/tests/e2e/tests/generate/application/application-no-zoneless-ng-module.ts @@ -0,0 +1,16 @@ +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useCIChrome, useSha } from '../../../utils/project'; + +export default async function () { + try { + await ng('generate', 'app', 'ngmodules', '--no-standalone', '--skip-install', '--no-zoneless'); + await useSha(); + await installWorkspacePackages(); + await useCIChrome('ngmodules', 'projects/ngmodules'); + await ng('test', 'ngmodules', '--watch=false'); + await ng('build', 'ngmodules'); + } finally { + await uninstallPackage('zone.js'); + } +} diff --git a/tests/e2e/tests/generate/application/application-no-zoneless-standalone.ts b/tests/e2e/tests/generate/application/application-no-zoneless-standalone.ts new file mode 100644 index 000000000000..065957f9fe11 --- /dev/null +++ b/tests/e2e/tests/generate/application/application-no-zoneless-standalone.ts @@ -0,0 +1,16 @@ +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useCIChrome, useSha } from '../../../utils/project'; + +export default async function () { + try { + await ng('generate', 'app', 'standalone', '--standalone', '--skip-install', '--no-zoneless'); + await useSha(); + await installWorkspacePackages(); + await useCIChrome('standalone', 'projects/standalone'); + await ng('test', 'standalone', '--watch=false'); + await ng('build', 'standalone'); + } finally { + await uninstallPackage('zone.js'); + } +} diff --git a/tests/e2e/tests/generate/application/application-zoneless-ng-module.ts b/tests/e2e/tests/generate/application/application-zoneless-ng-module.ts new file mode 100644 index 000000000000..5ad207a2fa76 --- /dev/null +++ b/tests/e2e/tests/generate/application/application-zoneless-ng-module.ts @@ -0,0 +1,12 @@ +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useCIChrome, useSha } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'app', 'ngmodules', '--no-standalone', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await useCIChrome('ngmodules', 'projects/ngmodules'); + await ng('test', 'ngmodules', '--watch=false'); + await ng('build', 'ngmodules'); +} diff --git a/tests/e2e/tests/generate/application/application-zoneless-standalone.ts b/tests/e2e/tests/generate/application/application-zoneless-standalone.ts new file mode 100644 index 000000000000..b203ed4a28d0 --- /dev/null +++ b/tests/e2e/tests/generate/application/application-zoneless-standalone.ts @@ -0,0 +1,12 @@ +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useCIChrome, useSha } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'app', 'standalone', '--standalone', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await useCIChrome('standalone', 'projects/standalone'); + await ng('test', 'standalone', '--watch=false'); + await ng('build', 'standalone'); +} diff --git a/tests/e2e/tests/generate/class.ts b/tests/e2e/tests/generate/class.ts deleted file mode 100644 index 336658a25610..000000000000 --- a/tests/e2e/tests/generate/class.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; - - -export default function() { - const classDir = join('src', 'app'); - - return ng('generate', 'class', 'test-class', '--spec') - .then(() => expectFileToExist(classDir)) - .then(() => expectFileToExist(join(classDir, 'test-class.ts'))) - .then(() => expectFileToExist(join(classDir, 'test-class.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/component/component-basic.ts b/tests/e2e/tests/generate/component/component-basic.ts index c0734463fc52..b87e6acad2bf 100644 --- a/tests/e2e/tests/generate/component/component-basic.ts +++ b/tests/e2e/tests/generate/component/component-basic.ts @@ -1,18 +1,20 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; +export default function () { + const projectDir = join('src', 'app'); + const componentDir = join(projectDir, 'test-component'); -export default function() { - const componentDir = join('src', 'app', 'test-component'); + return ( + ng('generate', 'component', 'test-component') + .then(() => expectFileToExist(componentDir)) + .then(() => expectFileToExist(join(componentDir, 'test-component.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.spec.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.html'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.css'))) - return ng('generate', 'component', 'test-component') - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/component/component-child-dir.ts b/tests/e2e/tests/generate/component/component-child-dir.ts new file mode 100644 index 000000000000..6c7dc7506bab --- /dev/null +++ b/tests/e2e/tests/generate/component/component-child-dir.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { createDir, expectFileToExist, rimraf } from '../../../utils/fs'; + +export default async function () { + const currentDirectory = process.cwd(); + const childDirectory = join('src', 'app', 'sub-dir'); + + try { + // Create and enter a child directory inside the project + await createDir(childDirectory); + process.chdir(childDirectory); + + // Generate a component inside the child directory + await ng('generate', 'component', 'test-component'); + + // Move back to the root of the workspacee + process.chdir(currentDirectory); + + // Ensure component is created in the correct location relative to the workspace root + const componentDirectory = join(childDirectory, 'test-component'); + await expectFileToExist(join(componentDirectory, 'test-component.ts')); + await expectFileToExist(join(componentDirectory, 'test-component.spec.ts')); + await expectFileToExist(join(componentDirectory, 'test-component.html')); + await expectFileToExist(join(componentDirectory, 'test-component.css')); + + // Ensure unit test execute and pass + await ng('test', '--watch=false'); + } finally { + // Windows CI may fail to clean up the created directory + // Resolves: "Error: Running "cmd.exe /c git clean -df" returned error code 1" + await rimraf(childDirectory); + } +} diff --git a/tests/e2e/tests/generate/component/component-duplicate.ts b/tests/e2e/tests/generate/component/component-duplicate.ts deleted file mode 100644 index 9099d487b084..000000000000 --- a/tests/e2e/tests/generate/component/component-duplicate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ng } from '../../../utils/process'; -import { oneLine } from 'common-tags'; - -export default function () { - return ng('generate', 'component', 'test-component') - .then((output) => { - if (!output.stdout.match(/update src[\\|\/]app[\\|\/]app.module.ts/)) { - throw new Error(oneLine` - Expected to match - "update src/app/app.module.ts" - in ${output.stdout}.`); - } - }) - .then(() => ng('generate', 'component', 'test-component')) - .then((output) => { - if (!output.stdout.match(/error! src[\\|\/]app[\\|\/]test-component[\\|\/]test-component.component.ts already exists./)) { - throw new Error(oneLine` - Expected to match - "ERROR! src/app/test-component/test-component.ts" - in ${output.stdout}.`); - } - }); -} diff --git a/tests/e2e/tests/generate/component/component-flag-case.ts b/tests/e2e/tests/generate/component/component-flag-case.ts deleted file mode 100644 index dbbee3525756..000000000000 --- a/tests/e2e/tests/generate/component/component-flag-case.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const compDir = join('src', 'app', 'test'); - - return Promise.resolve() - .then(() => ng('generate', 'component', 'test', '-cd', 'onpush', '-ve', 'emulated')) - .then(() => expectFileToMatch(join(compDir, 'test.component.ts'), - /changeDetection: ChangeDetectionStrategy.OnPush/)) - .then(() => expectFileToMatch(join(compDir, 'test.component.ts'), - /encapsulation: ViewEncapsulation.Emulated/)); -} diff --git a/tests/e2e/tests/generate/component/component-flat.ts b/tests/e2e/tests/generate/component/component-flat.ts index 57c3658907da..4bdb101391fa 100644 --- a/tests/e2e/tests/generate/component/component-flat.ts +++ b/tests/e2e/tests/generate/component/component-flat.ts @@ -1,24 +1,27 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {updateJsonFile} from '../../../utils/project'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; +import { updateJsonFile } from '../../../utils/project'; - -export default function() { +export default function () { const appDir = join('src', 'app'); + return ( + Promise.resolve() + .then(() => + updateJsonFile('angular.json', (configJson) => { + configJson.projects['test-project'].schematics = { + '@schematics/angular:component': { flat: true }, + }; + }), + ) + .then(() => ng('generate', 'component', 'test-component')) + .then(() => expectFileToExist(appDir)) + .then(() => expectFileToExist(join(appDir, 'test-component.ts'))) + .then(() => expectFileToExist(join(appDir, 'test-component.spec.ts'))) + .then(() => expectFileToExist(join(appDir, 'test-component.html'))) + .then(() => expectFileToExist(join(appDir, 'test-component.css'))) - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const comp = configJson.defaults.component; - comp.flat = true; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(appDir)) - .then(() => expectFileToExist(join(appDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(appDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(appDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(appDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/component/component-in-existing-module-dir.ts b/tests/e2e/tests/generate/component/component-in-existing-module-dir.ts deleted file mode 100644 index bb98447bb4a1..000000000000 --- a/tests/e2e/tests/generate/component/component-in-existing-module-dir.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -export default function () { - const modulePath = join('src', 'app', 'foo', 'foo.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'foo')) - .then(() => ng('generate', 'component', 'foo')) - .then(() => expectFileToMatch(modulePath, /import { FooComponent } from '.\/foo.component'/)); -} diff --git a/tests/e2e/tests/generate/component/component-inline-template.ts b/tests/e2e/tests/generate/component/component-inline-template.ts new file mode 100644 index 000000000000..135b97c43fcb --- /dev/null +++ b/tests/e2e/tests/generate/component/component-inline-template.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +// tslint:disable:max-line-length +export default function () { + const componentDir = join('src', 'app', 'test-component'); + return ( + Promise.resolve() + .then(() => + updateJsonFile('angular.json', (configJson) => { + configJson.projects['test-project'].schematics = { + '@schematics/angular:component': { inlineTemplate: true }, + }; + }), + ) + .then(() => ng('generate', 'component', 'test-component')) + .then(() => expectFileToExist(componentDir)) + .then(() => expectFileToExist(join(componentDir, 'test-component.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.spec.ts'))) + .then(() => expectToFail(() => expectFileToExist(join(componentDir, 'test-component.html')))) + .then(() => expectFileToExist(join(componentDir, 'test-component.css'))) + + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); +} diff --git a/tests/e2e/tests/generate/component/component-module-export.ts b/tests/e2e/tests/generate/component/component-module-export.ts deleted file mode 100644 index 77d0862ec4b2..000000000000 --- a/tests/e2e/tests/generate/component/component-module-export.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'component', 'test-component', '--export') - .then(() => expectFileToMatch(modulePath, 'exports: [TestComponentComponent]')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/component/component-module-fail.ts b/tests/e2e/tests/generate/component/component-module-fail.ts deleted file mode 100644 index f4efd1eb8ff6..000000000000 --- a/tests/e2e/tests/generate/component/component-module-fail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'component', 'test-component', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/e2e/tests/generate/component/component-module.ts b/tests/e2e/tests/generate/component/component-module.ts deleted file mode 100644 index e742a1ecabeb..000000000000 --- a/tests/e2e/tests/generate/component/component-module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as fs from 'fs-extra'; -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - - fs.mkdirSync('./src/app/sub-dir'); - - return ng('generate', 'component', 'test-component', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestComponentComponent } from '.\/test-component\/test-component.component'/)) - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'component', 'test-component2', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { TestComponent2Component } from '.\/test-component2\/test-component2.component'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/generate/component/component-not-flat.ts b/tests/e2e/tests/generate/component/component-not-flat.ts index 91ba0ba0e2fd..eedc3926da89 100644 --- a/tests/e2e/tests/generate/component/component-not-flat.ts +++ b/tests/e2e/tests/generate/component/component-not-flat.ts @@ -1,24 +1,28 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {updateJsonFile} from '../../../utils/project'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; +import { updateJsonFile } from '../../../utils/project'; - -export default function() { +export default function () { const componentDir = join('src', 'app', 'test-component'); - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const comp = configJson.defaults.component; - comp.flat = false; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) + return ( + Promise.resolve() + .then(() => + updateJsonFile('angular.json', (configJson) => { + configJson.projects['test-project'].schematics = { + '@schematics/angular:component': { flat: false }, + }; + }), + ) + .then(() => ng('generate', 'component', 'test-component')) + .then(() => expectFileToExist(componentDir)) + .then(() => expectFileToExist(join(componentDir, 'test-component.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.spec.ts'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.html'))) + .then(() => expectFileToExist(join(componentDir, 'test-component.css'))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/component/component-path-case.ts b/tests/e2e/tests/generate/component/component-path-case.ts index 2a53a3446847..63d08af7099d 100644 --- a/tests/e2e/tests/generate/component/component-path-case.ts +++ b/tests/e2e/tests/generate/component/component-path-case.ts @@ -1,27 +1,38 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist, rimraf } from '../../../utils/fs'; - -export default function() { +export default async function () { const upperDirs = join('non', 'existing', 'dir'); const rootDir = join('src', 'app', upperDirs); - const componentDir = join(rootDir, 'test-component'); - const componentTwoDir = join(rootDir, 'test-component-two'); + const componentDirectory = join(rootDir, 'test-component'); + const componentTwoDirectory = join(rootDir, 'test-component-two'); + + try { + // Generate a component + await ng('generate', 'component', `${upperDirs}/test-component`); + + // Ensure component is created in the correct location relative to the workspace root + await expectFileToExist(join(componentDirectory, 'test-component.ts')); + await expectFileToExist(join(componentDirectory, 'test-component.spec.ts')); + await expectFileToExist(join(componentDirectory, 'test-component.html')); + await expectFileToExist(join(componentDirectory, 'test-component.css')); + + // Generate another component + await ng('generate', 'component', `${upperDirs}/Test-Component-Two`); - return ng('generate', 'component', `${upperDirs}/test-component`) - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - .then(() => ng('generate', 'component', `${upperDirs}/Test-Component-Two`)) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.ts'))) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.spec.ts'))) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.html'))) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.css'))) + // Ensure component is created in the correct location relative to the workspace root + await expectFileToExist(join(componentTwoDirectory, 'test-component-two.ts')); + await expectFileToExist(join(componentTwoDirectory, 'test-component-two.spec.ts')); + await expectFileToExist(join(componentTwoDirectory, 'test-component-two.html')); + await expectFileToExist(join(componentTwoDirectory, 'test-component-two.css')); - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); + // Ensure unit test execute and pass + await ng('test', '--watch=false'); + } finally { + // Windows CI may fail to clean up the created directory + // Resolves: "Error: Running "cmd.exe /c git clean -df" returned error code 1" + await rimraf(rootDir); + } } diff --git a/tests/e2e/tests/generate/component/component-prefix.ts b/tests/e2e/tests/generate/component/component-prefix.ts new file mode 100644 index 000000000000..befa96939b00 --- /dev/null +++ b/tests/e2e/tests/generate/component/component-prefix.ts @@ -0,0 +1,27 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToMatch } from '../../../utils/fs'; +import { updateJsonFile } from '../../../utils/project'; + +export default function () { + const testCompDir = join('src', 'app', 'test-component'); + const aliasCompDir = join('src', 'app', 'alias'); + + return ( + Promise.resolve() + .then(() => + updateJsonFile('angular.json', (configJson) => { + configJson.projects['test-project'].schematics = { + '@schematics/angular:component': { prefix: 'pre' }, + }; + }), + ) + .then(() => ng('generate', 'component', 'test-component')) + .then(() => expectFileToMatch(join(testCompDir, 'test-component.ts'), /selector: 'pre-/)) + .then(() => ng('g', 'c', 'alias')) + .then(() => expectFileToMatch(join(aliasCompDir, 'alias.ts'), /selector: 'pre-/)) + + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); +} diff --git a/tests/e2e/tests/generate/config/type-browserslist.ts b/tests/e2e/tests/generate/config/type-browserslist.ts new file mode 100644 index 000000000000..b9717852a4f1 --- /dev/null +++ b/tests/e2e/tests/generate/config/type-browserslist.ts @@ -0,0 +1,6 @@ +import { ng } from '../../../utils/process'; + +export default async function () { + await ng('generate', 'config', 'browserslist'); + await ng('build'); +} diff --git a/tests/e2e/tests/generate/config/type-karma.ts b/tests/e2e/tests/generate/config/type-karma.ts new file mode 100644 index 000000000000..c81b7d6b1abf --- /dev/null +++ b/tests/e2e/tests/generate/config/type-karma.ts @@ -0,0 +1,8 @@ +import { ng } from '../../../utils/process'; +import { useCIChrome } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'config', 'karma'); + await useCIChrome('test-project'); + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/generate/directive/directive-basic.ts b/tests/e2e/tests/generate/directive/directive-basic.ts index 07ac720d0ba3..9ad00dfa22a3 100644 --- a/tests/e2e/tests/generate/directive/directive-basic.ts +++ b/tests/e2e/tests/generate/directive/directive-basic.ts @@ -1,14 +1,15 @@ -import {ng} from '../../../utils/process'; -import {join} from 'path'; -import {expectFileToExist} from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { join } from 'node:path'; +import { expectFileToExist } from '../../../utils/fs'; - -export default function() { +export default function () { const directiveDir = join('src', 'app'); - return ng('generate', 'directive', 'test-directive') - .then(() => expectFileToExist(join(directiveDir, 'test-directive.directive.ts'))) - .then(() => expectFileToExist(join(directiveDir, 'test-directive.directive.spec.ts'))) + return ( + ng('generate', 'directive', 'test-directive') + .then(() => expectFileToExist(join(directiveDir, 'test-directive.ts'))) + .then(() => expectFileToExist(join(directiveDir, 'test-directive.spec.ts'))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/directive/directive-in-existing-module-dir.ts b/tests/e2e/tests/generate/directive/directive-in-existing-module-dir.ts deleted file mode 100644 index 7dd270fe81a1..000000000000 --- a/tests/e2e/tests/generate/directive/directive-in-existing-module-dir.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -export default function () { - const modulePath = join('src', 'app', 'foo', 'foo.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'foo')) - .then(() => ng('generate', 'directive', 'foo', '--no-flat')) - .then(() => expectFileToMatch(modulePath, /import { FooDirective } from '.\/foo.directive'/)); -} diff --git a/tests/e2e/tests/generate/directive/directive-module-export.ts b/tests/e2e/tests/generate/directive/directive-module-export.ts deleted file mode 100644 index 7940cf466a1d..000000000000 --- a/tests/e2e/tests/generate/directive/directive-module-export.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'directive', 'test-directive', '--export') - .then(() => expectFileToMatch(modulePath, 'exports: [TestDirectiveDirective]')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/directive/directive-module-fail.ts b/tests/e2e/tests/generate/directive/directive-module-fail.ts deleted file mode 100644 index 05ff8d98f1c9..000000000000 --- a/tests/e2e/tests/generate/directive/directive-module-fail.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'directive', 'test-directive', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/e2e/tests/generate/directive/directive-module.ts b/tests/e2e/tests/generate/directive/directive-module.ts deleted file mode 100644 index 2667c390c910..000000000000 --- a/tests/e2e/tests/generate/directive/directive-module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as fs from 'fs-extra'; -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - - fs.mkdirSync('./src/app/sub-dir'); - - return ng('generate', 'directive', 'test-directive', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestDirectiveDirective } from '.\/test-directive.directive'/)) - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'directive', 'test-directive2', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { TestDirective2Directive } from '.\/test-directive2.directive'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/generate/directive/directive-prefix.ts b/tests/e2e/tests/generate/directive/directive-prefix.ts new file mode 100644 index 000000000000..a8b5981b34a3 --- /dev/null +++ b/tests/e2e/tests/generate/directive/directive-prefix.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToMatch } from '../../../utils/fs'; +import { updateJsonFile, useCIChrome, useCIDefaults } from '../../../utils/project'; + +export default function () { + const directiveDir = join('src', 'app'); + + return ( + Promise.resolve() + .then(() => + updateJsonFile('angular.json', (configJson) => { + configJson.schematics = { + '@schematics/angular:directive': { prefix: 'preW' }, + }; + }), + ) + .then(() => ng('generate', 'directive', 'test2-directive')) + .then(() => expectFileToMatch(join(directiveDir, 'test2-directive.ts'), /selector: '\[preW/)) + .then(() => + ng('generate', 'application', 'app-two', '--skip-install', '--test-runner', 'karma'), + ) + .then(() => useCIDefaults('app-two')) + .then(() => useCIChrome('app-two', './projects/app-two')) + .then(() => + updateJsonFile('angular.json', (configJson) => { + configJson.projects['test-project'].schematics = { + '@schematics/angular:directive': { prefix: 'preP' }, + }; + }), + ) + .then(() => process.chdir('projects/app-two')) + .then(() => ng('generate', 'directive', '--skip-import', 'test3-directive')) + .then(() => process.chdir('../..')) + .then(() => + expectFileToMatch(join('projects', 'app-two', 'test3-directive.ts'), /selector: '\[preW/), + ) + .then(() => process.chdir('src/app')) + .then(() => ng('generate', 'directive', 'test-directive')) + .then(() => process.chdir('../..')) + .then(() => expectFileToMatch(join(directiveDir, 'test-directive.ts'), /selector: '\[preP/)) + + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); +} diff --git a/tests/e2e/tests/generate/generate-error.ts b/tests/e2e/tests/generate/generate-error.ts index 1b82b8823a28..cee6d7998239 100644 --- a/tests/e2e/tests/generate/generate-error.ts +++ b/tests/e2e/tests/generate/generate-error.ts @@ -1,8 +1,9 @@ -import {ng} from '../../utils/process'; -import {deleteFile} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; +import { ng } from '../../utils/process'; +import { deleteFile } from '../../utils/fs'; +import { expectToFail } from '../../utils/utils'; -export default function() { - return deleteFile('.angular-cli.json') - .then(() => expectToFail(() => ng('generate', 'class', 'hello'))); +export default function () { + return deleteFile('angular.json').then(() => + expectToFail(() => ng('generate', 'class', 'hello')), + ); } diff --git a/tests/e2e/tests/generate/generate-name-check.ts b/tests/e2e/tests/generate/generate-name-check.ts index 499519b9c6c7..6fe89a58face 100644 --- a/tests/e2e/tests/generate/generate-name-check.ts +++ b/tests/e2e/tests/generate/generate-name-check.ts @@ -1,24 +1,27 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; +import { join } from 'node:path'; +import { ng } from '../../utils/process'; +import { expectFileToExist } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; - -export default function() { +export default function () { const compDir = join('src', 'app', 'test-component'); - return Promise.resolve() - .then(() => updateJsonFile('package.json', configJson => { - delete configJson.name; - return configJson; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(compDir)) - .then(() => expectFileToExist(join(compDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(compDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(compDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(compDir, 'test-component.component.css'))) + return ( + Promise.resolve() + .then(() => + updateJsonFile('package.json', (configJson) => { + delete configJson.name; + return configJson; + }), + ) + .then(() => ng('generate', 'component', 'test-component')) + .then(() => expectFileToExist(compDir)) + .then(() => expectFileToExist(join(compDir, 'test-component.ts'))) + .then(() => expectFileToExist(join(compDir, 'test-component.spec.ts'))) + .then(() => expectFileToExist(join(compDir, 'test-component.html'))) + .then(() => expectFileToExist(join(compDir, 'test-component.css'))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/generate-name-error.ts b/tests/e2e/tests/generate/generate-name-error.ts deleted file mode 100644 index 2fb0583ca514..000000000000 --- a/tests/e2e/tests/generate/generate-name-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'component', '1my-component'))); -} diff --git a/tests/e2e/tests/generate/guard/guard-basic.ts b/tests/e2e/tests/generate/guard/guard-basic.ts index 0e03538b8d0e..ca4e9a547ff6 100644 --- a/tests/e2e/tests/generate/guard/guard-basic.ts +++ b/tests/e2e/tests/generate/guard/guard-basic.ts @@ -1,17 +1,15 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist, expectFileToMatch } from '../../../utils/fs'; - -export default function() { +export default async function () { // Does not create a sub directory. const guardDir = join('src', 'app'); - return ng('generate', 'guard', 'test-guard') - .then(() => expectFileToExist(guardDir)) - .then(() => expectFileToExist(join(guardDir, 'test-guard.guard.ts'))) - .then(() => expectFileToExist(join(guardDir, 'test-guard.guard.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + await ng('generate', 'guard', 'test'); + await expectFileToExist(guardDir); + await expectFileToExist(join(guardDir, 'test-guard.ts')); + await expectFileToMatch(join(guardDir, 'test-guard.ts'), /export const testGuard: CanActivateFn/); + await expectFileToExist(join(guardDir, 'test-guard.spec.ts')); + await ng('test', '--watch=false'); } diff --git a/tests/e2e/tests/generate/guard/guard-implements.ts b/tests/e2e/tests/generate/guard/guard-implements.ts new file mode 100644 index 000000000000..ca7c35f754a4 --- /dev/null +++ b/tests/e2e/tests/generate/guard/guard-implements.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist, expectFileToMatch } from '../../../utils/fs'; + +export default async function () { + // Does not create a sub directory. + const guardDir = join('src', 'app'); + + await ng('generate', 'guard', 'match', '--implements=CanMatch'); + await expectFileToExist(guardDir); + await expectFileToExist(join(guardDir, 'match-guard.ts')); + await expectFileToMatch(join(guardDir, 'match-guard.ts'), /export const matchGuard: CanMatch/); + await expectFileToExist(join(guardDir, 'match-guard.spec.ts')); + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/generate/guard/guard-module-fail.ts b/tests/e2e/tests/generate/guard/guard-module-fail.ts deleted file mode 100644 index 2275a42f1487..000000000000 --- a/tests/e2e/tests/generate/guard/guard-module-fail.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'guard', 'test-guard', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/e2e/tests/generate/guard/guard-module.ts b/tests/e2e/tests/generate/guard/guard-module.ts deleted file mode 100644 index 8718fdd700ae..000000000000 --- a/tests/e2e/tests/generate/guard/guard-module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as fs from 'fs-extra'; -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - - fs.mkdirSync('./src/app/sub-dir'); - - return ng('generate', 'guard', 'test-guard', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestGuardGuard } from '.\/test-guard.guard'/)) - .then(() => expectFileToMatch(modulePath, - /providers:\s*\[TestGuardGuard\]/m)) - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'guard', 'test-guard2', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { TestGuard2Guard } from '.\/test-guard2.guard'/)) - - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/generate/guard/guard-multiple-implements.ts b/tests/e2e/tests/generate/guard/guard-multiple-implements.ts new file mode 100644 index 000000000000..4359eaaf9f59 --- /dev/null +++ b/tests/e2e/tests/generate/guard/guard-multiple-implements.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist, expectFileToMatch } from '../../../utils/fs'; + +export default async function () { + // Does not create a sub directory. + const guardDir = join('src', 'app'); + + // multiple implements are only supported in (deprecated) class-based guards + await ng( + 'generate', + 'guard', + 'multiple', + '--implements=CanActivate', + '--implements=CanDeactivate', + '--no-functional', + ); + await expectFileToExist(guardDir); + await expectFileToExist(join(guardDir, 'multiple-guard.ts')); + await expectFileToMatch( + join(guardDir, 'multiple-guard.ts'), + /implements CanActivate, CanDeactivate/, + ); + await expectFileToExist(join(guardDir, 'multiple-guard.spec.ts')); + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/generate/help-output-no-duplicates.ts b/tests/e2e/tests/generate/help-output-no-duplicates.ts new file mode 100644 index 000000000000..0be6ea93ea48 --- /dev/null +++ b/tests/e2e/tests/generate/help-output-no-duplicates.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; + +export default async function () { + // Verify that there are no duplicate options + const { stdout } = await ng('generate', 'component', '--help'); + const firstIndex = stdout.indexOf('--prefix'); + + assert.ok(firstIndex >= 0, '--prefix was not part of the help output.'); + assert.strictEqual( + firstIndex, + stdout.lastIndexOf('--prefix'), + '--prefix first and last index were different. Possible duplicate output!', + ); +} diff --git a/tests/e2e/tests/generate/help-output.ts b/tests/e2e/tests/generate/help-output.ts new file mode 100644 index 000000000000..bd5c71a7d9d1 --- /dev/null +++ b/tests/e2e/tests/generate/help-output.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { createDir, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default function () { + // setup temp collection + const genRoot = join('node_modules/fake-schematics/'); + + return ( + Promise.resolve() + .then(() => createDir(genRoot)) + .then(() => + writeMultipleFiles({ + [join(genRoot, 'package.json')]: ` + { + "schematics": "./collection.json" + }`, + [join(genRoot, 'collection.json')]: ` + { + "schematics": { + "fake": { + "factory": "./fake", + "description": "Fake schematic", + "schema": "./fake-schema.json" + }, + } + }`, + [join(genRoot, 'fake-schema.json')]: ` + { + "$id": "FakeSchema", + "title": "Fake Schema", + "type": "object", + "required": ["b"], + "properties": { + "b": { + "type": "string", + "description": "b.", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "a": { + "type": "string", + "description": "a.", + "$default": { + "$source": "argv", + "index": 1 + } + }, + "optC": { + "type": "string", + "description": "optC" + }, + "optA": { + "type": "string", + "description": "optA" + }, + "optB": { + "type": "string", + "description": "optB" + } + } + }`, + [join(genRoot, 'fake.js')]: ` + function def(options) { + return (host, context) => { + return host; + }; + } + exports.default = def; + `, + }), + ) + .then(() => ng('generate', 'fake-schematics:fake', '--help')) + .then(({ stdout }) => { + assert.match(stdout, /ng generate fake-schematics:fake \[a\]/); + assert.match(stdout, /opt-a[\s\S]*opt-b[\s\S]*opt-c/); + }) + // set up default collection. + .then(() => + updateJsonFile('angular.json', (json) => { + json.cli = json.cli || ({} as any); + json.cli.schematicCollections = ['fake-schematics']; + }), + ) + .then(() => ng('generate', 'fake', '--help')) + // verify same output + .then(({ stdout }) => { + assert.match(stdout, /ng generate fake \[a\]/); + assert.match(stdout, /opt-a[\s\S]*opt-b[\s\S]*opt-c/); + }) + + // should print all the available schematics in a collection + // when a collection has more than 1 schematic + .then(() => + writeMultipleFiles({ + [join(genRoot, 'collection.json')]: ` + { + "schematics": { + "fake": { + "factory": "./fake", + "description": "Fake schematic", + "schema": "./fake-schema.json" + }, + "fake-two": { + "factory": "./fake", + "description": "Fake schematic", + "schema": "./fake-schema.json" + }, + } + }`, + }), + ) + .then(() => ng('generate', '--help')) + .then(({ stdout }) => { + assert.match(stdout, /fake[\s\S]*fake-two/); + }) + ); +} diff --git a/tests/e2e/tests/generate/install-allow-scripts.ts b/tests/e2e/tests/generate/install-allow-scripts.ts new file mode 100644 index 000000000000..f41a49935d80 --- /dev/null +++ b/tests/e2e/tests/generate/install-allow-scripts.ts @@ -0,0 +1,31 @@ +import { copyAssets } from '../../utils/assets'; +import { expectFileNotToExist, expectFileToExist, rimraf } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + // Copy test schematic into test project to ensure schematic dependencies are available + await copyAssets('schematic-allow-scripts', 'schematic-allow-scripts'); + + // By default should not run the postinstall from the added package.json in the schematic + await ng('generate', './schematic-allow-scripts:test'); + await expectFileToExist('install-test/package.json'); + await expectFileNotToExist('install-test/post-script-ran'); + + // Cleanup for next test case + await rimraf('install-test'); + + // Should run the postinstall if the allowScripts task option is enabled + // For testing purposes, this schematic exposes the task option via a schematic option + await ng('generate', './schematic-allow-scripts:test', '--allow-scripts'); + await expectFileToExist('install-test/package.json'); + await expectFileToExist('install-test/post-script-ran'); + + // Cleanup for next test case + await rimraf('install-test'); + + // Package manager configuration should take priority + // The `ignoreScripts` schematic option sets the value of the `ignore-scripts` option in a test project `.npmrc` + await ng('generate', './schematic-allow-scripts:test', '--allow-scripts', '--ignore-scripts'); + await expectFileToExist('install-test/package.json'); + await expectFileNotToExist('install-test/post-script-ran'); +} diff --git a/tests/e2e/tests/generate/interceptor/interceptor-basic.ts b/tests/e2e/tests/generate/interceptor/interceptor-basic.ts new file mode 100755 index 000000000000..7dd0b48c439b --- /dev/null +++ b/tests/e2e/tests/generate/interceptor/interceptor-basic.ts @@ -0,0 +1,18 @@ +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; + +export default function () { + // Does not create a sub directory. + const interceptorDir = join('src', 'app'); + + return ( + ng('generate', 'interceptor', 'test') + .then(() => expectFileToExist(interceptorDir)) + .then(() => expectFileToExist(join(interceptorDir, 'test-interceptor.ts'))) + .then(() => expectFileToExist(join(interceptorDir, 'test-interceptor.spec.ts'))) + + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); +} diff --git a/tests/e2e/tests/generate/interface.ts b/tests/e2e/tests/generate/interface.ts deleted file mode 100644 index 39b2376ca06c..000000000000 --- a/tests/e2e/tests/generate/interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; - - -export default function() { - const interfaceDir = join('src', 'app'); - - return ng('generate', 'interface', 'test-interface', 'model') - .then(() => expectFileToExist(interfaceDir)) - .then(() => expectFileToExist(join(interfaceDir, 'test-interface.model.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/library/library-basic.ts b/tests/e2e/tests/generate/library/library-basic.ts new file mode 100644 index 000000000000..efc74694ea00 --- /dev/null +++ b/tests/e2e/tests/generate/library/library-basic.ts @@ -0,0 +1,9 @@ +import { ng } from '../../../utils/process'; +import { useCIChrome } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'lib-ngmodule', '--no-standalone'); + await useCIChrome('lib-ngmodule', 'projects/lib-ngmodule'); + await ng('test', 'lib-ngmodule', '--no-watch'); + await ng('build', 'lib-ngmodule'); +} diff --git a/tests/e2e/tests/generate/library/library-standalone.ts b/tests/e2e/tests/generate/library/library-standalone.ts new file mode 100644 index 000000000000..0b9754644bf5 --- /dev/null +++ b/tests/e2e/tests/generate/library/library-standalone.ts @@ -0,0 +1,9 @@ +import { ng } from '../../../utils/process'; +import { useCIChrome } from '../../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'lib-standalone', '--standalone'); + await useCIChrome('lib-standalone', 'projects/lib-standalone'); + await ng('test', 'lib-standalone', '--no-watch'); + await ng('build', 'lib-standalone'); +} diff --git a/tests/e2e/tests/generate/lint-fix.ts b/tests/e2e/tests/generate/lint-fix.ts deleted file mode 100644 index 1512ff501766..000000000000 --- a/tests/e2e/tests/generate/lint-fix.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; - -export default function () { - const nestedConfigContent = ` - { - "rules": { - "quotemark": [ - true, - "double", - "avoid-escape" - ] - } - }`; - - return Promise.resolve() - // setup a double-quote tslint config - .then(() => writeFile('src/app/tslint.json', nestedConfigContent)) - - // Generate a fixed new component but don't fix rest of app - .then(() => ng('generate', 'component', 'test-component1', '--lint-fix')) - .then(() => expectToFail(() => ng('lint'))) - - // Fix rest of app and generate new component - .then(() => ng('lint', '--fix')) - .then(() => ng('generate', 'component', 'test-component2', '--lint-fix')) - .then(() => ng('lint')) - - // Enable default option and generate all other module related blueprints - .then(() => ng('set', 'defaults.lintFix', 'true')) - .then(() => ng('generate', 'directive', 'test-directive')) - .then(() => ng('generate', 'service', 'test-service', '--module', 'app.module.ts')) - .then(() => ng('generate', 'pipe', 'test-pipe')) - .then(() => ng('generate', 'guard', 'test-guard', '--module', 'app.module.ts')) - .then(() => ng('lint')); -} diff --git a/tests/e2e/tests/generate/module/module-basic.ts b/tests/e2e/tests/generate/module/module-basic.ts index b76cf0dabab1..18e1a05fedde 100644 --- a/tests/e2e/tests/generate/module/module-basic.ts +++ b/tests/e2e/tests/generate/module/module-basic.ts @@ -1,19 +1,23 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist, expectFileToMatch } from '../../../utils/fs'; +import { expectToFail } from '../../../utils/utils'; +import { useCIChrome, useCIDefaults } from '../../../utils/project'; +export default async function () { + const projectName = 'test-project-two'; + const moduleDir = `projects/${projectName}/src/app/test`; + await ng('generate', 'application', projectName, '--no-standalone', '--skip-install'); + await useCIDefaults(projectName); + await useCIChrome(projectName, 'projects/test-project-two'); -export default function() { - const moduleDir = join('src', 'app', 'test'); + await ng('generate', 'module', 'test', '--project', projectName); + await expectFileToExist(moduleDir); + await expectFileToExist(join(moduleDir, 'test-module.ts')); + await expectToFail(() => expectFileToExist(join(moduleDir, 'test-routing-module.ts'))); + await expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts'))); + await expectFileToMatch(join(moduleDir, 'test-module.ts'), 'TestModule'); - return ng('generate', 'module', 'test') - .then(() => expectFileToExist(moduleDir)) - .then(() => expectFileToExist(join(moduleDir, 'test.module.ts'))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test-routing.module.ts')))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts')))) - .then(() => expectFileToMatch(join(moduleDir, 'test.module.ts'), 'TestModule')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + await ng('test', projectName, '--watch=false'); } diff --git a/tests/e2e/tests/generate/module/module-import.ts b/tests/e2e/tests/generate/module/module-import.ts index 3cf65e729740..533b3d19efe7 100644 --- a/tests/e2e/tests/generate/module/module-import.ts +++ b/tests/e2e/tests/generate/module/module-import.ts @@ -1,55 +1,59 @@ -import * as fs from 'fs-extra'; -import { join } from 'path'; +import { join } from 'node:path'; import { ng } from '../../../utils/process'; import { expectFileToMatch } from '../../../utils/fs'; -export default function () { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - const subModulePath = join('src', 'app', 'sub', 'sub.module.ts'); - const deepSubModulePath = join('src', 'app', 'sub', 'deep', 'deep.module.ts'); - - fs.mkdirSync('./src/app/sub-dir'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'sub')) - .then(() => ng('generate', 'module', 'sub/deep')) - - .then(() => ng('generate', 'module', 'test1', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { Test1Module } from '.\/test1\/test1.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test1Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test2', '--module', 'app.module')) - .then(() => expectFileToMatch(modulePath, - /import { Test2Module } from '.\/test2\/test2.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test2Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test3', '--module', 'app')) - .then(() => expectFileToMatch(modulePath, - /import { Test3Module } from '.\/test3\/test3.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test3Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test4', '--routing', '--module', 'app')) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test4Module(.|\s)*\]/m)) - .then(() => expectFileToMatch(join('src', 'app', 'test4', 'test4.module.ts'), - /import { Test4RoutingModule } from '.\/test4-routing.module'/)) - .then(() => expectFileToMatch(join('src', 'app', 'test4', 'test4.module.ts'), - /imports: \[(.|\s)*Test4RoutingModule(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test5', '--module', 'sub')) - .then(() => expectFileToMatch(subModulePath, - /import { Test5Module } from '..\/test5\/test5.module'/)) - .then(() => expectFileToMatch(subModulePath, /imports: \[(.|\s)*Test5Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test6', '--module', join('sub', 'deep')) - .then(() => expectFileToMatch(deepSubModulePath, - /import { Test6Module } from '..\/..\/test6\/test6.module'/)) - .then(() => expectFileToMatch(deepSubModulePath, /imports: \[(.|\s)*Test6Module(.|\s)*\]/m))); - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'module', 'test7', '--module', 'app.module.ts'))) - .then(() => expectFileToMatch(modulePath, - /import { Test7Module } from '.\/test7\/test7.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test7Module(.|\s)*\]/m)) +export default async function () { + const projectName = 'test-project-two'; + await ng('generate', 'application', projectName, '--no-standalone', '--skip-install'); + await ng('generate', 'module', 'sub', '--project', projectName); + await ng('generate', 'module', 'sub/deep', '--project', projectName); + + const projectAppDir = `projects/${projectName}/src/app`; + const modulePath = join(projectAppDir, 'app-module.ts'); + const subModulePath = join(projectAppDir, 'sub/sub-module.ts'); + const deepSubModulePath = join(projectAppDir, 'sub/deep/deep-module.ts'); + + await ng('generate', 'module', 'test1', '--module', 'app-module.ts', '--project', projectName); + await expectFileToMatch(modulePath, `import { Test1Module } from './test1/test1-module'`); + await expectFileToMatch(modulePath, /imports: \[.*?Test1Module.*?\]/s); + + await ng('generate', 'module', 'test2', '--module', 'app-module', '--project', projectName); + await expectFileToMatch(modulePath, `import { Test2Module } from './test2/test2-module'`); + await expectFileToMatch(modulePath, /imports: \[.*?Test2Module.*?\]/s); + + await ng('generate', 'module', 'test3', '--module', 'app', '--project', projectName); + await expectFileToMatch(modulePath, `import { Test3Module } from './test3/test3-module'`); + await expectFileToMatch(modulePath, /imports: \[.*?Test3Module.*?\]/s); + + await ng('generate', 'module', 'test4', '--routing', '--module', 'app', '--project', projectName); + await expectFileToMatch(modulePath, /imports: \[.*?Test4Module.*?\]/s); + await expectFileToMatch( + join(projectAppDir, 'test4/test4-module.ts'), + `import { Test4RoutingModule } from './test4-routing-module'`, + ); + await expectFileToMatch( + join(projectAppDir, 'test4/test4-module.ts'), + /imports: \[.*?Test4RoutingModule.*?\]/s, + ); + + await ng('generate', 'module', 'test5', '--module', 'sub', '--project', projectName); + await expectFileToMatch(subModulePath, `import { Test5Module } from '../test5/test5-module'`); + + await expectFileToMatch(subModulePath, /imports: \[.*?Test5Module.*?\]/s); + + await ng('generate', 'module', 'test6', '--module', 'sub/deep', '--project', projectName); + + await expectFileToMatch( + deepSubModulePath, + `import { Test6Module } from '../../test6/test6-module'`, + ); + await expectFileToMatch(deepSubModulePath, /imports: \[.*?Test6Module.*?\]/s); + + // E2E_DISABLE: temporarily disable pending investigation + // await process.chdir(join(root, 'src', 'app'))) + // await ng('generate', 'module', 'test7', '--module', 'app-module.ts')) + // await process.chdir('..')) + // await expectFileToMatch(modulePath, + // /import { Test7Module } from '.\/test7\/test7-module'/)) + // await expectFileToMatch(modulePath, /imports: \[(.|\s)*Test7Module(.|\s)*\]/m)); } diff --git a/tests/e2e/tests/generate/module/module-routing-child-folder.ts b/tests/e2e/tests/generate/module/module-routing-child-folder.ts index 73d2d486c570..f2ba0e3396f7 100644 --- a/tests/e2e/tests/generate/module/module-routing-child-folder.ts +++ b/tests/e2e/tests/generate/module/module-routing-child-folder.ts @@ -1,27 +1,24 @@ -import * as fs from 'fs-extra'; -import { join } from 'path'; +import { join } from 'node:path'; import { ng } from '../../../utils/process'; import { expectFileToExist } from '../../../utils/fs'; import { expectToFail } from '../../../utils/utils'; +import { useCIChrome, useCIDefaults } from '../../../utils/project'; +export default async function () { + const projectName = 'test-project-two'; + await ng('generate', 'application', projectName, '--no-standalone', '--skip-install'); + await useCIDefaults(projectName); + await useCIChrome(projectName, 'projects/test-project-two'); -export default function () { - const root = process.cwd(); - const testPath = join(root, 'src', 'app'); - + const testPath = join(process.cwd(), `projects/${projectName}/src/app`); process.chdir(testPath); - fs.mkdirSync('./sub-dir'); - return Promise.resolve() - .then(() => - ng('generate', 'module', 'sub-dir/child', '--routing') - .then(() => expectFileToExist(join(testPath, 'sub-dir/child'))) - .then(() => expectFileToExist(join(testPath, 'sub-dir/child', 'child.module.ts'))) - .then(() => expectFileToExist(join(testPath, 'sub-dir/child', 'child-routing.module.ts'))) - .then(() => expectToFail(() => - expectFileToExist(join(testPath, 'sub-dir/child', 'child.spec.ts')) - )) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')) - ); + await ng('generate', 'module', 'sub-dir/child', '--routing'); + await expectFileToExist(join(testPath, 'sub-dir/child')); + await expectFileToExist(join(testPath, 'sub-dir/child', 'child-module.ts')); + await expectFileToExist(join(testPath, 'sub-dir/child', 'child-routing-module.ts')); + await expectToFail(() => expectFileToExist(join(testPath, 'sub-dir/child', 'child.spec.ts'))); + + // Try to run the unit tests. + await ng('test', projectName, '--watch=false'); } diff --git a/tests/e2e/tests/generate/module/module-routing.ts b/tests/e2e/tests/generate/module/module-routing.ts deleted file mode 100644 index 2c18254f2042..000000000000 --- a/tests/e2e/tests/generate/module/module-routing.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - const moduleDir = join('src', 'app', 'test'); - - return ng('generate', 'module', 'test', '--routing') - .then(() => expectFileToExist(moduleDir)) - .then(() => expectFileToExist(join(moduleDir, 'test.module.ts'))) - .then(() => expectFileToExist(join(moduleDir, 'test-routing.module.ts'))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts')))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/pipe/pipe-basic.ts b/tests/e2e/tests/generate/pipe/pipe-basic.ts index 18a8d106125f..2ddb3ff4225f 100644 --- a/tests/e2e/tests/generate/pipe/pipe-basic.ts +++ b/tests/e2e/tests/generate/pipe/pipe-basic.ts @@ -1,17 +1,19 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; +import { expectFileToExist } from '../../../utils/fs'; -export default function() { +export default function () { // Create the pipe in the same directory. const pipeDir = join('src', 'app'); - return ng('generate', 'pipe', 'test-pipe') - .then(() => expectFileToExist(pipeDir)) - .then(() => expectFileToExist(join(pipeDir, 'test-pipe.pipe.ts'))) - .then(() => expectFileToExist(join(pipeDir, 'test-pipe.pipe.spec.ts'))) + return ( + ng('generate', 'pipe', 'test') + .then(() => expectFileToExist(pipeDir)) + .then(() => expectFileToExist(join(pipeDir, 'test-pipe.ts'))) + .then(() => expectFileToExist(join(pipeDir, 'test-pipe.spec.ts'))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/pipe/pipe-in-existing-module-dir.ts b/tests/e2e/tests/generate/pipe/pipe-in-existing-module-dir.ts deleted file mode 100644 index 2055643a1b5b..000000000000 --- a/tests/e2e/tests/generate/pipe/pipe-in-existing-module-dir.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -export default function () { - const modulePath = join('src', 'app', 'foo', 'foo.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'foo')) - .then(() => ng('generate', 'pipe', 'foo', '--no-flat')) - .then(() => expectFileToMatch(modulePath, /import { FooPipe } from '.\/foo.pipe'/)); -} diff --git a/tests/e2e/tests/generate/pipe/pipe-module-export.ts b/tests/e2e/tests/generate/pipe/pipe-module-export.ts deleted file mode 100644 index ae0c1348dfbd..000000000000 --- a/tests/e2e/tests/generate/pipe/pipe-module-export.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'pipe', 'test-pipe', '--export') - .then(() => expectFileToMatch(modulePath, 'exports: [TestPipePipe]')) - - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/generate/pipe/pipe-module-fail.ts b/tests/e2e/tests/generate/pipe/pipe-module-fail.ts deleted file mode 100644 index e18d5e73d038..000000000000 --- a/tests/e2e/tests/generate/pipe/pipe-module-fail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'pipe', 'test-pipe', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/e2e/tests/generate/pipe/pipe-module.ts b/tests/e2e/tests/generate/pipe/pipe-module.ts deleted file mode 100644 index c77d2d056943..000000000000 --- a/tests/e2e/tests/generate/pipe/pipe-module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as fs from 'fs-extra'; -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - - fs.mkdirSync('./src/app/sub-dir'); - - return ng('generate', 'pipe', 'test-pipe', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestPipePipe } from '.\/test-pipe.pipe'/)) - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'pipe', 'test-pipe2', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { TestPipe2Pipe } from '.\/test-pipe2.pipe'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/generate/schematic-aliases.ts b/tests/e2e/tests/generate/schematic-aliases.ts new file mode 100644 index 000000000000..926c96d7aacc --- /dev/null +++ b/tests/e2e/tests/generate/schematic-aliases.ts @@ -0,0 +1,14 @@ +import { ng } from '../../utils/process'; + +export default async function () { + const schematicNameVariation = [ + 'component', + 'c', + '@schematics/angular:component', + '@schematics/angular:c', + ]; + + for (const schematic of schematicNameVariation) { + await ng('generate', schematic, 'comp-name', '--display-block', '--dry-run'); + } +} diff --git a/tests/e2e/tests/generate/schematic-defaults.ts b/tests/e2e/tests/generate/schematic-defaults.ts new file mode 100644 index 000000000000..a6fc32a8c52e --- /dev/null +++ b/tests/e2e/tests/generate/schematic-defaults.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await updateJsonFile('angular.json', (config) => { + config.projects['test-project'].schematics = { + '@schematics/angular:component': { + style: 'scss', + }, + }; + }); + + // Generate component in application to verify that it's minimal + const { stdout } = await ng('generate', 'component', 'foo'); + assert.match(stdout, /foo\.scss/); + + // Generate another project with different settings + await ng('generate', 'application', 'test-project-two', '--no-minimal'); + + await updateJsonFile('angular.json', (config) => { + config.projects['test-project-two'].schematics = { + '@schematics/angular:component': { + style: 'less', + type: 'Component', + }, + }; + }); + + const { stdout: stdout2 } = await ng( + 'generate', + 'component', + 'foo', + '--project', + 'test-project-two', + ); + assert.match(stdout2, /foo\.component\.less/); +} diff --git a/tests/e2e/tests/generate/schematic-force-override.ts b/tests/e2e/tests/generate/schematic-force-override.ts new file mode 100644 index 000000000000..d3e9e1b7d947 --- /dev/null +++ b/tests/e2e/tests/generate/schematic-force-override.ts @@ -0,0 +1,51 @@ +import { appendFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { getActivePackageManager, installWorkspacePackages } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { isPrereleaseCli, updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isPrerelease = await isPrereleaseCli(); + let tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Angular material adds dependencies on other Angular packages + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries(snapshots.dependencies)) { + if (name in dependencies) { + dependencies[name] = version; + } + } + }); + await installWorkspacePackages(); + } + + const args: string[] = [ + 'generate', + '@angular/material:theme-color', + '--primary-color=#0641e6', + '--tertiary-color=#994aff', + '--neutral-color=#313138', + '--error-color=#eb5757', + '--secondary-color=#009096', + '--neutral-variant-color=#b2b2b8', + ]; + + await ng(...args); + + // Should fail as file exists + await expectToFail(() => ng(...args)); + + await ng(...args, '--force'); +} diff --git a/tests/e2e/tests/generate/schematics-collections-relative.ts b/tests/e2e/tests/generate/schematics-collections-relative.ts new file mode 100644 index 000000000000..f6f583bf0e72 --- /dev/null +++ b/tests/e2e/tests/generate/schematics-collections-relative.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert'; +import { join } from 'node:path'; +import { ng } from '../../utils/process'; +import { writeMultipleFiles, createDir } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + // setup temp collection + await createDir('./fake-schematics'); + await writeMultipleFiles({ + './fake-schematics/package.json': JSON.stringify({ + 'schematics': './collection.json', + }), + './fake-schematics/collection.json': JSON.stringify({ + 'schematics': { + 'fake': { + 'description': 'Fake schematic', + 'schema': './fake-schema.json', + 'factory': './fake', + }, + }, + }), + './fake-schematics/fake-schema.json': JSON.stringify({ + '$id': 'FakeSchema', + 'title': 'Fake Schema', + 'type': 'object', + }), + './fake-schematics/fake.js': ` + exports.default = () => (host, context) => context.logger.info('fake schematic run.'); + `, + }); + + await updateJsonFile('angular.json', (json) => { + json.cli ??= {}; + json.cli.schematicCollections = ['./fake-schematics']; + }); + + const { stdout: stdout1 } = await ng('generate', '--help'); + assert.match(stdout1, /Fake schematic/); + + const { stdout: stdout2 } = await ng('generate', 'fake'); + assert.match(stdout2, /fake schematic run/); + + // change cwd to a nested directory to validate the relative schematic is resolved correctly + const originalCwd = process.cwd(); + try { + process.chdir(join(originalCwd, 'src/app')); + const { stdout: stdout3 } = await ng('generate', 'fake'); + assert.match(stdout3, /fake schematic run/); + } finally { + process.chdir(originalCwd); + } +} diff --git a/tests/e2e/tests/generate/schematics-collections.ts b/tests/e2e/tests/generate/schematics-collections.ts new file mode 100644 index 000000000000..6c009bf91e5d --- /dev/null +++ b/tests/e2e/tests/generate/schematics-collections.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { createDir, expectFileToExist, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + // setup temp collection + const genRoot = join('node_modules/fake-schematics/'); + const fakeComponentSchematicDesc = 'Fake component schematic'; + + await createDir(genRoot); + await writeMultipleFiles({ + [join(genRoot, 'package.json')]: JSON.stringify({ + 'schematics': './collection.json', + }), + [join(genRoot, 'collection.json')]: JSON.stringify({ + 'schematics': { + 'fake': { + 'description': 'Fake schematic', + 'schema': './fake-schema.json', + 'factory': './fake', + }, + 'component': { + 'description': fakeComponentSchematicDesc, + 'schema': './fake-schema.json', + 'factory': './fake-component', + }, + }, + }), + [join(genRoot, 'fake-schema.json')]: JSON.stringify({ + '$id': 'FakeSchema', + 'title': 'Fake Schema', + 'type': 'object', + }), + [join(genRoot, 'fake.js')]: ` + exports.default = function (options) { + return (host, context) => { + console.log('fake schematic run.'); + }; + } + `, + [join(genRoot, 'fake-component.js')]: ` + exports.default = function (options) { + return (host, context) => { + console.log('fake component schematic run.'); + }; + } + `, + }); + + await updateJsonFile('angular.json', (json) => { + json.cli ??= {}; + json.cli.schematicCollections = ['fake-schematics', '@schematics/angular']; + }); + + // should display schematics for all schematics + const { stdout: stdout1 } = await ng('generate', '--help'); + assert.match(stdout1, /ng generate component/); + assert.match(stdout1, /ng generate fake/); + + // check registration order. Both schematics contain a component schematic verify that the first one wins. + assert.match(stdout1, new RegExp(fakeComponentSchematicDesc)); + + // Verify execution based on ordering + const { stdout: stdout2 } = await ng('generate', 'component'); + assert.match(stdout2, /fake component schematic run/); + + await updateJsonFile('angular.json', (json) => { + json.cli ??= {}; + json.cli.schematicCollections = ['@schematics/angular', 'fake-schematics']; + }); + + const { stdout: stdout3 } = await ng('generate', '--help'); + assert.match(stdout3, /ng generate component \[name\]/); + assert.doesNotMatch(stdout3, new RegExp(fakeComponentSchematicDesc)); + + // Verify execution based on ordering + const projectDir = join('src', 'app'); + const componentDir = join(projectDir, 'test-component'); + await ng('generate', 'component', 'test-component'); + await expectFileToExist(componentDir); +} diff --git a/tests/e2e/tests/generate/service/service-basic.ts b/tests/e2e/tests/generate/service/service-basic.ts index 273bcfb48bfd..a7ddb3cb8310 100644 --- a/tests/e2e/tests/generate/service/service-basic.ts +++ b/tests/e2e/tests/generate/service/service-basic.ts @@ -1,17 +1,18 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; +import { join } from 'node:path'; +import { ng } from '../../../utils/process'; +import { expectFileToExist } from '../../../utils/fs'; - -export default function() { +export default function () { // Does not create a sub directory. const serviceDir = join('src', 'app'); - return ng('generate', 'service', 'test-service') - .then(() => expectFileToExist(serviceDir)) - .then(() => expectFileToExist(join(serviceDir, 'test-service.service.ts'))) - .then(() => expectFileToExist(join(serviceDir, 'test-service.service.spec.ts'))) + return ( + ng('generate', 'service', 'test-service') + .then(() => expectFileToExist(serviceDir)) + .then(() => expectFileToExist(join(serviceDir, 'test-service.ts'))) + .then(() => expectFileToExist(join(serviceDir, 'test-service.spec.ts'))) - // Try to run the unit tests. - .then(() => ng('test', '--single-run')); + // Try to run the unit tests. + .then(() => ng('test', '--watch=false')) + ); } diff --git a/tests/e2e/tests/generate/service/service-dry-run.ts b/tests/e2e/tests/generate/service/service-dry-run.ts deleted file mode 100644 index b429c3e3cb45..000000000000 --- a/tests/e2e/tests/generate/service/service-dry-run.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectGitToBeClean} from '../../../utils/git'; - - -export default function() { - return ng('generate', 'service', 'test-service', '--module', 'app.module.ts', '--dry-run') - .then(() => expectGitToBeClean()); -} diff --git a/tests/e2e/tests/generate/service/service-module-fail.ts b/tests/e2e/tests/generate/service/service-module-fail.ts deleted file mode 100644 index 18f16e100ec7..000000000000 --- a/tests/e2e/tests/generate/service/service-module-fail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'service', 'test-service', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/e2e/tests/generate/service/service-module.ts b/tests/e2e/tests/generate/service/service-module.ts deleted file mode 100644 index 27723e9b6e69..000000000000 --- a/tests/e2e/tests/generate/service/service-module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as fs from 'fs-extra'; -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - - fs.mkdirSync('./src/app/sub-dir'); - - return ng('generate', 'service', 'test-service', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestServiceService } from '.\/test-service.service'/)) - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'service', 'test-service2', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { TestService2Service } from '.\/test-service2.service'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/e2e/tests/i18n/extract-default.ts b/tests/e2e/tests/i18n/extract-default.ts deleted file mode 100644 index fc35236952a2..000000000000 --- a/tests/e2e/tests/i18n/extract-default.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n')) - .then(() => expectFileToExist(join('src', 'messages.xlf'))) - .then(() => expectFileToMatch(join('src', 'messages.xlf'), /Hello world/)); -} diff --git a/tests/e2e/tests/i18n/extract-ivy-disk-cache.ts b/tests/e2e/tests/i18n/extract-ivy-disk-cache.ts new file mode 100644 index 000000000000..562a481086d5 --- /dev/null +++ b/tests/e2e/tests/i18n/extract-ivy-disk-cache.ts @@ -0,0 +1,51 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch, rimraf, writeFile } from '../../utils/fs'; +import { installPackage, uninstallPackage } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { readNgVersion } from '../../utils/version'; + +export default async function () { + // Enable disk cache + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = { environment: 'all' }; + }); + + // Setup an i18n enabled component + await ng('generate', 'component', 'i18n-test'); + await writeFile(join('src/app/i18n-test', 'i18n-test.html'), '

Hello world

'); + + await writeFile( + 'src/app/app.ts', + ` + import { Component } from '@angular/core'; + import { I18nTest } from './i18n-test/i18n-test'; + + @Component({ + selector: 'app-root', + imports: [I18nTest], + template: '' + }) + export class App {} + `, + ); + + // Install correct version + let localizeVersion = '@angular/localize@' + readNgVersion(); + if (getGlobalVariable('argv')['ng-snapshots']) { + localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize']; + } + + await installPackage(localizeVersion); + + for (let i = 0; i < 2; i++) { + // Run the extraction twice and make sure the second time round works with cache. + await rimraf('messages.xlf'); + await ng('extract-i18n'); + await expectFileToMatch('messages.xlf', 'Hello world'); + } + + await uninstallPackage('@angular/localize'); +} diff --git a/tests/e2e/tests/i18n/extract-ivy-libraries.ts b/tests/e2e/tests/i18n/extract-ivy-libraries.ts new file mode 100644 index 000000000000..a577a8c5e6a6 --- /dev/null +++ b/tests/e2e/tests/i18n/extract-ivy-libraries.ts @@ -0,0 +1,42 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch, prependToFile, replaceInFile, writeFile } from '../../utils/fs'; +import { installPackage, uninstallPackage } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { readNgVersion } from '../../utils/version'; + +export default async function () { + // Setup a library + await ng('generate', 'library', 'i18n-lib-test'); + await replaceInFile('projects/i18n-lib-test/src/lib/i18n-lib-test.ts', '

', '

'); + + // Build library + await ng('build', 'i18n-lib-test', '--configuration=development'); + + // Consume library in application + await replaceInFile('src/app/app.ts', 'imports: [', 'imports: [I18nLibTest,'); + await prependToFile('src/app/app.ts', `import { I18nLibTest } from 'i18n-lib-test';`); + + await writeFile( + 'src/app/app.html', + ` +

Hello world

+ + `, + ); + + // Install correct version + let localizeVersion = '@angular/localize@' + readNgVersion(); + if (getGlobalVariable('argv')['ng-snapshots']) { + localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize']; + } + await installPackage(localizeVersion); + + // Extract messages + await ng('extract-i18n'); + await expectFileToMatch('messages.xlf', 'Hello world'); + await expectFileToMatch('messages.xlf', 'i18n-lib-test works!'); + await expectFileToMatch('messages.xlf', 'src/app/app.html'); + await expectFileToMatch('messages.xlf', 'projects/i18n-lib-test/src/lib/i18n-lib-test.ts'); + + await uninstallPackage('@angular/localize'); +} diff --git a/tests/e2e/tests/i18n/extract-ivy.ts b/tests/e2e/tests/i18n/extract-ivy.ts new file mode 100644 index 000000000000..cf0d23da08e3 --- /dev/null +++ b/tests/e2e/tests/i18n/extract-ivy.ts @@ -0,0 +1,65 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { deleteFile, expectFileToMatch, writeFile } from '../../utils/fs'; +import { uninstallPackage } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; +import { readNgVersion } from '../../utils/version'; + +export default async function () { + // Setup an i18n enabled component + await ng('generate', 'component', 'i18n-test'); + await writeFile(join('src/app/i18n-test', 'i18n-test.html'), '

Hello world

'); + // Actually use the generated component to ensure it is present in the application output + await writeFile( + 'src/app/app.ts', + ` + import { Component } from '@angular/core'; + import { I18nTest } from './i18n-test/i18n-test'; + + @Component({ + selector: 'app-root', + imports: [I18nTest], + template: '' + }) + export class App {} + `, + ); + + // Ensure localize package is not present initially + await uninstallPackage('@angular/localize'); + + // Should fail if `@angular/localize` is missing + const { message: message1 } = await expectToFail(() => ng('extract-i18n')); + if (!message1.includes(`i18n extraction requires the '@angular/localize' package.`)) { + throw new Error('Expected localize package error message when missing'); + } + + // Install correct version + let localizeVersion = '@angular/localize@' + readNgVersion(); + if (getGlobalVariable('argv')['ng-snapshots']) { + // The snapshots job won't work correctly because 'ng add' doesn't support github URLs + // localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize']; + return; + } + await ng('add', localizeVersion, '--skip-confirmation'); + + // Should not show any warnings when extracting + const { stderr: message5 } = await ng('extract-i18n'); + if (message5.includes('WARNING')) { + throw new Error('Expected no warnings to be shown. STDERR:\n' + message5); + } + + await expectFileToMatch('messages.xlf', 'Hello world'); + + // Template messages should also be extracted when the build target disables AOT + await updateJsonFile('angular.json', (workspaceJson) => { + workspaceJson.projects['test-project'].architect.build.options.aot = false; + }); + await deleteFile('messages.xlf'); + await ng('extract-i18n'); + await expectFileToMatch('messages.xlf', 'Hello world'); + + await uninstallPackage('@angular/localize'); +} diff --git a/tests/e2e/tests/i18n/extract-locale.ts b/tests/e2e/tests/i18n/extract-locale.ts deleted file mode 100644 index 16df41a68957..000000000000 --- a/tests/e2e/tests/i18n/extract-locale.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n', '--locale', 'fr')) - .then((output) => { - if (!output.stdout.match(/starting from Angular v4/)) { - expectFileToExist(join('src', 'messages.xlf')); - expectFileToMatch(join('src', 'messages.xlf'), /source-language="fr"/); - } - }); -} diff --git a/tests/e2e/tests/i18n/extract-outfile.ts b/tests/e2e/tests/i18n/extract-outfile.ts deleted file mode 100644 index 444c0e1c70fc..000000000000 --- a/tests/e2e/tests/i18n/extract-outfile.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n', '--out-file', 'messages.fr.xlf')) - .then((output) => { - if (!output.stdout.match(/starting from Angular v4/)) { - expectFileToExist(join('src', 'messages.fr.xlf')); - expectFileToMatch(join('src', 'messages.fr.xlf'), /Hello world/); - } - }); -} diff --git a/tests/e2e/tests/i18n/extract-output.ts b/tests/e2e/tests/i18n/extract-output.ts deleted file mode 100644 index df11befed240..000000000000 --- a/tests/e2e/tests/i18n/extract-output.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n', '--output-path', 'src/locale')) - .then(() => expectFileToExist(join('src', 'locale', 'messages.xlf'))) - .then(() => expectFileToMatch(join('src', 'locale', 'messages.xlf'), /Hello world/)); -} diff --git a/tests/e2e/tests/i18n/extract-xmb.ts b/tests/e2e/tests/i18n/extract-xmb.ts deleted file mode 100644 index 368a4e7edaef..000000000000 --- a/tests/e2e/tests/i18n/extract-xmb.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n', '--i18n-format', 'xmb')) - .then(() => expectFileToExist(join('src', 'messages.xmb'))) - .then(() => expectFileToMatch(join('src', 'messages.xmb'), /Hello world/)); -} diff --git a/tests/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts b/tests/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts new file mode 100644 index 000000000000..b1568be95939 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts @@ -0,0 +1,85 @@ +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, createDir, expectFileToMatch, writeFile } from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { ng, silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { readNgVersion } from '../../utils/version'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/localize'] = isSnapshotBuild + ? snapshots.dependencies['@angular/localize'] + : readNgVersion(); + }); + + await appendToFile('src/app/app.html', ''); + + // Add app-shell and service-worker + await silentNg('generate', 'app-shell'); + await silentNg('generate', 'service-worker'); + + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/platform-server'] = snapshots.dependencies['@angular/platform-server']; + dependencies['@angular/service-worker'] = snapshots.dependencies['@angular/service-worker']; + dependencies['@angular/router'] = snapshots.dependencies['@angular/router']; + }); + } + + await installWorkspacePackages(); + + // Set configurations for each locale. + const langTranslations = [ + { lang: 'en-US', translation: 'Hello i18n!' }, + { lang: 'fr', translation: 'Bonjour i18n!' }, + ]; + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const appArchitect = appProject.architect; + const buildOptions = appArchitect['build'].options; + + // Enable localization for all locales + buildOptions.localize = true; + + // Add locale definitions to the project + const i18n: Record = (appProject.i18n = { locales: {} }); + for (const { lang } of langTranslations) { + if (lang == 'en-US') { + i18n.sourceLocale = lang; + } else { + i18n.locales[lang] = `src/locale/messages.${lang}.xlf`; + } + } + }); + + await createDir('src/locale'); + + for (const { lang } of langTranslations) { + // dummy translation file. + await writeFile( + `src/locale/messages.${lang}.xlf`, + ` + + + + `, + ); + } + + // Build each locale and verify the SW output. + await ng('build', '--output-hashing=none'); + + for (const { lang } of langTranslations) { + await Promise.all([ + expectFileToMatch(`dist/test-project/browser/${lang}/ngsw.json`, `/${lang}/main.js`), + expectFileToMatch(`dist/test-project/browser/${lang}/ngsw.json`, `/${lang}/index.html`), + ]); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-app-shell.ts b/tests/e2e/tests/i18n/ivy-localize-app-shell.ts new file mode 100644 index 000000000000..cbabbae17140 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-app-shell.ts @@ -0,0 +1,108 @@ +import { getGlobalVariable } from '../../utils/env'; +import { + appendToFile, + copyFile, + expectFileToMatch, + replaceInFile, + writeFile, +} from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { readNgVersion } from '../../utils/version'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/localize'] = isSnapshotBuild + ? snapshots.dependencies['@angular/localize'] + : readNgVersion(); + }); + + await appendToFile('src/app/app.html', ''); + await ng('generate', 'app-shell', '--project', 'test-project'); + + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/platform-server'] = snapshots.dependencies['@angular/platform-server']; + dependencies['@angular/router'] = snapshots.dependencies['@angular/router']; + }); + } + + await installWorkspacePackages(); + + // Set configurations for each locale. + const langTranslations = [ + { lang: 'en-US', translation: 'Hello i18n!' }, + { lang: 'fr', translation: 'Bonjour i18n!' }, + ]; + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const appArchitect = appProject.architect || appProject.targets; + const buildOptions = appArchitect['build'].options; + + // Enable localization for all locales + buildOptions.localize = true; + + // Add locale definitions to the project + const i18n: Record = (appProject.i18n = { locales: {} }); + for (const { lang } of langTranslations) { + if (lang == 'en-US') { + i18n.sourceLocale = lang; + } else { + i18n.locales[lang] = `src/locale/messages.${lang}.xlf`; + } + } + }); + + await writeFile( + 'src/app/app-shell/app-shell.html', + '

Hello i18n!

', + ); + + // Add a translatable element + // Extraction of i18n only works on browser targets. + // Let's add the same translation that there is in the app-shell + await writeFile( + 'src/app/app.html', + '

Hello i18n!

', + ); + + // Extract the translation messages and copy them for each language. + await ng('extract-i18n', '--output-path=src/locale'); + await expectFileToMatch('src/locale/messages.xlf', `source-language="en-US"`); + await expectFileToMatch('src/locale/messages.xlf', `An introduction header for this sample`); + + // Clean up app.ng.html so that we can easily + // find the translation text + await writeFile('src/app/app.html', ''); + + for (const { lang, translation } of langTranslations) { + if (lang != 'en-US') { + await copyFile('src/locale/messages.xlf', `src/locale/messages.${lang}.xlf`); + await replaceInFile( + `src/locale/messages.${lang}.xlf`, + 'source-language="en-US"', + `source-language="en-US" target-language="${lang}"`, + ); + await replaceInFile( + `src/locale/messages.${lang}.xlf`, + 'Hello i18n!', + `Hello i18n!\n${translation}`, + ); + } + } + + // Build each locale and verify the output. + await ng('build', '--output-mode=static'); + + for (const { lang, translation } of langTranslations) { + await expectFileToMatch(`dist/test-project/browser/${lang}/index.html`, translation); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-basehref-absolute.ts b/tests/e2e/tests/i18n/ivy-localize-basehref-absolute.ts new file mode 100644 index 000000000000..db59853ff759 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-basehref-absolute.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { langTranslations, setupI18nConfig } from './setup'; + +const baseHrefs: { [l: string]: string } = { + 'en-US': '/en/', + fr: '/fr-FR/', + de: '', +}; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + // tslint:disable-next-line: no-any + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = { + baseHref: baseHrefs['en-US'], + }; + + i18n.locales['fr'] = { + translation: i18n.locales['fr'], + baseHref: baseHrefs['fr'], + }; + + i18n.locales['de'] = { + translation: i18n.locales['de'], + baseHref: baseHrefs['de'], + }; + }); + + // Test absolute base href. + await ng('build', '--base-href', 'http://www.example.com/', '--configuration=development'); + for (const { lang, outputPath } of langTranslations) { + // Verify the HTML base HREF attribute is present + await expectFileToMatch( + `${outputPath}/index.html`, + `href="http://www.example.com${baseHrefs[lang] || '/'}"`, + ); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-basehref.ts b/tests/e2e/tests/i18n/ivy-localize-basehref.ts new file mode 100644 index 000000000000..4bbd7dece3ee --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-basehref.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; +import { + baseHrefs, + browserCheck, + externalServer, + langTranslations, + setupI18nConfig, +} from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + // tslint:disable-next-line: no-any + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = { + baseHref: baseHrefs['en-US'], + }; + + i18n.locales['fr'] = { + translation: i18n.locales['fr'], + baseHref: baseHrefs['fr'], + }; + + i18n.locales['de'] = { + translation: i18n.locales['de'], + baseHref: baseHrefs['de'], + }; + }); + + // Build each locale and verify the output. + await ng('build'); + for (const { lang, outputPath } of langTranslations) { + if (baseHrefs[lang] === undefined) { + throw new Error('Invalid E2E test setup: unexpected locale ' + lang); + } + + // Verify the HTML lang attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`); + + // Verify the HTML base HREF attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `href="${baseHrefs[lang] || '/'}"`); + + // Execute Application E2E tests for a production build without dev server + const { server, url } = await externalServer(outputPath, (baseHrefs[lang] as string) || '/'); + try { + await executeBrowserTest({ + baseUrl: url, + checkFn: (page) => browserCheck(page, lang), + }); + } finally { + server.close(); + } + } + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + + appArchitect['build'].options.baseHref = '/test/'; + }); + + // Build each locale and verify the output. + await ng('build', '--configuration=development'); + for (const { lang, outputPath } of langTranslations) { + // Verify the HTML base HREF attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `href="/test${baseHrefs[lang] || '/'}"`); + + // Execute Application E2E tests for a production build without dev server + const { server, url } = await externalServer(outputPath, '/test' + (baseHrefs[lang] || '/')); + try { + await executeBrowserTest({ + baseUrl: url, + checkFn: (page) => browserCheck(page, lang), + }); + } finally { + server.close(); + } + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-es2015-e2e.ts b/tests/e2e/tests/i18n/ivy-localize-es2015-e2e.ts new file mode 100644 index 000000000000..d1f7ac8e28b8 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-es2015-e2e.ts @@ -0,0 +1,14 @@ +import { executeBrowserTest } from '../../utils/puppeteer'; +import { browserCheck, langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + for (const { lang } of langTranslations) { + await executeBrowserTest({ + configuration: lang, + checkFn: (page) => browserCheck(page, lang), + }); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-es2015.ts b/tests/e2e/tests/i18n/ivy-localize-es2015.ts new file mode 100644 index 000000000000..cea87a75f2b8 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-es2015.ts @@ -0,0 +1,46 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { executeBrowserTest } from '../../utils/puppeteer'; +import { expectToFail } from '../../utils/utils'; +import { browserCheck, externalServer, langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + const { stderr } = await ng('build'); + if (/Locale data for .+ cannot be found/.test(stderr)) { + throw new Error( + `A warning for a locale not found was shown. This should not happen.\n\nSTDERR:\n${stderr}\n`, + ); + } + + for (const { lang, outputPath, translation } of langTranslations) { + await expectFileToMatch(`${outputPath}/main.js`, translation.helloPartial); + await expectToFail(() => expectFileToMatch(`${outputPath}/main.js`, '$localize`')); + + // Ensure locale is inlined (@angular/localize plugin inlines `$localize.locale` references) + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // The only reference in a new application with Webpack is in @angular/core + await expectFileToMatch(`${outputPath}/vendor.js`, lang); + } else { + await expectFileToMatch(`${outputPath}/polyfills.js`, lang); + } + + // Verify the HTML lang attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`); + + // Execute Application E2E tests for a production build without dev server + const { server, url } = await externalServer(outputPath, `/${lang}/`); + try { + await executeBrowserTest({ + baseUrl: url, + checkFn: (page) => browserCheck(page, lang), + }); + } finally { + server.close(); + } + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-hashes.ts b/tests/e2e/tests/i18n/ivy-localize-hashes.ts new file mode 100644 index 000000000000..2f682ca76988 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-hashes.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import * as fs from 'node:fs'; +import { appendToFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { langTranslations, setupI18nConfig } from './setup'; + +const OUTPUT_RE = /^(?(?:main|vendor|\d+))(?:\.|-)(?[a-z0-9]+)\.js$/i; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Build each locale and record output file hashes + const hashes = new Map(); + await ng('build', '--output-hashing=all', '--configuration=development'); + for (const { lang, outputPath } of langTranslations) { + for (const entry of fs.readdirSync(outputPath)) { + const match = entry.match(OUTPUT_RE); + if (!match?.groups) { + continue; + } + + hashes.set(`${lang}/${match.groups.name}`, match.groups.hash); + } + } + + // Ensure hashes for output files were recorded + if (hashes.size === 0) { + throw new Error('No output entries found.'); + } + + // Alter content of a used translation file + await appendToFile('src/locale/messages.fr.xlf', '\n'); + + // Build each locale and ensure hashes are different + await ng('build', '--output-hashing=all', '--configuration=development'); + for (const { lang, outputPath } of langTranslations) { + for (const entry of fs.readdirSync(outputPath)) { + const match = entry.match(OUTPUT_RE); + if (!match?.groups) { + continue; + } + + const id = `${lang}/${match.groups.name}`; + const hash = hashes.get(id); + if (!hash) { + throw new Error('Unexpected output entry: ' + id); + } + if (hash === match.groups!.hash) { + throw new Error('Hash value did not change for entry: ' + id); + } + + hashes.delete(id); + } + } + + // Check for missing entries in second build + if (hashes.size > 0) { + throw new Error('Missing output entries: ' + JSON.stringify(Array.from(hashes.values()))); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-locale-data-augment.ts b/tests/e2e/tests/i18n/ivy-localize-locale-data-augment.ts new file mode 100644 index 000000000000..b4c0ae72ced4 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-locale-data-augment.ts @@ -0,0 +1,63 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch, prependToFile, readFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; +import { browserCheck, langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json to only localize one locale + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + appProject.architect['build'].options.localize = ['fr']; + }); + + // Augment the locale data and import into the main application file + const localeData = await readFile('node_modules/@angular/common/locales/global/fr.js'); + await writeFile('src/fr-changed.js', localeData.replace('janvier', 'changed-janvier')); + await prependToFile('src/main.ts', "import './fr-changed.js';\n"); + + // Run a build and test + await ng('build'); + for (const { lang, outputPath } of langTranslations) { + // Only the fr locale was built for this test + if (lang !== 'fr') { + continue; + } + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // The only reference in a new application with Webpack is in @angular/core + await expectFileToMatch(`${outputPath}/vendor.js`, lang); + } else { + await expectFileToMatch(`${outputPath}/polyfills.js`, lang); + } + + // Execute Application E2E tests with dev server + await executeBrowserTest({ + configuration: lang, + checkFn: async (page) => { + // Run standard checks but expect failure on date + try { + await browserCheck(page, lang); + throw new Error('Expected browserCheck to fail due to modified locale data'); + } catch (e) { + if (!(e instanceof Error) || !e.message.includes("Expected 'date' to be")) { + throw e; + } + } + + // Verify the modified date + const getParagraph = async (id: string) => + page.$eval(`p#${id}`, (el) => el.textContent?.trim()); + const date = await getParagraph('date'); + if (date !== 'changed-janvier') { + throw new Error(`Expected 'date' to be 'changed-janvier', but got '${date}'.`); + } + }, + }); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-locale-data.ts b/tests/e2e/tests/i18n/ivy-localize-locale-data.ts new file mode 100644 index 000000000000..d378ba5c56a9 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-locale-data.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + // tslint:disable-next-line: no-any + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = 'fr-Abcd'; + appProject.architect['build'].options.localize = ['fr-Abcd']; + }); + + const { stderr: err1 } = await ng('build'); + if (!err1.includes(`Locale data for 'fr-Abcd' cannot be found. Using locale data for 'fr'.`)) { + throw new Error('locale data fallback warning not shown'); + } + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + // tslint:disable-next-line: no-any + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = 'en-US'; + appProject.architect['build'].options.localize = ['en-US']; + }); + + const { stderr: err2 } = await ng('build'); + if ( + err2.includes( + `Locale data for 'en-US' cannot be found. No locale data will be included for this locale.`, + ) + ) { + throw new Error('locale data not found warning shown'); + } + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = 'en-x-abc'; + appProject.architect['build'].options.localize = ['en-x-abc']; + }); + + const { stderr: err3 } = await ng('build', '--configuration=development'); + if ( + err3.includes( + `Locale data for 'en-x-abc' cannot be found. No locale data will be included for this locale.`, + ) + ) { + throw new Error('locale data not found warning shown'); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-merging.ts b/tests/e2e/tests/i18n/ivy-localize-merging.ts new file mode 100644 index 000000000000..f4e016951094 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-merging.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; +import { setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const i18n: Record = appProject.i18n; + + i18n.locales['fr'] = [i18n.locales['fr'], i18n.locales['fr']]; + appProject.architect['build'].options.localize = ['fr']; + }); + + const { stderr: err1 } = await ng('build'); + if (!err1.includes('Duplicate translations for message')) { + throw new Error('duplicate translations warning not shown'); + } + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + appProject.architect['build'].options.i18nDuplicateTranslation = 'error'; + }); + await expectToFail(() => ng('build')); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + appProject.architect['build'].options.i18nDuplicateTranslation = 'ignore'; + }); + const { stderr: err2 } = await ng('build'); + if (err2.includes('Duplicate translations for message')) { + throw new Error('duplicate translations message not ignore'); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-sourcelocale.ts b/tests/e2e/tests/i18n/ivy-localize-sourcelocale.ts new file mode 100644 index 000000000000..e88fc571e289 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-sourcelocale.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + // tslint:disable-next-line: no-any + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = 'fr'; + + delete i18n.locales['fr']; + }); + + // Build each locale and verify the output. + await ng('build', '--configuration=development'); + for (const { lang, outputPath } of langTranslations) { + // does not exist in this test due to the source locale change + if (lang === 'en-US') { + continue; + } + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // The only reference in a new application with Webpack is in @angular/core + await expectFileToMatch(`${outputPath}/vendor.js`, lang); + + // Verify the locale data is registered using the global files + await expectFileToMatch(`${outputPath}/vendor.js`, '.ng.common.locales'); + } else { + await expectFileToMatch(`${outputPath}/polyfills.js`, lang); + + // Verify the locale data is registered using the global files + await expectFileToMatch(`${outputPath}/polyfills.js`, '.ng.common.locales'); + } + + // Verify the HTML lang attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-sourcemaps.ts b/tests/e2e/tests/i18n/ivy-localize-sourcemaps.ts new file mode 100644 index 000000000000..8f65ef450c0e --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-sourcemaps.ts @@ -0,0 +1,22 @@ +import { readFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + await ng('build', '--source-map'); + + for (const { outputPath } of langTranslations) { + // Ensure sourcemap for modified file contains content + const mainSourceMap = JSON.parse(await readFile(`${outputPath}/main.js.map`)); + if ( + mainSourceMap.version !== 3 || + !Array.isArray(mainSourceMap.sources) || + typeof mainSourceMap.mappings !== 'string' + ) { + throw new Error('invalid localized sourcemap for main.js'); + } + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-ssr.ts b/tests/e2e/tests/i18n/ivy-localize-ssr.ts new file mode 100644 index 000000000000..dd0c75ae74fc --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-ssr.ts @@ -0,0 +1,80 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { getGlobalVariable } from '../../utils/env'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile, useSha } from '../../utils/project'; +import { langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // This test is for the `application` builder only + return; + } + + // Setup i18n tests and config. + await setupI18nConfig(); + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + // tslint:disable-next-line: no-any + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = { + baseHref: '', + }; + + i18n.locales['fr'] = { + translation: i18n.locales['fr'], + baseHref: '', + }; + + i18n.locales['de'] = { + translation: i18n.locales['de'], + baseHref: '', + }; + }); + + // forcibly remove in case another test doesn't clean itself up + await uninstallPackage('@angular/ssr'); + + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + + await useSha(); + await installWorkspacePackages(); + + // Build each locale and verify the output. + await ng('build', '--output-hashing=none'); + + for (const { lang, translation } of langTranslations) { + let foundTranslation = false; + let foundLocaleData = false; + + // The translation may be in any of the lazy-loaded generated chunks + for (const entry of readdirSync(`dist/test-project/server/${lang}/`)) { + if (!entry.endsWith('.mjs')) { + continue; + } + + const contents = readFileSync(`dist/test-project/server/${lang}/${entry}`, 'utf-8'); + + // Check for translated content + foundTranslation ||= contents.includes(translation.helloPartial); + // Check for the locale data month name to be present + foundLocaleData ||= contents.includes(translation.date); + + if (foundTranslation && foundLocaleData) { + break; + } + } + + if (!foundTranslation) { + throw new Error(`Translation not found in 'dist/test-project/server/${lang}/'`); + } + + if (!foundLocaleData) { + throw new Error(`Locale data not found in 'dist/test-project/server/${lang}/'`); + } + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-sub-path.ts b/tests/e2e/tests/i18n/ivy-localize-sub-path.ts new file mode 100644 index 000000000000..6116c438a49f --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-sub-path.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { join } from 'node:path'; +import { expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; +import { baseDir, browserCheck, externalServer, langTranslations, setupI18nConfig } from './setup'; + +export default async function () { + // Setup i18n tests and config. + await setupI18nConfig(); + + const URL_SUB_PATH: Record = { + 'en-US': '', + 'fr': 'fr', + 'de': 'deutsche', + }; + + // Update angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const i18n: Record = appProject.i18n; + + i18n.sourceLocale = { + subPath: URL_SUB_PATH['en-US'], + }; + + i18n.locales['fr'] = { + translation: i18n.locales['fr'], + subPath: URL_SUB_PATH['fr'], + }; + + i18n.locales['de'] = { + translation: i18n.locales['de'], + subPath: URL_SUB_PATH['de'], + }; + }); + + // Build each locale and verify the output. + await ng('build'); + for (const { lang } of langTranslations) { + const subPath = URL_SUB_PATH[lang]; + const baseHref = subPath ? `/${subPath}/` : '/'; + const outputPath = join(baseDir, subPath); + + // Verify the HTML lang attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`); + + // Verify the HTML base HREF attribute is present + await expectFileToMatch(`${outputPath}/index.html`, `href="${baseHref}"`); + + // Execute Application E2E tests for a production build without dev server + const { server, url } = await externalServer(outputPath, baseHref); + + try { + await executeBrowserTest({ + baseUrl: url, + checkFn: (page) => browserCheck(page, lang), + }); + } finally { + server.close(); + } + } +} diff --git a/tests/e2e/tests/i18n/setup.ts b/tests/e2e/tests/i18n/setup.ts new file mode 100644 index 000000000000..8eade4e2783c --- /dev/null +++ b/tests/e2e/tests/i18n/setup.ts @@ -0,0 +1,275 @@ +import express from 'express'; +import { dirname, resolve } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, copyFile, createDir, replaceInFile, writeFile } from '../../utils/fs'; +import { installPackage } from '../../utils/packages'; +import { updateJsonFile } from '../../utils/project'; +import { readNgVersion } from '../../utils/version'; +import { Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import type { Page } from 'puppeteer'; + +// Configurations for each locale. +const translationFile = 'src/locale/messages.xlf'; +export const baseDir = 'dist/test-project/browser'; +export const langTranslations = [ + { + lang: 'en-US', + outputPath: `${baseDir}/en-US`, + translation: { + helloPartial: 'Hello', + hello: 'Hello i18n!', + plural: 'Updated 3 minutes ago', + date: 'January', + }, + }, + { + lang: 'fr', + outputPath: `${baseDir}/fr`, + translation: { + helloPartial: 'Bonjour', + hello: 'Bonjour i18n!', + plural: 'Mis à jour il y a 3 minutes', + date: 'janvier', + }, + translationReplacements: [ + ['Hello', 'Bonjour'], + ['Updated', 'Mis à jour'], + ['just now', 'juste maintenant'], + ['one minute ago', 'il y a une minute'], + [/other {/g, 'other {il y a '], + ['minutes ago', 'minutes'], + ], + }, + { + lang: 'de', + outputPath: `${baseDir}/de`, + translation: { + helloPartial: 'Hallo', + hello: 'Hallo i18n!', + plural: 'Aktualisiert vor 3 Minuten', + date: 'Januar', + }, + translationReplacements: [ + ['Hello', 'Hallo'], + ['Updated', 'Aktualisiert'], + ['just now', 'gerade jetzt'], + ['one minute ago', 'vor einer Minute'], + [/other {/g, 'other {vor '], + ['minutes ago', 'Minuten'], + ], + }, +]; +export const sourceLocale = langTranslations[0].lang; + +export async function browserCheck(page: Page, lang: string) { + const translation = langTranslations.find((t) => t.lang === lang)?.translation; + if (!translation) { + throw new Error(`Could not find translation for language '${lang}'`); + } + + const getParagraph = async (id: string) => page.$eval(`p#${id}`, (el) => el.textContent?.trim()); + + const hello = await getParagraph('hello'); + if (hello !== translation.hello) { + throw new Error(`Expected 'hello' to be '${translation.hello}', but got '${hello}'.`); + } + + const locale = await getParagraph('locale'); + if (locale !== lang) { + throw new Error(`Expected 'locale' to be '${lang}', but got '${locale}'.`); + } + + const date = await getParagraph('date'); + if (date !== translation.date) { + throw new Error(`Expected 'date' to be '${translation.date}', but got '${date}'.`); + } + + const plural = await getParagraph('plural'); + if (plural !== translation.plural) { + throw new Error(`Expected 'plural' to be '${translation.plural}', but got '${plural}'.`); + } +} + +export interface ExternalServer { + readonly server: Server; + readonly port: number; + readonly url: string; +} + +/** + * Create an `express` `http.Server` listening on a random port. + * + * Call .close() on the server return value to close the server. + */ +export async function externalServer(outputPath: string, baseUrl = '/'): Promise { + const app = express(); + app.use(baseUrl, express.static(resolve(outputPath))); + + return new Promise((resolve) => { + const server = app.listen(0, 'localhost', () => { + const { port } = server.address() as AddressInfo; + + resolve({ + server, + port, + url: `http://localhost:${port}${baseUrl}`, + }); + }); + }); +} + +export const baseHrefs: { [l: string]: string } = { + 'en-US': '/en/', + fr: '/fr-FR/', + de: '', +}; + +export async function setupI18nConfig() { + // Add component with i18n content, both translations and localeData (plural, dates). + await writeFile( + 'src/app/app.ts', + ` + import { Component, Inject, LOCALE_ID } from '@angular/core'; + import { DatePipe } from '@angular/common'; + import { RouterOutlet } from '@angular/router'; + + @Component({ + selector: 'app-root', + imports: [DatePipe, RouterOutlet], + templateUrl: './app.html' + }) + export class App { + constructor(@Inject(LOCALE_ID) public locale: string) { } + title = 'i18n'; + jan = new Date(2000, 0, 1); + minutes = 3; + } + `, + ); + await writeFile( + `src/app/app.html`, + ` +

Hello {{ title }}!

+

{{ locale }}

+

{{ jan | date : 'LLLL' }}

+

Updated {minutes, plural, =0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}}

+ + `, + ); + + await createDir(dirname(translationFile)); + await writeFile( + translationFile, + ` + + + + + + Hello ! + + src/app/app.html + 2,3 + + An introduction header for this sample + + + Updated + + src/app/app.html + 5,6 + + + + {VAR_PLURAL, plural, =0 {just now} =1 {one minute ago} other { minutes ago}} + + src/app/app.html + 5,6 + + + + + `, + ); + + // Add a dynamic import to ensure syntax is supported + // ng serve support: https://github.com/angular/angular-cli/issues/16248 + await writeFile('src/app/dynamic.ts', `export const abc = 5;`); + await appendToFile( + 'src/app/app.ts', + ` + (async () => { await import('./dynamic'); })(); + `, + ); + + // Update angular.json to build, serve, and test each locale. + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const appArchitect = workspaceJson.projects['test-project'].architect; + const buildConfigs = appArchitect['build'].configurations; + const serveConfigs = appArchitect['serve'].configurations; + + appArchitect['build'].defaultConfiguration = undefined; + + // Always error on missing translations. + appArchitect['build'].options.optimization = true; + appArchitect['build'].options.aot = true; + appArchitect['build'].options.i18nMissingTranslation = 'error'; + appArchitect['build'].options.sourceMap = true; + appArchitect['build'].options.outputHashing = 'none'; + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + appArchitect['build'].options.buildOptimizer = true; + appArchitect['build'].options.vendorChunk = true; + } + + // Enable localization for all locales + appArchitect['build'].options.localize = true; + + // Add i18n config items (app, build, serve, e2e). + // tslint:disable-next-line: no-any + const i18n: Record = (appProject.i18n = { locales: {} }); + for (const { lang } of langTranslations) { + if (lang === sourceLocale) { + i18n.sourceLocale = lang; + } else { + i18n.locales[lang] = `src/locale/messages.${lang}.xlf`; + } + + buildConfigs[lang] = { localize: [lang] }; + serveConfigs[lang] = { buildTarget: `test-project:build:${lang}` }; + } + }); + + // Install the localize package if using ivy + let localizeVersion = '@angular/localize@' + readNgVersion(); + if (getGlobalVariable('argv')['ng-snapshots']) { + localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize']; + } + + await installPackage(localizeVersion); + + // Make translations for each language. + for (const { lang, translationReplacements } of langTranslations) { + if (lang != sourceLocale) { + await copyFile(translationFile, `src/locale/messages.${lang}.xlf`); + for (const replacements of translationReplacements!) { + await replaceInFile( + `src/locale/messages.${lang}.xlf`, + new RegExp(replacements[0], 'g'), + replacements[1] as string, + ); + } + + for (const replacement of [[/source/g, 'target']]) { + await replaceInFile( + `src/locale/messages.${lang}.xlf`, + new RegExp(replacement[0], 'g'), + replacement[1] as string, + ); + } + } + } +} diff --git a/tests/e2e/tests/lint/lint-no-config-section.ts b/tests/e2e/tests/lint/lint-no-config-section.ts deleted file mode 100644 index 79378681cae1..000000000000 --- a/tests/e2e/tests/lint/lint-no-config-section.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ng } from '../../utils/process'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLine } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => ng('set', 'lint', '[]')) - .then(() => ng('lint')) - .then(({ stdout }) => { - if (!stdout.match(/No lint configuration\(s\) found\./)) { - throw new Error(oneLine` - Expected to match "No lint configuration(s) found." - in ${stdout}. - `); - } - - return stdout; - }); -} diff --git a/tests/e2e/tests/lint/lint-no-project.ts b/tests/e2e/tests/lint/lint-no-project.ts deleted file mode 100644 index 9e374ef040e4..000000000000 --- a/tests/e2e/tests/lint/lint-no-project.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLine } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => ng('set', 'lint.0.project', '')) - .then(() => ng('lint', '--type-check')) - .then(({ stdout }) => { - if (!stdout.match(/A "project" must be specified to enable type checking./)) { - throw new Error(oneLine` - Expected to match "A "project" must be specified to enable type checking." - in ${stdout}. - `); - } - - return stdout; - }) - .then(() => ng('set', 'lint.0.files', '"**/baz.ts"')) - .then(() => writeFile('src/app/foo.ts', 'const foo = "";\n')) - .then(() => writeFile('src/app/baz.ts', 'const baz = \'\';\n')) - .then(() => ng('lint')) - .then(() => ng('set', 'lint.0.files', '"**/foo.ts"')) - .then(() => expectToFail(() => ng('lint'))); -} diff --git a/tests/e2e/tests/lint/lint-with-exclude.ts b/tests/e2e/tests/lint/lint-with-exclude.ts deleted file mode 100644 index be792e2e9530..000000000000 --- a/tests/e2e/tests/lint/lint-with-exclude.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLine } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => ng('set', 'lint.0.exclude', '"**/foo.ts"')) - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint')) - .then(({ stdout }) => { - if (!stdout.match(/All files pass linting\./)) { - throw new Error(oneLine` - Expected to match "All files pass linting." - in ${stdout}. - `); - } - }); -} diff --git a/tests/e2e/tests/lint/lint-with-fix.ts b/tests/e2e/tests/lint/lint-with-fix.ts deleted file mode 100644 index 0dfb3fffe72d..000000000000 --- a/tests/e2e/tests/lint/lint-with-fix.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ng } from '../../utils/process'; -import { readFile, writeFile } from '../../utils/fs'; - -export default function () { - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', '--fix', '--force')) - .then(() => readFile(fileName)) - .then(content => { - if (!content.match(/const foo = '';/)) { - throw new Error(`Expected to match "const foo = '';" in ${content}.`); - } - }); -} diff --git a/tests/e2e/tests/lint/lint-with-force.ts b/tests/e2e/tests/lint/lint-with-force.ts deleted file mode 100644 index ac3f2975e663..000000000000 --- a/tests/e2e/tests/lint/lint-with-force.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLine } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', '--force')) - .then(({ stdout }) => { - if (!stdout.match(/" should be '/)) { - throw new Error(`Expected to match "" should be '" in ${stdout}.`); - } - - return stdout; - }) - .then((output) => { - if (!output.match(/Lint errors found in the listed files\./)) { - throw new Error(oneLine` - Expected to match "Lint errors found in the listed files." - in ${output}. - `); - } - }); -} diff --git a/tests/e2e/tests/lint/lint-with-format-by-aliases.ts b/tests/e2e/tests/lint/lint-with-format-by-aliases.ts deleted file mode 100644 index 067adb1640f6..000000000000 --- a/tests/e2e/tests/lint/lint-with-format-by-aliases.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLine } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', '-t=stylish', '--force')) - .then(({ stdout }) => { - if (!stdout.match(/1:13 quotemark " should be '/)) { - throw new Error(oneLine` - Expected to match "1:13 quotemark " should be '" - in ${stdout}. - `); - } - }); -} diff --git a/tests/e2e/tests/lint/lint-with-format.ts b/tests/e2e/tests/lint/lint-with-format.ts deleted file mode 100644 index ed21f1ae87a1..000000000000 --- a/tests/e2e/tests/lint/lint-with-format.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { oneLine } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', '--format=stylish', '--force')) - .then(({ stdout }) => { - if (!stdout.match(/1:13 quotemark " should be '/)) { - throw new Error(oneLine` - Expected to match "1:13 quotemark " should be '" - in ${stdout}. - `); - } - }); -} diff --git a/tests/e2e/tests/lint/lint-with-nested-configs.ts b/tests/e2e/tests/lint/lint-with-nested-configs.ts deleted file mode 100644 index d0580fa8f114..000000000000 --- a/tests/e2e/tests/lint/lint-with-nested-configs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createDir, writeFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo/foo.ts'; - const nestedConfigContent = ` - { - "rules": { - "quotemark": [ - true, - "double", - "avoid-escape" - ] - } - }`; - - return Promise.resolve() - .then(() => createDir('src/app/foo')) - .then(() => writeFile(fileName, 'const foo = \'\';\n')) - .then(() => writeFile('src/app/foo/tslint.json', nestedConfigContent)) - .then(() => expectToFail(() => ng('lint'))); -} diff --git a/tests/e2e/tests/lint/lint-with-type-check-fail.ts b/tests/e2e/tests/lint/lint-with-type-check-fail.ts deleted file mode 100644 index ce8437d018cc..000000000000 --- a/tests/e2e/tests/lint/lint-with-type-check-fail.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo.ts'; - const fileContents = ` -const ANIMATION_CSS_VALUE_REGEX = 'asda'; -const a = ["asda", 'asda', 'asdasd', "ASDASDAS"]; -const b = "asdasd"; -const c = { - a: "sadas", - b: { - v: "asdasda", - s: ["asda", "asdas", 10, true, "asda"], - } -}; - -function check(val: any, fxState: any) { - if (typeof val === "string" && val.indexOf(" ") < 0) { - let r = val.match(ANIMATION_CSS_VALUE_REGEX); - let num = parseFloat(r[1]); - - if (!isNaN(num)) { - fxState.num = num + ""; - } - fxState.unit = (r[0] !== r[2] ? r[2] : ""); - - } else if (typeof val === "number") { - fxState.num = val + ""; - } -} - - `; - - return Promise.resolve() - .then(() => writeFile(fileName, fileContents)) - .then(() => expectToFail(() => ng('lint', '--fix', '--type-check'))); -} diff --git a/tests/e2e/tests/lint/lint-with-type-check.ts b/tests/e2e/tests/lint/lint-with-type-check.ts deleted file mode 100644 index 59b8642cf6f8..000000000000 --- a/tests/e2e/tests/lint/lint-with-type-check.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - const fileName = 'src/app/foo.ts'; - const fileContents = ` -const ANIMATION_CSS_VALUE_REGEX = 'asda'; -const a = ["asda", 'asda', 'asdasd', "ASDASDAS"]; -const b = "asdasd"; -const c = { - a: "sadas", - b: { - v: "asdasda", - s: ["asda", "asdas", 10, true, "asda"], - } -}; - -function check(val: any, fxState: any) { - if (typeof val === "string" && val.indexOf(" ") < 0) { - let r = val.match(ANIMATION_CSS_VALUE_REGEX); - let num = parseFloat(r[1]); - - if (!isNaN(num)) { - fxState.num = num + ""; - } - fxState.unit = (r[0] !== r[2] ? r[2] : ""); - - } else if (typeof val === "number") { - fxState.num = val + ""; - } -} - - `; - - return Promise.resolve() - .then(() => writeFile(fileName, fileContents)) - .then(() => ng('lint', '--fix')) - .then(() => ng('lint')) - .then(({ stdout }) => { - if (!stdout.match(/All files pass linting./)) { - throw new Error('All files pass linting.'); - } - }); -} diff --git a/tests/e2e/tests/lint/lint.ts b/tests/e2e/tests/lint/lint.ts deleted file mode 100644 index 7d4b3d655164..000000000000 --- a/tests/e2e/tests/lint/lint.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ng } from '../../utils/process'; -import { oneLine } from 'common-tags'; - -export default function () { - return ng('lint') - .then(({ stdout }) => { - if (!stdout.match(/All files pass linting\./)) { - throw new Error(oneLine` - Expected to match "All files pass linting." - in ${stdout}. - `); - } - }); -} diff --git a/tests/e2e/tests/mcp/ai-tutor.ts b/tests/e2e/tests/mcp/ai-tutor.ts new file mode 100644 index 000000000000..220b32fcc202 --- /dev/null +++ b/tests/e2e/tests/mcp/ai-tutor.ts @@ -0,0 +1,40 @@ +import { chdir } from 'node:process'; +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + // Ensure `get_best_practices` returns the markdown content + const { stdout: stdoutInsideWorkspace } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'ai_tutor', + ); + + assert.match(stdoutInsideWorkspace, /# `airules.md` - Modern Angular Tutor 🧑‍🏫/); +} diff --git a/tests/e2e/tests/mcp/best-practices.ts b/tests/e2e/tests/mcp/best-practices.ts new file mode 100644 index 000000000000..55736c63795b --- /dev/null +++ b/tests/e2e/tests/mcp/best-practices.ts @@ -0,0 +1,43 @@ +import { chdir } from 'node:process'; +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + // Ensure `get_best_practices` returns the markdown content + const { stdout: stdoutInsideWorkspace } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'get_best_practices', + ); + + assert.match( + stdoutInsideWorkspace, + /You are an expert in TypeScript, Angular, and scalable web application development./, + ); +} diff --git a/tests/e2e/tests/mcp/projects-sourceroot-resolution.ts b/tests/e2e/tests/mcp/projects-sourceroot-resolution.ts new file mode 100644 index 000000000000..01ebb8c1ca05 --- /dev/null +++ b/tests/e2e/tests/mcp/projects-sourceroot-resolution.ts @@ -0,0 +1,47 @@ +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + return exec(MCP_INSPECTOR_COMMAND_NAME, '--cli', 'npx', '--no', '@angular/cli', 'mcp', ...args); +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + try { + // 1. Add a sample project with a non-root path to angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + workspaceJson.projects ??= {}; + workspaceJson.projects['sample-lib'] = { + root: 'projects/sample-lib', + sourceRoot: 'projects/sample-lib/src', + projectType: 'library', + }; + }); + + // 2. Call list_projects + const { stdout } = await runInspector('--method', 'tools/call', '--tool-name', 'list_projects'); + + // 3. Verify output + assert.match(stdout, /"name": "sample-lib"/); + // Assert that sourceRoot is NOT duplicated + assert.match(stdout, /"sourceRoot": "projects\/sample-lib\/src"/); + assert.doesNotMatch(stdout, /"sourceRoot": "projects\/sample-lib\/projects\/sample-lib\/src"/); + } finally { + // 4. Cleanup angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + delete workspaceJson.projects['sample-lib']; + }); + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/mcp/registers-tools.ts b/tests/e2e/tests/mcp/registers-tools.ts new file mode 100644 index 000000000000..abc76a99f5d7 --- /dev/null +++ b/tests/e2e/tests/mcp/registers-tools.ts @@ -0,0 +1,49 @@ +import { chdir } from 'node:process'; +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + // Ensure 'list_projects' is registered when inside an Angular workspace + try { + const { stdout: stdoutInsideWorkspace } = await runInspector('--method', 'tools/list'); + + assert.match(stdoutInsideWorkspace, /"list_projects"/); + assert.match(stdoutInsideWorkspace, /"get_best_practices"/); + assert.match(stdoutInsideWorkspace, /"search_documentation"/); + + chdir('..'); + + const { stdout: stdoutOutsideWorkspace } = await runInspector('--method', 'tools/list'); + + assert.match(stdoutOutsideWorkspace, /"list_projects"/); + assert.match(stdoutOutsideWorkspace, /"get_best_practices"/); + assert.match(stdoutInsideWorkspace, /"search_documentation"/); + } finally { + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/mcp/run-target.ts b/tests/e2e/tests/mcp/run-target.ts new file mode 100644 index 000000000000..16548fc4f3ca --- /dev/null +++ b/tests/e2e/tests/mcp/run-target.ts @@ -0,0 +1,80 @@ +import { getGlobalVariable } from '../../utils/env'; +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + try { + // 1. Ensure `run_target` is registered by default (now stable) + const { stdout: stdoutDefault } = await runInspector('--method', 'tools/list'); + assert.match(stdoutDefault, /"run_target"/); + + // 2. Ensure `run_target` is also registered when using the deprecated experimental-tool flag + const { stdout: stdoutEnabled } = await runInspector( + '-E', + 'run_target', + '--method', + 'tools/list', + ); + assert.match(stdoutEnabled, /"run_target"/); + + // 3. Call run_target with build target + const { stdout: stdoutCall } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'run_target', + '--tool-arg', + 'target=build', + ); + assert.match(stdoutCall, /"status":\s*"success"/); + // Webpack-based browser builder does not print output paths to stdout logs. + // Only esbuild-based application builders output 'Output location: ...' and support outputPath extraction. + const esbuild = getGlobalVariable('argv')['esbuild']; + if (esbuild) { + assert.match(stdoutCall, /"outputPath":\s*"dist\/.+"/); + } else { + assert.doesNotMatch(stdoutCall, /"outputPath"/); + } + + // 4. Call run_target with test target (only for esbuild/Vite test runner, as webpack-based Karma fails on this bazel CI headless runner) + if (esbuild) { + const { stdout: stdoutTestCall } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'run_target', + '--tool-arg', + 'target=test', + ); + assert.match(stdoutTestCall, /"status":\s*"success"/); + } + } finally { + // 5. Clean up global installation + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/misc/ask-missing-builder.ts b/tests/e2e/tests/misc/ask-missing-builder.ts new file mode 100644 index 000000000000..ce4270cdbb47 --- /dev/null +++ b/tests/e2e/tests/misc/ask-missing-builder.ts @@ -0,0 +1,24 @@ +import { execAndWaitForOutputToMatch, killAllProcesses } from '../../utils/process'; + +export default async function () { + // Execute a command with TTY force enabled and check that the prompt is shown. + await execAndWaitForOutputToMatch( + 'ng', + ['deploy'], + /Would you like to add a package with "deploy" capabilities/, + { + ...process.env, + NG_FORCE_TTY: '1', + NG_CLI_ANALYTICS: 'false', + }, + ); + + await killAllProcesses(); + + // Execute a command with TTY force enabled and check that the prompt is shown. + await execAndWaitForOutputToMatch('ng', ['lint'], /Would you like to add ESLint now/, { + ...process.env, + NG_FORCE_TTY: '1', + NG_CLI_ANALYTICS: 'false', + }); +} diff --git a/tests/e2e/tests/misc/assets.ts b/tests/e2e/tests/misc/assets.ts deleted file mode 100644 index 482881561495..000000000000 --- a/tests/e2e/tests/misc/assets.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {writeFile, expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return writeFile('src/assets/.file', '') - .then(() => writeFile('src/assets/test.abc', 'hello world')) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/favicon.ico')) - .then(() => expectFileToExist('dist/assets/.file')) - .then(() => expectFileToMatch('dist/assets/test.abc', 'hello world')) - .then(() => expectToFail(() => expectFileToExist('dist/assets/.gitkeep'))) - // doesn't break beta.16 projects - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = 'assets'; - })) - .then(() => expectFileToExist('dist/assets/.file')) - .then(() => expectFileToMatch('dist/assets/test.abc', 'hello world')); -} diff --git a/tests/e2e/tests/misc/breaking-change.ts b/tests/e2e/tests/misc/breaking-change.ts deleted file mode 100644 index e087e959cf84..000000000000 --- a/tests/e2e/tests/misc/breaking-change.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createProjectFromAsset } from '../../utils/assets'; -import { replaceInFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - -// This test ensures a project generated with 1.0.0 will still work. -// Only change it test on major releases. - -export default function () { - return Promise.resolve() - .then(() => createProjectFromAsset('1.0.0-proj')) - // Update chrome configs. - .then(() => replaceInFile('protractor.conf.js', `'browserName': 'chrome'`, - `'browserName': 'chrome', - chromeOptions: { - args: [ - "--enable-logging", - "--no-sandbox", - ${process.env['TRAVIS'] ? '"--headless", "--disable-gpu"' : ''} - ] - } - `)) - .then(() => replaceInFile('karma.conf.js', `browsers: ['Chrome'],`, - `browsers: ['ChromeCI'], - customLaunchers: { - ChromeCI: { - base: '${process.env['TRAVIS'] ? 'ChromeHeadless' : 'Chrome'}', - flags: ['--no-sandbox'] - } - }, - `)) - .then(() => ng('generate', 'component', 'my-comp')) - .then(() => ng('lint')) - .then(() => ng('test', '--single-run')) - .then(() => ng('e2e')) - .then(() => ng('e2e', '--prod')); -} diff --git a/tests/e2e/tests/misc/browsers.ts b/tests/e2e/tests/misc/browsers.ts new file mode 100644 index 000000000000..1212013a2c99 --- /dev/null +++ b/tests/e2e/tests/misc/browsers.ts @@ -0,0 +1,93 @@ +import express from 'express'; +import * as path from 'node:path'; +import { copyProjectAsset } from '../../utils/assets'; +import { appendToFile, createDir, replaceInFile, writeFile } from '../../utils/fs'; +import { exec, ng } from '../../utils/process'; +import { installPackage } from '../../utils/packages'; + +/** + * The list of development dependencies used by the E2E protractor-based builder. + */ +const E2E_DEV_DEPENDENCIES = [ + 'protractor@~7.0.0', + 'jasmine-spec-reporter@~7.0.0', + 'ts-node@~10.9.0', + '@types/node@^20.17.19', +]; + +export default async function () { + // Ensure SauceLabs configuration + if (!process.env['SAUCE_USERNAME'] || !process.env['SAUCE_ACCESS_KEY']) { + throw new Error('SauceLabs is not configured.'); + } + + for (const e2eDep of E2E_DEV_DEPENDENCIES) { + await installPackage(e2eDep); + } + + await appendToFile( + 'src/app/app.config.ts', + "import { provideProtractorTestingSupport } from '@angular/platform-browser';\n", + ); + await replaceInFile( + 'src/app/app.config.ts', + 'providers: [', + 'providers: [\n provideProtractorTestingSupport(),\n', + ); + + // Workaround for https://github.com/angular/angular/issues/32192 + await replaceInFile('src/app/app.html', /class="material-icons"/g, ''); + + await ng('build'); + + // Add Protractor configuration + await copyProjectAsset('protractor-saucelabs.conf.js', 'e2e/protractor-saucelabs.conf.js'); + + // Add App E2E test file + await createDir('e2e/src'); + await writeFile( + 'e2e/src/app.e2e-spec.ts', + ` +import { browser, by, element, logging } from 'protractor'; + +describe('workspace-project App', () => { + it('should display welcome message', async () => { + await browser.get(browser.baseUrl); + expect((await element(by.css('h1')).getText()).trim()).toEqual('Hello, test-project'); + }); +}); +`, + ); + + // Add App E2E tsconfig file + await writeFile( + 'e2e/tsconfig.json', + ` +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "out-tsc/e2e", + "module": "commonjs", + "target": "es2019", + "types": [ + "jasmine", + "node" + ] + } +} +`, + ); + + // Setup server + const app = express(); + app.use(express.static(path.resolve('dist/test-project/browser'))); + const server = app.listen(2000, 'localhost'); + + try { + // Execute application's E2E tests with SauceLabs + const binPath = path.join('node_modules', '.bin', 'protractor'); + await exec(binPath, 'e2e/protractor-saucelabs.conf.js'); + } finally { + server.close(); + } +} diff --git a/tests/e2e/tests/misc/check-postinstalls.ts b/tests/e2e/tests/misc/check-postinstalls.ts new file mode 100644 index 000000000000..7c1d63e6ef97 --- /dev/null +++ b/tests/e2e/tests/misc/check-postinstalls.ts @@ -0,0 +1,69 @@ +import glob from 'fast-glob'; +import { readFile } from '../../utils/fs'; + +const CURRENT_SCRIPT_PACKAGES: ReadonlySet = new Set([ + '@parcel/watcher (install)', + 'esbuild (postinstall)', + 'lmdb (install)', + 'msgpackr-extract (install)', + 'nice-napi (install)', + 'unrs-resolver (postinstall)', +]); + +const POTENTIAL_SCRIPTS: ReadonlyArray = ['preinstall', 'install', 'postinstall']; + +// Some packages include test and/or example code that causes false positives +const FALSE_POSITIVE_PATHS: ReadonlySet = new Set([ + 'jasmine-spec-reporter/examples/protractor/package.json', + 'resolve/test/resolver/multirepo/package.json', + 'resolve/test/list-exports/packages/tests/fixtures/resolve-1/project/test/resolver/multirepo/package.json', + 'resolve/test/list-exports/packages/tests/fixtures/resolve-2/project/test/resolver/multirepo/package.json', +]); + +const INNER_NODE_MODULES_SEGMENT = '/node_modules/'; + +export default async function () { + const manifestPaths = await glob('node_modules/**/package.json'); + const newPackages: string[] = []; + + for (const manifestPath of manifestPaths) { + const lastNodeModuleIndex = manifestPath.lastIndexOf(INNER_NODE_MODULES_SEGMENT); + const packageRelativePath = manifestPath.slice( + lastNodeModuleIndex === -1 + ? INNER_NODE_MODULES_SEGMENT.length - 1 + : lastNodeModuleIndex + INNER_NODE_MODULES_SEGMENT.length, + ); + if (FALSE_POSITIVE_PATHS.has(packageRelativePath)) { + continue; + } + + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath)); + } catch { + continue; + } + + if (!manifest.scripts) { + continue; + } + + for (const script of POTENTIAL_SCRIPTS) { + if (!manifest.scripts[script]) { + continue; + } + + const packageScript = `${manifest.name} (${script})`; + + if (!CURRENT_SCRIPT_PACKAGES.has(packageScript)) { + newPackages.push(packageScript + `[${manifestPath}]`); + } + } + } + + if (newPackages.length) { + throw new Error( + 'New install script package(s) detected:\n' + JSON.stringify(newPackages, null, 2), + ); + } +} diff --git a/tests/e2e/tests/misc/circular-dependency.ts b/tests/e2e/tests/misc/circular-dependency.ts deleted file mode 100644 index 4b68995d1322..000000000000 --- a/tests/e2e/tests/misc/circular-dependency.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { prependToFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function () { - await prependToFile('src/app/app.component.ts', - `import { AppModule } from './app.module'; console.log(AppModule);`); - let output = await ng('build', '--show-circular-dependencies'); - if (!output.stdout.match(/WARNING in Circular dependency detected/)) { - throw new Error('Expected to have circular dependency warning in output.'); - } - - await ng('set', 'defaults.build.showCircularDependencies=false'); - output = await ng('build'); - if (output.stdout.match(/WARNING in Circular dependency detected/)) { - throw new Error('Expected to not have circular dependency warning in output.'); - } -} diff --git a/tests/e2e/tests/misc/common-async.ts b/tests/e2e/tests/misc/common-async.ts deleted file mode 100644 index da5bb5c5edaa..000000000000 --- a/tests/e2e/tests/misc/common-async.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {readdirSync} from 'fs'; -import {oneLine} from 'common-tags'; - -import {ng, npm} from '../../utils/process'; -import {addImportToModule} from '../../utils/ast'; -import {appendToFile, expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - let oldNumberOfFiles = 0; - return Promise.resolve() - .then(() => ng('build')) - .then(() => oldNumberOfFiles = readdirSync('dist').length) - .then(() => ng('generate', 'module', 'lazyA', '--routing')) - .then(() => ng('generate', 'module', 'lazyB', '--routing')) - .then(() => addImportToModule('src/app/app.module.ts', oneLine` - RouterModule.forRoot([{ path: "lazyA", loadChildren: "./lazy-a/lazy-a.module#LazyAModule" }]), - RouterModule.forRoot([{ path: "lazyB", loadChildren: "./lazy-b/lazy-b.module#LazyBModule" }]) - `, '@angular/router')) - .then(() => ng('build')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy module was not created.'); - } - oldNumberOfFiles = currentNumberOfDistFiles; - }) - .then(() => npm('install', 'moment')) - .then(() => appendToFile('src/app/lazy-a/lazy-a.module.ts', ` - import * as moment from 'moment'; - console.log(moment); - `)) - .then(() => ng('build')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('The build contains a different number of files.'); - } - }) - .then(() => appendToFile('src/app/lazy-b/lazy-b.module.ts', ` - import * as moment from 'moment'; - console.log(moment); - `)) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/common.chunk.js')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error(oneLine`The build contains the wrong number of files. - The test for 'dist/common.chunk.js' to exist should have failed.`); - } - oldNumberOfFiles = currentNumberOfDistFiles; - }) - .then(() => ng('build', '--no-common-chunk')) - .then(() => expectToFail(() => expectFileToExist('dist/common.chunk.js'))) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles <= currentNumberOfDistFiles) { - throw new Error(oneLine`The build contains the wrong number of files. - The test for 'dist/common.chunk.js' not to exist should have failed.`); - } - }) - // Check for AoT and lazy routes. - .then(() => ng('build', '--aot')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('AoT build contains a different number of files.'); - } - }); -} diff --git a/tests/e2e/tests/misc/coverage.ts b/tests/e2e/tests/misc/coverage.ts deleted file mode 100644 index 280a5cca8458..000000000000 --- a/tests/e2e/tests/misc/coverage.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; -import {ng} from '../../utils/process'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return ng('test', '--single-run', '--code-coverage') - .then(() => expectFileToExist('coverage/src/app')) - .then(() => expectFileToExist('coverage/lcov.info')) - // Verify code coverage exclude work - .then(() => expectFileToMatch('coverage/lcov.info', 'polyfills.ts')) - .then(() => expectFileToMatch('coverage/lcov.info', 'test.ts')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const test = configJson['test']; - test['codeCoverage'] = { - exclude: [ - 'src/polyfills.ts', - '**/test.ts' - ] - }; - })) - .then(() => ng('test', '--single-run', '--code-coverage')) - .then(() => expectToFail(() => expectFileToMatch('coverage/lcov.info', 'polyfills.ts'))) - .then(() => expectToFail(() => expectFileToMatch('coverage/lcov.info', 'test.ts'))); -} diff --git a/tests/e2e/tests/misc/create-angular.ts b/tests/e2e/tests/misc/create-angular.ts new file mode 100644 index 000000000000..acbdf135359b --- /dev/null +++ b/tests/e2e/tests/misc/create-angular.ts @@ -0,0 +1,52 @@ +import { equal } from 'node:assert'; +import { join, resolve } from 'node:path'; +import { expectFileToExist, readFile, rimraf } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { silentBun, silentNpm, silentPnpm, silentYarn } from '../../utils/process'; + +export default async function () { + const currentDirectory = process.cwd(); + const newDirectory = resolve('../'); + const projectName = 'test-project-create'; + + try { + process.chdir(newDirectory); + const packageManager = getActivePackageManager(); + + switch (packageManager) { + case 'npm': + await silentNpm('init', '@angular', projectName, '--', '--style=scss'); + + break; + case 'yarn': + await silentYarn('create', '@angular', projectName, '--style=scss'); + + break; + case 'bun': + await silentBun('create', '@angular', projectName, '--style=scss'); + + break; + case 'pnpm': + await silentPnpm('create', '@angular', projectName, '--style=scss'); + + break; + default: + throw new Error(`This test is not configured to use ${packageManager}.`); + } + + // Check that package manager has been configured based on the package manager used to invoke the create command. + const workspace = JSON.parse(await readFile(join(projectName, 'angular.json'))); + equal( + workspace.cli?.packageManager, + packageManager, + `Expected 'packageManager' option to be configured to ${packageManager}.`, + ); + + // Verify styles was create with correct extension. + await expectFileToExist(join(projectName, 'src/styles.scss')); + } finally { + await rimraf(projectName); + // Change directory back + process.chdir(currentDirectory); + } +} diff --git a/tests/e2e/tests/misc/dedupe-duplicate-modules.ts b/tests/e2e/tests/misc/dedupe-duplicate-modules.ts new file mode 100644 index 000000000000..5047021566eb --- /dev/null +++ b/tests/e2e/tests/misc/dedupe-duplicate-modules.ts @@ -0,0 +1,65 @@ +import { expectFileToMatch, rimraf, writeFile } from '../../utils/fs'; +import { gitClean } from '../../utils/git'; +import { installWorkspacePackages } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + try { + // Force duplicate modules + await updateJsonFile('package.json', (json) => { + json.dependencies = { + ...json.dependencies, + 'tslib': '^2.0.0', + 'tslib-1': 'npm:tslib@1.13.0', + 'tslib-1-copy': 'npm:tslib@1.13.0', + }; + }); + + await installWorkspacePackages(); + + await writeFile( + './src/main.ts', + ` + import { __assign as __assign_0 } from 'tslib'; + import { __assign as __assign_1 } from 'tslib-1'; + import { __assign as __assign_2 } from 'tslib-1-copy'; + + console.log({ + __assign_0, + __assign_1, + __assign_2, + }) + `, + ); + + const { stderr } = await ng( + 'build', + '--verbose', + '--no-vendor-chunk', + '--no-progress', + '--configuration=development', + ); + const outFile = 'dist/test-project/browser/main.js'; + + if (/\[DedupeModuleResolvePlugin\]:.+tslib-1-copy.+ -> .+tslib-1.+/.test(stderr)) { + await expectFileToMatch(outFile, './node_modules/tslib-1/tslib.es6.js'); + await expectToFail(() => + expectFileToMatch(outFile, './node_modules/tslib-1-copy/tslib.es6.js'), + ); + } else if (/\[DedupeModuleResolvePlugin\]:.+tslib-1.+ -> .+tslib-1-copy.+/.test(stderr)) { + await expectFileToMatch(outFile, './node_modules/tslib-1-copy/tslib.es6.js'); + await expectToFail(() => expectFileToMatch(outFile, './node_modules/tslib-1/tslib.es6.js')); + } else { + console.error(`\n\n\n${stderr}\n\n\n`); + throw new Error('Expected stderr to contain [DedupeModuleResolvePlugin] log for tslib.'); + } + + await expectFileToMatch(outFile, './node_modules/tslib/tslib.es6.mjs'); + } finally { + await rimraf('node_modules/tslib'); + await gitClean(); + await installWorkspacePackages(); + } +} diff --git a/tests/e2e/tests/misc/default-port.ts b/tests/e2e/tests/misc/default-port.ts deleted file mode 100644 index a010173f2907..000000000000 --- a/tests/e2e/tests/misc/default-port.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson.defaults; - app.serve = { port: 4201 }; - })) - .then(() => ngServe()) - .then(() => request('http://localhost:4201/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/deploy-url.ts b/tests/e2e/tests/misc/deploy-url.ts deleted file mode 100644 index ed3d28c48f45..000000000000 --- a/tests/e2e/tests/misc/deploy-url.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { killAllProcesses } from '../../utils/process'; -import { request } from '../../utils/http'; -import { ngServe } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - // check when setup through command line arguments - .then(() => ngServe('--deploy-url', '/deployurl/', '--base-href', '/deployurl/')) - .then(() => request('http://localhost:4200')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => request('http://localhost:4200/deployurl/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/different-file-format.ts b/tests/e2e/tests/misc/different-file-format.ts deleted file mode 100644 index df2fe4ff4216..000000000000 --- a/tests/e2e/tests/misc/different-file-format.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../utils/process'; -import * as fs from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - - -const options = { - encoding: 'utf8' -}; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => fs.prependToFile('./src/tsconfig.app.json', '\ufeff', options)) - .then(() => fs.prependToFile('./.angular-cli.json', '\ufeff', options)) - .then(() => ng('build', '--env=dev')); -} diff --git a/tests/e2e/tests/misc/duplicate-command-line-option.ts b/tests/e2e/tests/misc/duplicate-command-line-option.ts new file mode 100644 index 000000000000..0042a363e156 --- /dev/null +++ b/tests/e2e/tests/misc/duplicate-command-line-option.ts @@ -0,0 +1,19 @@ +import { ng } from '../../utils/process'; +import { expectFileToExist } from '../../utils/fs'; + +export default async function () { + const { stderr } = await ng( + 'generate', + 'component', + 'test-component', + '--style=scss', + '--style=sass', + ); + + const warningMatch = `Option 'style' has been specified multiple times. The value 'sass' will be used`; + if (!stderr.includes(warningMatch)) { + throw new Error(`Expected stderr to contain: "${warningMatch}".`); + } + + await expectFileToExist('src/app/test-component/test-component.sass'); +} diff --git a/tests/e2e/tests/misc/es2015-nometa.ts b/tests/e2e/tests/misc/es2015-nometa.ts new file mode 100644 index 000000000000..c8fad0e07954 --- /dev/null +++ b/tests/e2e/tests/misc/es2015-nometa.ts @@ -0,0 +1,18 @@ +import { prependToFile, replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await ng('generate', 'service', 'user-service'); + + // Update the application to use the new service + await prependToFile('src/app/app.ts', "import { UserService } from './user-service';"); + + await replaceInFile( + 'src/app/app.ts', + 'export class App {', + 'export class App {\n constructor(user: UserService) {}', + ); + + // Execute the application's tests with emitDecoratorMetadata disabled (default) + await ng('test', '--no-watch'); +} diff --git a/tests/e2e/tests/misc/fallback.ts b/tests/e2e/tests/misc/fallback.ts deleted file mode 100644 index c3cf7067aa6c..000000000000 --- a/tests/e2e/tests/misc/fallback.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; -import { moveFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - // should fallback to config.app[0].index (index.html by default) - return Promise.resolve() - .then(() => ngServe()) - .then(() => request('http://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - // should correctly fallback to a changed index - .then(() => moveFile('src/index.html', 'src/not-index.html')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['index'] = 'not-index.html'; - })) - .then(() => ngServe()) - .then(() => request('http://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/forwardref-es2015.ts b/tests/e2e/tests/misc/forwardref-es2015.ts new file mode 100644 index 000000000000..48c28086cd0d --- /dev/null +++ b/tests/e2e/tests/misc/forwardref-es2015.ts @@ -0,0 +1,43 @@ +import { appendToFile, replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // Update the application to use a forward reference + await replaceInFile( + 'src/app/app.ts', + "import { Component, signal } from '@angular/core';", + "import { Component, Inject, Injectable, forwardRef, signal } from '@angular/core';", + ); + await appendToFile('src/app/app.ts', '\n@Injectable() export class Lock { }\n'); + await replaceInFile( + 'src/app/app.ts', + 'export class App {', + 'export class App {\n constructor(@Inject(forwardRef(() => Lock)) lock: Lock) {}', + ); + + // Update the application's unit tests to include the new injectable + await replaceInFile( + 'src/app/app.spec.ts', + "import { App } from './app';", + "import { App, Lock } from './app';", + ); + await replaceInFile( + 'src/app/app.spec.ts', + 'TestBed.configureTestingModule({', + 'TestBed.configureTestingModule({ providers: [Lock],', + ); + + // Execute the application's tests with emitDecoratorMetadata disabled (default) + await ng('test', '--no-watch'); + + // Turn on emitDecoratorMetadata + await replaceInFile( + 'tsconfig.json', + '"experimentalDecorators": true', + '"experimentalDecorators": true, "emitDecoratorMetadata": true', + ); + + // Execute the application's tests with emitDecoratorMetadata enabled + await expectToFail(() => ng('test', '--no-watch')); +} diff --git a/tests/e2e/tests/misc/invalid-schematic-dependencies.ts b/tests/e2e/tests/misc/invalid-schematic-dependencies.ts new file mode 100644 index 000000000000..88300951965e --- /dev/null +++ b/tests/e2e/tests/misc/invalid-schematic-dependencies.ts @@ -0,0 +1,60 @@ +import { join } from 'node:path'; +import { expectFileToMatch } from '../../utils/fs'; +import { + execWithEnv, + extractCIAndInfraEnv, + extractNpmEnv, + ng, + silentNpm, +} from '../../utils/process'; +import { getActivePackageManager, installPackage, uninstallPackage } from '../../utils/packages'; +import { isPrereleaseCli } from '../../utils/project'; +import { appendFile, writeFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + // Must publish old version to local registry to allow install. This is especially important + // for release commits as npm will try to request tooling packages that are not on the npm registry yet + await publishOutdated('@schematics/angular@7'); + await publishOutdated('@angular-devkit/core@7'); + await publishOutdated('@angular-devkit/schematics@7'); + + // Install outdated and incompatible version + await installPackage('@schematics/angular@7'); + + const isPrerelease = await isPrereleaseCli(); + const tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + await expectFileToMatch('package.json', /@angular\/material/); + + // Clean up existing cdk package + // Not doing so can cause adding material to fail if an incompatible cdk is present + await uninstallPackage('@angular/cdk'); +} + +async function publishOutdated(npmSpecifier: string): Promise { + const npmrc = join(getGlobalVariable('tmp-root'), '.npmrc-publish'); + const testRegistry = (getGlobalVariable('package-registry') as string).replace(/^\w+:/, ''); + await writeFile( + npmrc, + ` + ${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret + `, + ); + + const { stdout: stdoutPack } = await silentNpm( + 'pack', + npmSpecifier, + '--registry=https://registry.npmjs.org', + ); + + await execWithEnv('npm', ['publish', stdoutPack.trim(), '--tag=outdated'], { + ...extractNpmEnv(), + ...extractCIAndInfraEnv(), + 'NPM_CONFIG_USERCONFIG': npmrc, + }); +} diff --git a/tests/e2e/tests/misc/lazy-module.ts b/tests/e2e/tests/misc/lazy-module.ts deleted file mode 100644 index e4adbb2295bb..000000000000 --- a/tests/e2e/tests/misc/lazy-module.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {readdirSync} from 'fs'; -import {oneLine} from 'common-tags'; - -import {ng, npm} from '../../utils/process'; -import {addImportToModule} from '../../utils/ast'; -import {appendToFile, writeFile} from '../../utils/fs'; - - -export default function() { - let oldNumberOfFiles = 0; - return Promise.resolve() - .then(() => ng('build')) - .then(() => oldNumberOfFiles = readdirSync('dist').length) - .then(() => ng('generate', 'module', 'lazy', '--routing')) - .then(() => ng('generate', 'module', 'too/lazy', '--routing')) - .then(() => addImportToModule('src/app/app.module.ts', oneLine` - RouterModule.forRoot([{ path: "lazy", loadChildren: "app/lazy/lazy.module#LazyModule" }]), - RouterModule.forRoot([{ path: "lazy1", loadChildren: "./lazy/lazy.module#LazyModule" }]), - RouterModule.forRoot([{ path: "lazy2", loadChildren: "./too/lazy/lazy.module#LazyModule" }]) - `, '@angular/router')) - .then(() => ng('build', '--named-chunks')) - .then(() => readdirSync('dist')) - .then((distFiles) => { - const currentNumberOfDistFiles = distFiles.length; - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy module was not created.'); - } - oldNumberOfFiles = currentNumberOfDistFiles; - - if (!distFiles.includes('lazy.module.chunk.js')){ - throw new Error('The lazy module chunk did not have a name.'); - } - if (!distFiles.includes('lazy.module.0.chunk.js')){ - throw new Error('The lazy module chunk did not use a unique name.'); - } - }) - // verify System.import still works - .then(() => writeFile('src/app/lazy-file.ts', '')) - .then(() => appendToFile('src/app/app.component.ts', ` - // verify other System.import still work - declare var System: any; - const lazyFile = 'file'; - System.import('./lazy-' + lazyFile); - `)) - .then(() => ng('build', '--named-chunks')) - .then(() => readdirSync('dist')) - .then((distFiles) => { - const currentNumberOfDistFiles = distFiles.length; - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy file was not created.'); - } - if (!distFiles.includes('lazy-file.chunk.js')) { - throw new Error('The lazy file chunk did not have a name.'); - } - oldNumberOfFiles = currentNumberOfDistFiles; - }) - // verify 'import *' syntax doesn't break lazy modules - .then(() => npm('install', 'moment')) - .then(() => appendToFile('src/app/app.component.ts', ` - import * as moment from 'moment'; - console.log(moment); - `)) - .then(() => ng('build')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('Bundles were not created after adding \'import *\'.'); - } - }) - .then(() => ng('build', '--no-named-chunks')) - .then(() => readdirSync('dist')) - .then((distFiles) => { - if (distFiles.includes('lazy.module.chunk.js') - || distFiles.includes('lazy.module.0.chunk.js') - || distFiles.includes('lazy-file.chunk.js') - ) { - throw new Error('Lazy chunks shouldn\'t have a name but did.'); - } - }) - // Check for AoT and lazy routes. - .then(() => ng('build', '--aot')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('AoT build contains a different number of files.'); - } - }); -} diff --git a/tests/e2e/tests/misc/live-reload.ts b/tests/e2e/tests/misc/live-reload.ts deleted file mode 100644 index 159d6d6e00dc..000000000000 --- a/tests/e2e/tests/misc/live-reload.ts +++ /dev/null @@ -1,171 +0,0 @@ -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 = /webpack: 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, 5000)) - .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, 5000)) - .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, 5000)) - .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/tests/e2e/tests/misc/loaders-resolution.ts b/tests/e2e/tests/misc/loaders-resolution.ts index 25137f637d1a..b411ab60514a 100644 --- a/tests/e2e/tests/misc/loaders-resolution.ts +++ b/tests/e2e/tests/misc/loaders-resolution.ts @@ -1,12 +1,45 @@ import { createDir, moveFile } from '../../utils/fs'; import { ng } from '../../utils/process'; +import { assertIsError } from '../../utils/utils'; export default async function () { - await createDir('node_modules/@angular/cli/node_modules'); - await moveFile( - 'node_modules/@ngtools', - 'node_modules/@angular/cli/node_modules/@ngtools' - ); + await createDir('node_modules/@angular-devkit/build-angular/node_modules'); + let originalInRootNodeModules = true; - await ng('build'); + try { + await moveFile( + 'node_modules/@ngtools', + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + ); + } catch (e) { + assertIsError(e); + + if (e.code !== 'ENOENT') { + throw e; + } + + // In some cases due to module resolution '@ngtools' might already been under `@angular-devkit/build-angular`. + originalInRootNodeModules = false; + await moveFile( + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + 'node_modules/@ngtools', + ); + } + + await ng('build', '--configuration=development'); + + // Move it back. + await moveBack(originalInRootNodeModules); +} + +function moveBack(originalInRootNodeModules: Boolean): Promise { + return originalInRootNodeModules + ? moveFile( + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + 'node_modules/@ngtools', + ) + : moveFile( + 'node_modules/@ngtools', + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + ); } diff --git a/tests/e2e/tests/misc/minimal-config.ts b/tests/e2e/tests/misc/minimal-config.ts deleted file mode 100644 index 7e21832b0f72..000000000000 --- a/tests/e2e/tests/misc/minimal-config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { writeFile, writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default function () { - return Promise.resolve() - .then(() => writeFile('.angular-cli.json', JSON.stringify({ - apps: [{ - root: 'src', - main: 'main.ts', - scripts: [ - '../node_modules/core-js/client/shim.min.js', - '../node_modules/zone.js/dist/zone.js' - ] - }], - e2e: { protractor: { config: './protractor.conf.js' } } - }))) - .then(() => ng('e2e')) - .then(() => writeMultipleFiles({ - './src/script.js': ` - document.querySelector('app-root').innerHTML = '

app works!

'; - `, - './e2e/app.e2e-spec.ts': ` - import { browser, element, by } from 'protractor'; - - describe('minimal project App', function() { - it('should display message saying app works', () => { - browser.ignoreSynchronization = true; - browser.get('/'); - let el = element(by.css('app-root h1')).getText(); - expect(el).toEqual('app works!'); - }); - }); - `, - '.angular-cli.json': JSON.stringify({ - apps: [{ - root: 'src', - scripts: ['./script.js'] - }], - e2e: { protractor: { config: './protractor.conf.js' } } - }), - })) - .then(() => ng('e2e')); -} diff --git a/tests/e2e/tests/misc/module-resolution.ts b/tests/e2e/tests/misc/module-resolution.ts deleted file mode 100644 index 6b02d7d4e1cb..000000000000 --- a/tests/e2e/tests/misc/module-resolution.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { appendToFile, prependToFile } from '../../utils/fs'; -import { ng, silentNpm } from '../../utils/process'; - - -export default async function () { - await silentNpm('install', 'firebase@3.7.8'); - - await prependToFile('src/app/app.module.ts', 'import * as firebase from \'firebase\';'); - await appendToFile('src/app/app.module.ts', 'firebase.initializeApp({});'); - - await ng('build'); - await ng('build', '--aot'); -} diff --git a/tests/e2e/tests/misc/module-resolution/module-resolution-core-mapping.ts b/tests/e2e/tests/misc/module-resolution/module-resolution-core-mapping.ts new file mode 100644 index 000000000000..d2710b07b8be --- /dev/null +++ b/tests/e2e/tests/misc/module-resolution/module-resolution-core-mapping.ts @@ -0,0 +1,41 @@ +import { createDir, moveFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '*': ['./node_modules/*'], + }; + }); + await ng('build', '--configuration=development'); + + await createDir('xyz'); + await moveFile('node_modules/@angular/platform-browser', 'xyz/platform-browser'); + await expectToFail(() => ng('build', '--configuration=development')); + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '@angular/platform-browser': ['./xyz/platform-browser'], + }; + }); + await ng('build', '--configuration=development'); + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '*': ['./node_modules/*'], + '@angular/platform-browser': ['./xyz/platform-browser'], + }; + }); + await ng('build', '--configuration=development'); + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '@angular/platform-browser': ['./xyz/platform-browser'], + '*': ['./node_modules/*'], + }; + }); + await ng('build', '--configuration=development'); + await moveFile('xyz/platform-browser', 'node_modules/@angular/platform-browser'); +} diff --git a/tests/e2e/tests/misc/multiple-targets.ts b/tests/e2e/tests/misc/multiple-targets.ts new file mode 100644 index 000000000000..a99a37d54b06 --- /dev/null +++ b/tests/e2e/tests/misc/multiple-targets.ts @@ -0,0 +1,9 @@ +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await ng('generate', 'app', 'secondary-app'); + await ng('build', 'secondary-app', '--configuration=development'); + await expectFileToExist('dist/secondary-app/browser/index.html'); + await expectFileToExist('dist/secondary-app/browser/main.js'); +} diff --git a/tests/e2e/tests/misc/negated-boolean-options.ts b/tests/e2e/tests/misc/negated-boolean-options.ts new file mode 100644 index 000000000000..377967785496 --- /dev/null +++ b/tests/e2e/tests/misc/negated-boolean-options.ts @@ -0,0 +1,18 @@ +import { copyAssets } from '../../utils/assets'; +import { execAndWaitForOutputToMatch } from '../../utils/process'; + +export default async function () { + await copyAssets('schematic-boolean-option-negated', 'schematic-boolean-option-negated'); + + await execAndWaitForOutputToMatch( + 'ng', + ['generate', './schematic-boolean-option-negated:test', '--no-watch'], + /noWatch: true/, + ); + + await execAndWaitForOutputToMatch( + 'ng', + ['generate', './schematic-boolean-option-negated:test', '--watch'], + /noWatch: false/, + ); +} diff --git a/tests/e2e/tests/misc/nested-schematic-packages.ts b/tests/e2e/tests/misc/nested-schematic-packages.ts new file mode 100644 index 000000000000..3398a293c837 --- /dev/null +++ b/tests/e2e/tests/misc/nested-schematic-packages.ts @@ -0,0 +1,11 @@ +import { copyAssets } from '../../utils/assets'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await copyAssets('nested-schematic-main', 'nested'); + await copyAssets('nested-schematic-dependency', 'nested/node_modules/empty-app-nested'); + + await ng('generate', './nested:test'); + await expectFileToExist('empty-file'); +} diff --git a/tests/e2e/tests/misc/non-relative-module-resolution.ts b/tests/e2e/tests/misc/non-relative-module-resolution.ts deleted file mode 100644 index aeeadcb15616..000000000000 --- a/tests/e2e/tests/misc/non-relative-module-resolution.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { prependToFile, writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function () { - await writeMultipleFiles({ - './src/app/foo.ts': ` - export const foo = 'fooo'; - `, - './src/app/bar.ts': ` - import { foo } from 'app/foo'; - - console.log(foo); - ` - }), - - await prependToFile('src/app/app.module.ts', `import './bar';\n`); - - await ng('build'); - await ng('build', '--aot'); -} diff --git a/tests/e2e/tests/misc/platform-server.ts b/tests/e2e/tests/misc/platform-server.ts deleted file mode 100644 index d5c251294c9a..000000000000 --- a/tests/e2e/tests/misc/platform-server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; - -export default function () { - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['platform'] = 'server'; - })) - .then(() => expectToFail(() => ng('serve'))) - .then(() => expectToFail(() => ng('test'))) - .then(() => expectToFail(() => ng('e2e'))) - .then(() => expectToFail(() => ng('eject'))); -} diff --git a/tests/e2e/tests/misc/proxy-config.ts b/tests/e2e/tests/misc/proxy-config.ts deleted file mode 100644 index 1a165276d441..000000000000 --- a/tests/e2e/tests/misc/proxy-config.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as express from 'express'; -import * as http from 'http'; - -import {writeFile} from '../../utils/fs'; -import {request} from '../../utils/http'; -import {killAllProcesses, ng} from '../../utils/process'; -import {ngServe} from '../../utils/project'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from "../../utils/utils"; - -export default function() { - // Create an express app that serves as a proxy. - const app = express(); - const server = http.createServer(app); - server.listen(0); - - app.set('port', server.address().port); - app.get('/api/test', function (req, res) { - res.send('TEST_API_RETURN'); - }); - - const backendHost = 'localhost'; - const backendPort = server.address().port; - const proxyServerUrl = `http://${backendHost}:${backendPort}`; - const proxyConfigFile = 'proxy.config.json'; - const proxyConfig = { - '/api/*': { - target: proxyServerUrl - } - }; - - return Promise.resolve() - .then(() => writeFile(proxyConfigFile, JSON.stringify(proxyConfig, null, 2))) - .then(() => ngServe('--proxy-config', proxyConfigFile)) - .then(() => request('http://localhost:4200/api/test')) - .then(body => { - if (!body.match(/TEST_API_RETURN/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson.defaults; - app.serve = { - proxyConfig: proxyConfigFile - }; - })) - .then(() => ngServe()) - .then(() => request('http://localhost:4200/api/test')) - .then(body => { - if (!body.match(/TEST_API_RETURN/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - - .then(() => server.close(), (err) => { server.close(); throw err; }) - - // A non-existing proxy file should error. - .then(() => expectToFail(() => ng('serve', '--proxy-config', 'proxy.non-existent.json'))) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson.defaults; - app.serve = { - proxyConfig: 'proxy.non-existent.json' - }; - })) - .then(() => expectToFail(() => ng('serve'))); -} diff --git a/tests/e2e/tests/misc/public-host.ts b/tests/e2e/tests/misc/public-host.ts deleted file mode 100644 index 467b893f9445..000000000000 --- a/tests/e2e/tests/misc/public-host.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as os from 'os'; -import * as _ from 'lodash'; - -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; - -export default function () { - const firstLocalIp = _(os.networkInterfaces()) - .values() - .flatten() - .filter({ family: 'IPv4', internal: false }) - .map('address') - .first(); - const publicHost = `${firstLocalIp}:4200`; - const localAddress = `http://${publicHost}`; - - return Promise.resolve() - // Disabling this test. Webpack Dev Server does not check the hots anymore when binding to - // numeric IP addresses. - // .then(() => ngServe('--host=0.0.0.0')) - // .then(() => request(localAddress)) - // .then(body => { - // if (!body.match(/Invalid Host header/)) { - // throw new Error('Response does not match expected value.'); - // } - // }) - // .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--public-host=${publicHost}`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--disable-host-check`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--public-host=${localAddress}`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--public-host=${firstLocalIp}`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/ssl-default-config.ts b/tests/e2e/tests/misc/ssl-default-config.ts deleted file mode 100644 index c87641a54d11..000000000000 --- a/tests/e2e/tests/misc/ssl-default-config.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson.defaults; - app.serve = { ssl: true }; - })) - .then(() => ngServe()) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/ssl-default.ts b/tests/e2e/tests/misc/ssl-default.ts deleted file mode 100644 index 5f32001067ec..000000000000 --- a/tests/e2e/tests/misc/ssl-default.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => ngServe('--ssl', 'true')) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/e2e/tests/misc/ssl-with-cert-config.ts b/tests/e2e/tests/misc/ssl-with-cert-config.ts deleted file mode 100644 index 50e03f2485ce..000000000000 --- a/tests/e2e/tests/misc/ssl-with-cert-config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { request } from '../../utils/http'; -import { assetDir } from '../../utils/assets'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson.defaults; - app.serve = { - ssl: true, - sslKey: assetDir('ssl/server.key'), - sslCert: assetDir('ssl/server.crt') - }; - })) - .then(() => ngServe()) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); - -} diff --git a/tests/e2e/tests/misc/ssl-with-cert.ts b/tests/e2e/tests/misc/ssl-with-cert.ts deleted file mode 100644 index de2e0c90ca17..000000000000 --- a/tests/e2e/tests/misc/ssl-with-cert.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { request } from '../../utils/http'; -import { assetDir } from '../../utils/assets'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function() { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => ngServe( - '--ssl', 'true', - '--ssl-key', assetDir('ssl/server.key'), - '--ssl-cert', assetDir('ssl/server.crt') - )) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); - -} diff --git a/tests/e2e/tests/misc/supported-angular.ts b/tests/e2e/tests/misc/supported-angular.ts new file mode 100644 index 000000000000..d5299485bb7f --- /dev/null +++ b/tests/e2e/tests/misc/supported-angular.ts @@ -0,0 +1,37 @@ +import { SemVer } from 'semver'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + if (getGlobalVariable('argv')['ng-snapshots']) { + // The snapshots job won't work correctly because it doesn't use semver for Angular. + return; + } + + const angularCliPkgJson = JSON.parse(await readFile('node_modules/@angular/cli/package.json')); + const cliMajor = new SemVer(angularCliPkgJson.version as string).major; + const angularCorePkgPath = 'node_modules/@angular/core/package.json'; + const originalAngularCorePkgJson = await readFile(angularCorePkgPath); + + // Fake version by writing over the @angular/core version, since that's what the CLI checks. + const fakeCoreVersion = async (newMajor: number) => { + const tmpPkgJson = JSON.parse(originalAngularCorePkgJson); + tmpPkgJson.version = `${newMajor}.0.0`; + await writeFile(angularCorePkgPath, JSON.stringify(tmpPkgJson)); + }; + + // Major should succeed, but we don't need to test it here since it's tested everywhere else. + // Major+1 and -1 should fail architect commands, but allow other commands. + try { + await fakeCoreVersion(cliMajor + 1); + await expectToFail(() => ng('build'), 'Should fail Major+1'); + await ng('version'); + await fakeCoreVersion(cliMajor - 1); + await ng('version'); + } finally { + // Restore the original core package.json. + await writeFile(angularCorePkgPath, originalAngularCorePkgJson); + } +} diff --git a/tests/e2e/tests/misc/target-default-configuration.ts b/tests/e2e/tests/misc/target-default-configuration.ts new file mode 100644 index 000000000000..d5f381b2db97 --- /dev/null +++ b/tests/e2e/tests/misc/target-default-configuration.ts @@ -0,0 +1,35 @@ +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = undefined; + build.options = { + ...build.options, + optimization: false, + buildOptimizer: false, + outputHashing: 'none', + sourceMap: true, + }; + }); + + await ng('build'); + await expectFileToExist('dist/test-project/browser/main.js'); + await expectFileToExist('dist/test-project/browser/main.js.map'); + + // Add new configuration and set "defaultConfiguration" + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = 'foo'; + build.configurations.foo = { + sourceMap: false, + }; + }); + + await ng('build'); + await expectFileToExist('dist/test-project/browser/main.js'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/main.js.map')); +} diff --git a/tests/e2e/tests/misc/trace-resolution.ts b/tests/e2e/tests/misc/trace-resolution.ts new file mode 100644 index 000000000000..0827223e58a0 --- /dev/null +++ b/tests/e2e/tests/misc/trace-resolution.ts @@ -0,0 +1,22 @@ +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.traceResolution = true; + }); + + const { stdout: stdoutTraced } = await ng('build', '--configuration=development'); + if (!/Resolving module/.test(stdoutTraced)) { + throw new Error(`Modules resolutions must be printed when 'traceResolution' is enabled.`); + } + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.traceResolution = false; + }); + + const { stdout: stdoutUnTraced } = await ng('build', '--configuration=development'); + if (/Resolving module/.test(stdoutUnTraced)) { + throw new Error(`Modules resolutions must not be printed when 'traceResolution' is disabled.`); + } +} diff --git a/tests/e2e/tests/misc/trusted-types.ts b/tests/e2e/tests/misc/trusted-types.ts new file mode 100644 index 000000000000..21deb03223dd --- /dev/null +++ b/tests/e2e/tests/misc/trusted-types.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import assert from 'node:assert/strict'; +import { replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Add lazy route. + await ng('generate', 'component', 'lazy'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [{path: 'lazy', loadComponent: () => import('./lazy/lazy').then(c => c.Lazy)}];`, + ); + + const testCases = [ + { + aot: false, + csp: `trusted-types angular angular#unsafe-bypass angular#unsafe-jit angular#bundler; require-trusted-types-for 'script';`, + }, + { + aot: true, + csp: `trusted-types angular angular#unsafe-bypass angular#bundler; require-trusted-types-for 'script';`, + }, + ]; + + for (const { aot, csp } of testCases) { + await updateJsonFile('angular.json', (json) => { + const architect = json['projects']['test-project']['architect']; + architect['build']['options']['aot'] = aot; + if (!architect['serve']['options']) architect['serve']['options'] = {}; + architect['serve']['options']['headers'] = { + 'Content-Security-Policy': csp, + }; + }); + + try { + await executeBrowserTest({ + checkFn: async (page) => { + const baseUrl = page.url(); + await page.goto(new URL('/lazy', baseUrl).href); + + await page.waitForSelector('app-lazy p'); + const lazyText = await page.$eval('app-lazy p', (el) => el.textContent); + assert.strictEqual(lazyText, 'lazy works!'); + }, + }); + } catch (error) { + console.error(`Test case AOT ${aot} with CSP header ${csp} failed.`); + throw error; + } + } +} diff --git a/tests/e2e/tests/misc/update-git-clean-subdirectory.ts b/tests/e2e/tests/misc/update-git-clean-subdirectory.ts new file mode 100644 index 000000000000..733a91164019 --- /dev/null +++ b/tests/e2e/tests/misc/update-git-clean-subdirectory.ts @@ -0,0 +1,25 @@ +import { getGlobalVariable } from '../../utils/env'; +import { createDir, writeFile } from '../../utils/fs'; +import { ng, silentGit } from '../../utils/process'; +import { prepareProjectForE2e } from '../../utils/project'; + +export default async function () { + process.chdir(getGlobalVariable('projects-root')); + + await createDir('./subdirectory'); + process.chdir('./subdirectory'); + + await silentGit('init', '.'); + + await ng('new', 'subdirectory-test-project', '--skip-install', '--test-runner', 'karma'); + process.chdir('./subdirectory-test-project'); + await prepareProjectForE2e('subdirectory-test-project'); + + await writeFile('../added.ts', "console.log('created');\n"); + await silentGit('add', '../added.ts'); + + const { stderr } = await ng('update', '@angular/cli'); + if (stderr && stderr.includes('Repository is not clean.')) { + throw new Error('Expected clean repository'); + } +} diff --git a/tests/e2e/tests/misc/update-git-clean.ts b/tests/e2e/tests/misc/update-git-clean.ts new file mode 100644 index 000000000000..c992c695c4e3 --- /dev/null +++ b/tests/e2e/tests/misc/update-git-clean.ts @@ -0,0 +1,12 @@ +import { appendToFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await appendToFile('src/main.ts', "console.log('changed');\n"); + + const { message } = await expectToFail(() => ng('update', '@angular/cli')); + if (!message || !message.includes('Repository is not clean.')) { + throw new Error('Expected unclean repository'); + } +} diff --git a/tests/e2e/tests/misc/version.ts b/tests/e2e/tests/misc/version.ts index c49c0c62dae3..4ad57adc9726 100644 --- a/tests/e2e/tests/misc/version.ts +++ b/tests/e2e/tests/misc/version.ts @@ -1,10 +1,22 @@ -import {deleteFile} from '../../utils/fs'; -import {ng} from '../../utils/process'; +import { deleteFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +export default async function () { + const { stdout: commandOutput } = await ng('version'); -export default function() { - return ng('version') - .then(() => deleteFile('.angular-cli.json')) - // doesn't fail on a project with missing .angular-cli.json - .then(() => ng('version')); + if (commandOutput.includes(process.versions.node + ' (Unsupported)')) { + throw new Error('Node version should not show unsupported entry'); + } + + if (commandOutput.includes('Warning: The current version of Node ')) { + throw new Error('Node support warning should not be shown'); + } + + // doesn't fail on a project with missing angular.json + await deleteFile('angular.json'); + await ng('version'); + + // Doesn't fail outside a project. + process.chdir('/'); + await ng('version'); } diff --git a/tests/e2e/tests/misc/workspace-verification.ts b/tests/e2e/tests/misc/workspace-verification.ts new file mode 100644 index 000000000000..bf55841a9398 --- /dev/null +++ b/tests/e2e/tests/misc/workspace-verification.ts @@ -0,0 +1,14 @@ +import { deleteFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default function () { + return ( + ng('generate', 'component', 'foo', '--dry-run') + .then(() => deleteFile('angular.json')) + // fails because it needs to be inside a project + // without a workspace file + .then(() => expectToFail(() => ng('generate', 'component', 'foo', '--dry-run'))) + .then(() => ng('version')) + ); +} diff --git a/tests/e2e/tests/packages/webpack/server.ts b/tests/e2e/tests/packages/webpack/server.ts deleted file mode 100644 index be50abae69a3..000000000000 --- a/tests/e2e/tests/packages/webpack/server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {normalize} from 'path'; -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec} from '../../../utils/process'; -import {expectFileToMatch, rimraf} from '../../../utils/fs'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-server-app')) - .then(() => exec(normalize('node_modules/.bin/webpack'))) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('.bootstrapModuleFactory')) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('MyInjectable.ctorParameters = .*' - + 'type: undefined, decorators.*Inject.*args: .*DOCUMENT.*')) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('AppComponent.ctorParameters = .*MyInjectable')) - .then(() => expectFileToMatch('dist/app.main.js', - /AppModule \*\/\].*\.testProp = \'testing\'/)) - .then(() => expectFileToMatch('dist/app.main.js', - /renderModuleFactory \*\/\].*\/\* AppModuleNgFactory \*\/\]/)) - .then(() => rimraf('dist')) - .then(() => exec(normalize('node_modules/.bin/webpack'), - '--config', 'webpack.commonjs.config.js')) - .then(() => expectFileToMatch('dist/app.main.js', - /__webpack_exports__, "AppModuleNgFactory"/)) - .then(() => expectFileToMatch('dist/app.main.js', - /var LAZY_MODULE_MAP = { ".\/lazy\.module#LazyModule": /)) - .then(() => exec(normalize('node'), 'index.js')) - .then(() => expectFileToMatch('dist/index.html', - new RegExp('lazy'))) - .then(() => skipCleaning()); -} diff --git a/tests/e2e/tests/packages/webpack/test.ts b/tests/e2e/tests/packages/webpack/test.ts deleted file mode 100644 index d59139d2620d..000000000000 --- a/tests/e2e/tests/packages/webpack/test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {normalize} from 'path'; -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec} from '../../../utils/process'; -import {expectFileSizeToBeUnder, replaceInFile, expectFileToMatch} from '../../../utils/fs'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-app')) - .then(() => exec(normalize('node_modules/.bin/webpack'), '-p')) - .then(() => expectFileSizeToBeUnder('dist/app.main.js', 420000)) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 10000)) - // test resource urls without ./ - .then(() => replaceInFile('app/app.component.ts', - './app.component.html', 'app.component.html')) - .then(() => replaceInFile('app/app.component.ts', - './app.component.scss', 'app.component.scss')) - // test the inclusion of metadata - // This build also test resource URLs without ./ - .then(() => exec(normalize('node_modules/.bin/webpack'))) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('MyInjectable.ctorParameters = .*' - + 'type: .*ViewContainerRef.*' - + 'type: undefined, decorators.*Inject.*args: .*DOCUMENT.*')) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('AppComponent.ctorParameters = .*MyInjectable')) - .then(() => skipCleaning()); -} diff --git a/tests/e2e/tests/packages/webpack/weird.ts b/tests/e2e/tests/packages/webpack/weird.ts deleted file mode 100644 index 353afb7e0a31..000000000000 --- a/tests/e2e/tests/packages/webpack/weird.ts +++ /dev/null @@ -1,45 +0,0 @@ -import {normalize} from 'path'; - -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec, execWithEnv} from '../../../utils/process'; -import {updateJsonFile} from '../../../utils/project'; -import {expectFileSizeToBeUnder, expectFileToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-app-weird')) - .then(() => exec(normalize('node_modules/.bin/webpack'), '-p')) - .then(() => expectFileToExist('dist/app.main.js')) - .then(() => expectFileToExist('dist/0.app.main.js')) - .then(() => expectFileToExist('dist/1.app.main.js')) - .then(() => expectFileToExist('dist/2.app.main.js')) - .then(() => expectFileSizeToBeUnder('dist/app.main.js', 410000)) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) - - // Verify that we're using the production environment. - .then(() => expectFileToMatch('dist/app.main.js', /PRODUCTION_ONLY/)) - .then(() => expectToFail(() => expectFileToMatch('dist/app.main.js', /DEBUG_ONLY/))) - - // Skip code generation and rebuild. - .then(() => updateJsonFile('aotplugin.config.json', json => { - json['skipCodeGeneration'] = true; - })) - .then(() => updateJsonFile('webpack.flags.json', json => { - json['DEBUG'] = true; - })) - .then(() => exec(normalize('node_modules/.bin/webpack'), '-p')) - .then(() => expectFileToExist('dist/app.main.js')) - .then(() => expectFileToExist('dist/0.app.main.js')) - .then(() => expectFileToExist('dist/1.app.main.js')) - .then(() => expectFileToExist('dist/2.app.main.js')) - .then(() => expectToFail(() => expectFileSizeToBeUnder('dist/app.main.js', 410000))) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) - - // Verify that we're using the debug environment now. - .then(() => expectFileToMatch('dist/app.main.js', /DEBUG_ONLY/)) - .then(() => expectToFail(() => expectFileToMatch('dist/app.main.js', /PRODUCTION_ONLY/))) - - .then(() => skipCleaning()); -} diff --git a/tests/e2e/tests/schematics_cli/basic.ts b/tests/e2e/tests/schematics_cli/basic.ts new file mode 100644 index 000000000000..62128e1e32db --- /dev/null +++ b/tests/e2e/tests/schematics_cli/basic.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, execAndWaitForOutputToMatch, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create blank schematic + await exec('schematics', 'schematic', '--name', 'test-schematic'); + + process.chdir(join(startCwd, 'test-schematic')); + await execAndWaitForOutputToMatch( + 'schematics', + ['.:', '--list-schematics'], + /my-full-schematic/, + ); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/schematics_cli/blank-test.ts b/tests/e2e/tests/schematics_cli/blank-test.ts new file mode 100644 index 000000000000..4087f7476db9 --- /dev/null +++ b/tests/e2e/tests/schematics_cli/blank-test.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create schematic + await exec('schematics', 'blank', '--name', 'test-schematic'); + + process.chdir(schematicPath); + + await silentNpm('test'); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/schematics_cli/schematic-test.ts b/tests/e2e/tests/schematics_cli/schematic-test.ts new file mode 100644 index 000000000000..7d7d26e7304b --- /dev/null +++ b/tests/e2e/tests/schematics_cli/schematic-test.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create schematic + await exec('schematics', 'schematic', '--name', 'test-schematic'); + + process.chdir(schematicPath); + + await silentNpm('test'); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/test/e2e-baseurl.ts b/tests/e2e/tests/test/e2e-baseurl.ts deleted file mode 100644 index 8a70d4ef6197..000000000000 --- a/tests/e2e/tests/test/e2e-baseurl.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ng, killAllProcesses } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => expectToFail(() => ng('e2e', '--no-serve'))) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson.defaults; - app.serve = { port: 4400 }; - })) - .then(() => ngServe()) - .then(() => expectToFail(() => ng('e2e', '--no-serve'))) - .then(() => ng('e2e', '--no-serve', '--base-href=http://localhost:4400')) - .then(() => ng('e2e', '--no-serve', '--port=4400')) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/e2e/tests/test/e2e.ts b/tests/e2e/tests/test/e2e.ts deleted file mode 100644 index aca46ca1f624..000000000000 --- a/tests/e2e/tests/test/e2e.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - ng, - npm, - execAndWaitForOutputToMatch, - killAllProcesses -} from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; -import { moveFile, copyFile } from '../../utils/fs'; - - -export default function () { - // Should fail without updated webdriver - return updateJsonFile('package.json', packageJson => { - // Add to npm scripts to make running the binary compatible with Windows - const scripts = packageJson['scripts']; - scripts['wd:clean'] = 'webdriver-manager clean'; - }) - .then(() => npm('run', 'wd:clean')) - .then(() => expectToFail(() => ng('e2e', '--no-webdriver-update', '--no-serve'))) - // Should fail without serving - .then(() => expectToFail(() => ng('e2e', '--no-serve'))) - // These should work. - .then(() => ng('e2e')) - .then(() => ng('e2e', '--prod')) - // Should use port in baseUrl - .then(() => ng('e2e', '--port', '4400')) - // Should accept different config file - .then(() => moveFile('./protractor.conf.js', './renamed-protractor.conf.js')) - .then(() => ng('e2e', '--config', './renamed-protractor.conf.js')) - .then(() => moveFile('./renamed-protractor.conf.js', './protractor.conf.js')) - // Should accept different multiple spec files - .then(() => moveFile('./e2e/app.e2e-spec.ts', './e2e/renamed-app.e2e-spec.ts')) - .then(() => copyFile('./e2e/renamed-app.e2e-spec.ts', './e2e/another-app.e2e-spec.ts')) - .then(() => ng('e2e', '--specs', './e2e/renamed-app.e2e-spec.ts', - '--specs', './e2e/another-app.e2e-spec.ts')) - // Should start up Element Explorer - .then(() => execAndWaitForOutputToMatch('ng', ['e2e', '--element-explorer'], - /Element Explorer/)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }) - // Should run side-by-side with `ng serve` - .then(() => execAndWaitForOutputToMatch('ng', ['serve'], - /webpack: Compiled successfully./)) - .then(() => ng('e2e')); -} diff --git a/tests/e2e/tests/test/karma-junit-output.ts b/tests/e2e/tests/test/karma-junit-output.ts new file mode 100644 index 000000000000..dbc61c3fafc5 --- /dev/null +++ b/tests/e2e/tests/test/karma-junit-output.ts @@ -0,0 +1,27 @@ +import { expectFileMatchToExist, replaceInFile } from '../../utils/fs'; +import { installPackage } from '../../utils/packages'; +import { silentNg } from '../../utils/process'; + +const E2E_CUSTOM_LAUNCHER = ` + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'], + }, + }, + restartOnFileChange: true +`; + +export default async function () { + await installPackage('karma-junit-reporter'); + await silentNg('generate', 'config', 'karma'); + + await replaceInFile('karma.conf.js', 'karma-jasmine-html-reporter', 'karma-junit-reporter'); + await replaceInFile('karma.conf.js', `'kjhtml'`, `'junit'`); + + await replaceInFile('karma.conf.js', `restartOnFileChange: true`, E2E_CUSTOM_LAUNCHER); + + await silentNg('test', '--no-watch'); + + await expectFileMatchToExist('.', /TESTS\-.+\.xml/); +} diff --git a/tests/e2e/tests/test/test-assets.ts b/tests/e2e/tests/test/test-assets.ts deleted file mode 100644 index adf3ee27328c..000000000000 --- a/tests/e2e/tests/test/test-assets.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; -import { getGlobalVariable } from '../../utils/env'; -import { stripIndent } from 'common-tags'; - -// Make sure asset files are served -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/assets/file.txt': 'assets-folder-content', - 'src/file.txt': 'file-content', - // Not using `async()` in tests as it seemed to swallow `fetch()` errors - 'src/app/app.component.spec.ts': stripIndent` - describe('Test Runner', () => { - const fetch = global['fetch']; - it('should serve files in assets folder', (done) => { - fetch('/assets/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('assets-folder-content'); - done(); - }); - }); - it('should serve files explicitly added to assets array', (done) => { - fetch('/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('file-content'); - done(); - }); - }); - }); - ` - })) - // Test failure condition (no assets in .angular-cli.json) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = []; - })) - .then(() => expectToFail(() => ng('test', '--single-run'), - 'Should fail because the assets to serve were not in the Angular CLI config')) - // Test passing condition (assets are included) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['assets'] = ['assets', 'file.txt']; - })) - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/test/test-backwards-compat.ts b/tests/e2e/tests/test/test-backwards-compat.ts deleted file mode 100644 index 067f19b9c31e..000000000000 --- a/tests/e2e/tests/test/test-backwards-compat.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ng } from '../../utils/process'; -import { replaceInFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - - // Old configs (with the cli preprocessor listed) should still supported. - return Promise.resolve() - .then(() => replaceInFile('karma.conf.js', - 'coverageIstanbulReporter: {', ` - files: [ - { pattern: './src/test.ts', watched: false } - ], - preprocessors: { - './src/test.ts': ['@angular/cli'] - }, - mime: { - 'text/x-typescript': ['ts','tsx'] - }, - coverageIstanbulReporter: { - `)) - .then(() => ng('test', '--single-run')); -} diff --git a/tests/e2e/tests/test/test-code-coverage-exclude.ts b/tests/e2e/tests/test/test-code-coverage-exclude.ts new file mode 100644 index 000000000000..808bcb59e2d9 --- /dev/null +++ b/tests/e2e/tests/test/test-code-coverage-exclude.ts @@ -0,0 +1,26 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, rimraf } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; + const coverageOptionName = isWebpack ? '--code-coverage' : '--coverage'; + + // This test is already in build-angular, but that doesn't run on Windows. + await silentNg('test', '--no-watch', coverageOptionName); + await expectFileToExist('coverage/test-project/app.ts.html'); + // Delete coverage directory + await rimraf('coverage'); + + await silentNg( + 'test', + '--no-watch', + coverageOptionName, + `${coverageOptionName}-exclude='src/**/app.ts'`, + ); + + // Doesn't include excluded. + await expectFileToExist('coverage/test-project/index.html'); + await expectToFail(() => expectFileToExist('coverage/test-project/app.ts.html')); +} diff --git a/tests/e2e/tests/test/test-environment.ts b/tests/e2e/tests/test/test-environment.ts index 4a5fe4a08702..1670cb246521 100644 --- a/tests/e2e/tests/test/test-environment.ts +++ b/tests/e2e/tests/test/test-environment.ts @@ -1,28 +1,71 @@ import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; +import { writeFile, writeMultipleFiles } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; -export default function () { // Tests run in 'dev' environment by default. - return writeFile('src/app/environment.spec.ts', ` - import { environment } from '../environments/environment'; + await writeMultipleFiles({ + 'src/environment.prod.ts': ` + export const environment = { + production: true + };`, + 'src/environment.ts': ` + export const environment = { + production: false + }; + `, + 'src/app/environment.spec.ts': ` + import { environment } from '../environment'; describe('Test environment', () => { it('should have production disabled', () => { expect(environment.production).toBe(false); }); }); - `) - .then(() => ng('test', '--single-run')) + `, + }); + + await ng('test', '--watch=false'); + + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + appArchitect[isWebpack ? 'test' : 'build'].configurations = { + production: { + fileReplacements: [ + { + replace: 'src/environment.ts', + with: 'src/environment.prod.ts', + }, + ], + }, + }; + if (!isWebpack) { + appArchitect.test.options ??= {}; + appArchitect.test.options.buildTarget = '::production'; + } + }); - // Tests can run in different environment. - .then(() => writeFile('src/app/environment.spec.ts', ` - import { environment } from '../environments/environment'; + // Tests can run in different environment. + + await writeFile( + 'src/app/environment.spec.ts', + ` + import { environment } from '../environment'; describe('Test environment', () => { it('should have production enabled', () => { expect(environment.production).toBe(true); }); }); - `)) - .then(() => ng('test', '-e', 'prod', '--single-run')); + `, + ); + + if (isWebpack) { + await ng('test', '--watch=false', '--configuration=production'); + } else { + await ng('test', '--watch=false'); + } } diff --git a/tests/e2e/tests/test/test-fail-single-run.ts b/tests/e2e/tests/test/test-fail-single-run.ts index bdcbb14604df..d2054e7c37ee 100644 --- a/tests/e2e/tests/test/test-fail-single-run.ts +++ b/tests/e2e/tests/test/test-fail-single-run.ts @@ -2,9 +2,11 @@ import { ng } from '../../utils/process'; import { writeFile } from '../../utils/fs'; import { expectToFail } from '../../utils/utils'; - export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. + // Fails on single run with broken compilation. - return writeFile('src/app.component.spec.ts', '

definitely not typescript

') - .then(() => expectToFail(() => ng('test', '--single-run'))); + return writeFile('src/app.spec.ts', '

definitely not typescript

').then(() => + expectToFail(() => ng('test', '--watch=false')), + ); } diff --git a/tests/e2e/tests/test/test-fail-watch.ts b/tests/e2e/tests/test/test-fail-watch.ts deleted file mode 100644 index 282f028d348d..000000000000 --- a/tests/e2e/tests/test/test-fail-watch.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { readFile, writeFile } from '../../utils/fs'; - - -// Karma is only really finished with a run when it shows a non-zero total time in the first slot. -const karmaGoodRegEx = /Executed 3 of 3 SUCCESS \(\d+\.\d+ secs/; - -export default function () { - let originalSpec: string; - return execAndWaitForOutputToMatch('ng', ['test'], karmaGoodRegEx) - .then(() => readFile('src/app/app.component.spec.ts')) - .then((data) => originalSpec = data) - // Trigger a failed rebuild, which shouldn't run tests again. - .then(() => writeFile('src/app/app.component.spec.ts', '

definitely not typescript

')) - .then(() => expectToFail(() => waitForAnyProcessOutputToMatch(karmaGoodRegEx, 10000))) - // Restore working spec. - .then(() => writeFile('src/app/app.component.spec.ts', originalSpec)) - .then(() => waitForAnyProcessOutputToMatch(karmaGoodRegEx, 20000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/e2e/tests/test/test-include-glob.ts b/tests/e2e/tests/test/test-include-glob.ts new file mode 100644 index 000000000000..5dc55edbf8c7 --- /dev/null +++ b/tests/e2e/tests/test/test-include-glob.ts @@ -0,0 +1,5 @@ +import { ng } from '../../utils/process'; + +export default async function () { + await ng('test', '--no-watch', `--include='**/*.spec.ts'`); +} diff --git a/tests/e2e/tests/test/test-jasmine-clock.ts b/tests/e2e/tests/test/test-jasmine-clock.ts new file mode 100644 index 000000000000..37b164ff5914 --- /dev/null +++ b/tests/e2e/tests/test/test-jasmine-clock.ts @@ -0,0 +1,33 @@ +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; + +export default async function () { + await writeFile( + 'src/app/app.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; + + describe('App', () => { + beforeAll(() => { + jasmine.clock().install(); + }); + + afterAll(() => { + jasmine.clock().uninstall(); + }); + + beforeEach(() => TestBed.configureTestingModule({ + imports: [App] + })); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + ); + + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/test/test-scripts.ts b/tests/e2e/tests/test/test-scripts.ts index 4ca07efd65db..953ed52cfbd7 100644 --- a/tests/e2e/tests/test/test-scripts.ts +++ b/tests/e2e/tests/test/test-scripts.ts @@ -1,78 +1,71 @@ +import { getGlobalVariable } from '../../utils/env'; import { writeMultipleFiles } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; import { expectToFail } from '../../utils/utils'; -import { getGlobalVariable } from '../../utils/env'; -import { stripIndent } from 'common-tags'; - -export default function () { - // Skip this in Appveyor tests. - if (getGlobalVariable('argv').appveyor) { - return Promise.resolve(); - } - return Promise.resolve() - .then(() => ng('test', '--watch=false')) - // prepare global scripts test files - .then(() => writeMultipleFiles({ - 'src/string-script.js': `stringScriptGlobal = 'string-scripts.js';`, - 'src/input-script.js': `inputScriptGlobal = 'input-scripts.js';`, - 'src/typings.d.ts': stripIndent` - declare var stringScriptGlobal: any; - declare var inputScriptGlobal: any; - `, - 'src/app/app.component.ts': stripIndent` - import { Component } from '@angular/core'; +export default async function () { + // prepare global scripts test files + await writeMultipleFiles({ + 'src/string-script.js': `globalThis.stringScriptGlobal = 'string-scripts.js';`, + 'src/input-script.js': `globalThis.inputScriptGlobal = 'input-scripts.js';`, + 'src/typings.d.ts': ` + declare var stringScriptGlobal: any; + declare var inputScriptGlobal: any; + `, + 'src/app/app.ts': ` + import { Component } from '@angular/core'; - @Component({ selector: 'app-root', template: '' }) - export class AppComponent { - stringScriptGlobalProp = stringScriptGlobal; - inputScriptGlobalProp = inputScriptGlobal; - } - `, - 'src/app/app.component.spec.ts': stripIndent` - import { TestBed, async } from '@angular/core/testing'; - import { AppComponent } from './app.component'; + @Component({ selector: 'app-root', template: '', standalone: false }) + export class App { + stringScriptGlobalProp = stringScriptGlobal; + inputScriptGlobalProp = inputScriptGlobal; + } + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; - describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ AppComponent ] - }).compileComponents(); - })); + describe('App', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [App] + })); - it('should have access to string-script.js', async(() => { - let app = TestBed.createComponent(AppComponent).debugElement.componentInstance; - expect(app.stringScriptGlobalProp).toEqual('string-scripts.js'); - })); + it('should have access to string-script.js', () => { + let app = TestBed.createComponent(App).debugElement.componentInstance; + expect(app.stringScriptGlobalProp).toEqual('string-scripts.js'); + }); - it('should have access to input-script.js', async(() => { - let app = TestBed.createComponent(AppComponent).debugElement.componentInstance; - expect(app.inputScriptGlobalProp).toEqual('input-scripts.js'); - })); + it('should have access to input-script.js', () => { + let app = TestBed.createComponent(App).debugElement.componentInstance; + expect(app.inputScriptGlobalProp).toEqual('input-scripts.js'); }); + }); - describe('Spec', () => { - it('should have access to string-script.js', async(() => { - expect(stringScriptGlobal).toBe('string-scripts.js'); - })); + describe('Spec', () => { + it('should have access to string-script.js', () => { + expect(stringScriptGlobal).toBe('string-scripts.js'); + }); - it('should have access to input-script.js', async(() => { - expect(inputScriptGlobal).toBe('input-scripts.js'); - })); + it('should have access to input-script.js', () => { + expect(inputScriptGlobal).toBe('input-scripts.js'); }); - ` - })) - // should fail because the global scripts were not added to scripts array - .then(() => expectToFail(() => ng('test', '--single-run'))) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['scripts'] = [ - 'string-script.js', - { input: 'input-script.js' } - ]; - })) - // should pass now - .then(() => ng('test', '--single-run')); -} + }); + `, + }); + + // should fail because the global scripts were not added to scripts array + await expectToFail(() => ng('test', '--watch=false')); + const isWebpack = !getGlobalVariable('argv')['esbuild']; + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect[isWebpack ? 'test' : 'build'].options.scripts = [ + { input: 'src/string-script.js' }, + { input: 'src/input-script.js' }, + ]; + }); + + // should pass now + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/test/test-sourcemap.ts b/tests/e2e/tests/test/test-sourcemap.ts new file mode 100644 index 000000000000..5d4e668c54b6 --- /dev/null +++ b/tests/e2e/tests/test/test-sourcemap.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert'; +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { assertIsError } from '../../utils/utils'; +import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; + + await writeFile( + 'src/app/app.spec.ts', + ` + it('should fail', () => { + expect(undefined).toBeTruthy(); + }); + `, + ); + + // when sourcemaps are 'on' the stacktrace will point to the spec.ts file. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + if (isWebpack) { + appArchitect['test'].options.sourceMap = true; + } else { + appArchitect['build'].configurations.development.sourceMap = true; + } + }); + try { + await ng('test', '--no-watch'); + throw new Error('ng test should have failed.'); + } catch (error) { + assertIsError(error); + assert.match(error.message, /\(src\/app\/app\.spec\.ts:3:27/); + assert.doesNotMatch(error.message, /webpack/); + } + + // when sourcemaps are 'off' the stacktrace won't point to the spec.ts file. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + if (isWebpack) { + appArchitect['test'].options.sourceMap = false; + } else { + appArchitect['build'].configurations.development.sourceMap = false; + } + }); + try { + await ng('test', '--no-watch'); + throw new Error('ng test should have failed.'); + } catch (error) { + assertIsError(error); + assert.match(error.message, /main\.js/); + } +} diff --git a/tests/e2e/tests/test/test.ts b/tests/e2e/tests/test/test.ts deleted file mode 100644 index 2acbd339b7c6..000000000000 --- a/tests/e2e/tests/test/test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ng } from '../../utils/process'; -import { moveFile } from '../../utils/fs'; - -export default function () { - // make sure both --watch=false and --single-run work - return ng('test', '--single-run') - .then(() => ng('test', '--watch=false')) - .then(() => moveFile('./karma.conf.js', './karma.conf.bis.js')) - .then(() => ng('test', '--single-run', '--config', 'karma.conf.bis.js')); -} diff --git a/tests/e2e/tests/third-party/bootstrap.ts b/tests/e2e/tests/third-party/bootstrap.ts deleted file mode 100644 index 6cf6626c37c8..000000000000 --- a/tests/e2e/tests/third-party/bootstrap.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {npm, ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectFileToMatch} from '../../utils/fs'; -import {oneLineTrim} from 'common-tags'; - - -export default function() { - return Promise.resolve() - .then(() => npm('install', 'bootstrap@next')) - .then(() => updateJsonFile('.angular-cli.json', configJson => { - const app = configJson['apps'][0]; - app['styles'].push('../node_modules/bootstrap/dist/css/bootstrap.css'); - app['scripts'].push( - '../node_modules/bootstrap/dist/js/bootstrap.js' - ); - })) - .then(() => ng('build', '--extract-css')) - .then(() => expectFileToMatch('dist/scripts.bundle.js', '* Bootstrap')) - .then(() => expectFileToMatch('dist/styles.bundle.css', '* Bootstrap')) - .then(() => expectFileToMatch('dist/index.html', oneLineTrim` - - - - - - `)); -} diff --git a/tests/e2e/tests/update/update-application-builder.ts b/tests/e2e/tests/update/update-application-builder.ts new file mode 100644 index 000000000000..2769fe943138 --- /dev/null +++ b/tests/e2e/tests/update/update-application-builder.ts @@ -0,0 +1,36 @@ +import { match } from 'node:assert'; +import { createProjectFromAsset } from '../../utils/assets'; +import { + expectFileMatchToExist, + expectFileNotToExist, + expectFileToExist, + expectFileToMatch, +} from '../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg } from '../../utils/process'; +import { findFreePort } from '../../utils/network'; + +export default async function () { + await createProjectFromAsset('ssr-project-webpack', false, false); + await ng('update', `@angular/cli`, '--name=use-application-builder'); + + await Promise.all([ + expectFileNotToExist('tsconfig.server.json'), + expectFileToMatch('tsconfig.json', 'esModuleInterop'), + expectFileToMatch('src/server.ts', 'import.meta.url'), + ]); + + // Verify project now creates bundles + await noSilentNg('build', '--configuration=production'); + + await Promise.all([ + expectFileToExist('dist/ssr-project-webpack/server/server.mjs'), + expectFileMatchToExist('dist/ssr-project-webpack/browser', /main-[a-zA-Z0-9]{8}\.js/), + ]); + + // Verify that the app runs + const port = await findFreePort(); + await execAndWaitForOutputToMatch('ng', ['serve', '--port', String(port)], /complete\./); + const response = await fetch(`http://localhost:${port}/`); + const text = await response.text(); + match(text, /app is running!/); +} diff --git a/tests/e2e/tests/update/update-multiple-versions.ts b/tests/e2e/tests/update/update-multiple-versions.ts new file mode 100644 index 000000000000..c9070b1a090b --- /dev/null +++ b/tests/e2e/tests/update/update-multiple-versions.ts @@ -0,0 +1,43 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { setRegistry } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { isPrereleaseCli } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + let restoreRegistry: (() => Promise) | undefined; + try { + restoreRegistry = await createProjectFromAsset('20.0-project', true); + await setRegistry(true); + + const extraArgs = ['--force']; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + + // TODO(alanagius): investigate how to re-enable this. This is failing but it's correct since we are using the public registry. + // Update Angular from v13 to 14 + // const { stdout } = await ng('update', ...extraArgs); + // if (!/@angular\/core\s+13\.\d\.\d+ -> 14\.\d\.\d+\s+ng update @angular\/core@14/.test(stdout)) { + // // @angular/core 13.x.x -> 14.x.x ng update @angular/core@14 + // throw new Error( + // `Output didn't match "@angular/core 13.x.x -> 14.x.x ng update @angular/core@14". OUTPUT: \n` + + // stdout, + // ); + // } + + const { message } = await expectToFail(() => ng('update', '@angular/core', ...extraArgs)); + if ( + !message.includes( + `Updating multiple major versions of '@angular/core' at once is not supported`, + ) + ) { + throw new Error( + `Expected error message to include "Updating multiple major versions of '@angular/core' at once is not supported" but didn't. OUTPUT: \n` + + message, + ); + } + } finally { + await restoreRegistry?.(); + } +} diff --git a/tests/e2e/tests/update/update-pnpm-workspace.ts b/tests/e2e/tests/update/update-pnpm-workspace.ts new file mode 100644 index 000000000000..29aff7bff42a --- /dev/null +++ b/tests/e2e/tests/update/update-pnpm-workspace.ts @@ -0,0 +1,33 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { readFile, writeFile } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng } from '../../utils/process'; + +export default async function () { + if (getActivePackageManager() !== 'pnpm') { + return; + } + + let restoreRegistry: (() => Promise) | undefined; + + try { + // Setup project from older asset using the public registry + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // Create pnpm-workspace.yaml inside the project directory + await writeFile('pnpm-workspace.yaml', "packages:\n - '.'\n"); + + // Run ng update on @angular/cli to trigger the update from version 20 to the next major version + await ng('update', '@angular/cli@21', '@angular/core@21'); + + // Verify that the pnpm lockfile does not contain references to the temporary package directory + const lockfileContent = await readFile('pnpm-lock.yaml'); + if (lockfileContent.includes('angular-cli-tmp-packages-')) { + throw new Error( + 'pnpm-lock.yaml contains reference to temporary package directory, isolation failed!', + ); + } + } finally { + await restoreRegistry?.(); + } +} diff --git a/tests/e2e/tests/update/update-secure-registry.ts b/tests/e2e/tests/update/update-secure-registry.ts new file mode 100644 index 000000000000..47a9db657cd8 --- /dev/null +++ b/tests/e2e/tests/update/update-secure-registry.ts @@ -0,0 +1,39 @@ +import { exec, ng } from '../../utils/process'; +import { createNpmConfigForAuthentication } from '../../utils/registry'; +import { expectToFail } from '../../utils/utils'; +import { isPrereleaseCli } from '../../utils/project'; +import { getActivePackageManager } from '../../utils/packages'; +import assert from 'node:assert'; + +export default async function () { + // The environment variable has priority over the .npmrc + delete process.env['NPM_CONFIG_REGISTRY']; + const worksMessage = 'We analyzed your package.json'; + + const extraArgs: string[] = []; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + + // Valid authentication token + + await createNpmConfigForAuthentication(); + const { stdout: stdout2 } = await ng('update', ...extraArgs); + if (!stdout2.includes(worksMessage)) { + throw new Error(`Expected stdout to contain "${worksMessage}"`); + } + + // Invalid authentication token + + await createNpmConfigForAuthentication(true, true); + await expectToFail(() => ng('update', ...extraArgs)); + + if (getActivePackageManager() === 'yarn') { + // When running `ng update` using yarn (`yarn ng update`), yarn will set the `npm_config_registry` env variable to `https://registry.yarnpkg.com` + // Validate the registry in the RC is used. + await createNpmConfigForAuthentication(true, true); + + const error = await expectToFail(() => exec('yarn', 'ng', 'update', ...extraArgs)); + assert.match(error.message, /not allowed to access package|status code 403/); + } +} diff --git a/tests/e2e/tests/update/update.ts b/tests/e2e/tests/update/update.ts new file mode 100644 index 000000000000..fd8663503ecc --- /dev/null +++ b/tests/e2e/tests/update/update.ts @@ -0,0 +1,94 @@ +import { appendFile } from 'node:fs/promises'; +import { createProjectFromAsset } from '../../utils/assets'; +import { expectFileMatchToExist } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng, noSilentNg } from '../../utils/process'; +import { isPrereleaseCli, useCIChrome, useCIDefaults, getNgCLIVersion } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + let restoreRegistry: (() => Promise) | undefined; + + try { + // We need to use the public registry because in the local NPM server we don't have + // older versions @angular/cli packages which would cause `npm install` during `ng update` to fail. + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // CLI project version + const cliMajorProjectVersion = 20; + + // If using npm, enable legacy peer deps mode to avoid defects in npm 7+'s peer dependency resolution + // Example error where 11.2.14 satisfies the SemVer range ^11.0.0 but still fails: + // npm ERR! Conflicting peer dependency: @angular/compiler-cli@11.2.14 + // npm ERR! node_modules/@angular/compiler-cli + // npm ERR! peer @angular/compiler-cli@"^11.0.0 || ^11.2.0-next" from @angular-devkit/build-angular@0.1102.19 + // npm ERR! node_modules/@angular-devkit/build-angular + // npm ERR! dev @angular-devkit/build-angular@"~0.1102.19" from the root project + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + // CLI current version. + const cliMajorVersion = getNgCLIVersion().major; + + for (let version = cliMajorProjectVersion + 1; version < cliMajorVersion; version++) { + // Run all the migrations until the current build major version - 1. + // Example: when the project is using CLI version 10 and the build CLI version is 14. + // We will run the following migrations: + // - 10 -> 11 + // - 11 -> 12 + // - 12 -> 13 + const { stdout } = await ng('update', `@angular/cli@${version}`, `@angular/core@${version}`); + if ( + !stdout.includes("Executing migrations of package '@angular/cli'") && + !stdout.includes("Optional migrations of package '@angular/cli'") + ) { + throw new Error('Update did not execute migrations for @angular/cli. OUTPUT: \n' + stdout); + } + if (!stdout.includes("Executing migrations of package '@angular/core'")) { + throw new Error('Update did not execute migrations for @angular/core. OUTPUT: \n' + stdout); + } + } + } finally { + await restoreRegistry?.(); + } + + // Update Angular current build + const extraUpdateArgs = isPrereleaseCli() ? ['--next', '--force'] : []; + + // For the latest/next release we purposely don't run `ng update @angular/core`. + + // During a major release when the branch version is bumped from `12.0.0-rc.x` to `12.0.0` there would be a period were in + // the local NPM registry `@angular/cli@latest` will point to `12.0.0`, but on the public NPM repository `@angular/core@latest` will be `11.2.x`. + + // This causes `ng update @angular/core` to fail because of mismatching peer dependencies. + + // The reason for this is because of our bumping and release strategy. When we release a major version on NPM we don't tag it + // `@latest` right away, but we wait for all teams to release their packages before doing so. While this is good because all team + // packages gets tagged with `@latest` at the same time. This is problematic for our CI, since we test against the public NPM repo and are dependent on tags. + + // NB: `ng update @angular/cli` will still cause `@angular/core` packages to be updated therefore we still test updating the core package without running the command. + + await ng('update', '@angular/cli', ...extraUpdateArgs); + + // Setup testing to use CI Chrome. + await useCIChrome('twenty-project', './'); + await useCIDefaults('twenty-project'); + + // Run CLI commands. + await ng('generate', 'component', 'my-comp'); + await ng('test', '--watch=false'); + + await executeBrowserTest({ + configuration: 'production', + expectedTitleText: 'Hello, twenty-project', + }); + await executeBrowserTest({ + configuration: 'development', + expectedTitleText: 'Hello, twenty-project', + }); + + // Verify project now creates bundles + await noSilentNg('build', '--configuration=production'); + await expectFileMatchToExist('dist/twenty-project/browser', /main-[a-zA-Z0-9]{8}\.js/); +} diff --git a/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts b/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts new file mode 100644 index 000000000000..07be3f2a911b --- /dev/null +++ b/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import { findFreePort } from '../../utils/network'; +import { execAndWaitForOutputToMatch, killAllProcesses, ng } from '../../utils/process'; + +export default async function () { + await ng('cache', 'clean'); + await ng('cache', 'on'); + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', `${port}`], + /dependencies optimized/, + // Use CI:0 to force caching + { ...process.env, DEBUG: 'vite:deps', CI: '0', NO_COLOR: 'true' }, + ); + + const response = await fetch(`http://localhost:${port}/main.js`); + + assert(response.ok, `Expected 'response.ok' to be 'true'.`); + + // Terminate the dev-server + await killAllProcesses(); + + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port=0'], + /Hash is consistent\. Skipping/, + // Use CI:0 to force caching + { ...process.env, DEBUG: 'vite:deps', CI: '0', NO_COLOR: 'true' }, + ); +} diff --git a/tests/e2e/tests/vite/ssr-base-href.ts b/tests/e2e/tests/vite/ssr-base-href.ts new file mode 100644 index 000000000000..140f2582689a --- /dev/null +++ b/tests/e2e/tests/vite/ssr-base-href.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { replaceInFile } from '../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.options['baseHref'] = '/base'; + }); + + await replaceInFile( + 'src/server.ts', + /express\(\);/, + `express(); + + app.use('/ping', (req, res) => { + return res.json({ pong: true }); + });`, + ); + + const port = await ngServe(); + + // Angular application and bundled should be affected by baseHref + await matchResponse(`http://localhost:${port}/base`, /ng-server-context/); + await matchResponse(`http://localhost:${port}/base/main.js`, /App/); + + // Server endpoint should not be affected by baseHref + await matchResponse(`http://localhost:${port}/ping`, /pong/); +} + +async function matchResponse(url: string, match: RegExp): Promise { + const response = await fetch(url); + const text = await response.text(); + + assert.match(text, match); +} diff --git a/tests/e2e/tests/vite/ssr-default.ts b/tests/e2e/tests/vite/ssr-default.ts new file mode 100644 index 000000000000..8b64a4b30f67 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-default.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Enable caching to test real development workflow. + await ng('cache', 'clean'); + await ng('cache', 'on'); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/', /Hello,/); + await validateResponse('/unknown', /Cannot GET/, 404); + + async function validateResponse(pathname: string, match: RegExp, status = 200): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, status); + } +} diff --git a/tests/e2e/tests/vite/ssr-entry-express.ts b/tests/e2e/tests/vite/ssr-entry-express.ts new file mode 100644 index 000000000000..11cf671becbf --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-express.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { join } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const browserDistFolder = join(import.meta.dirname, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.use('/api/{*splat}', (req, res) => { + res.json({ hello: 'foo' }) + }); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.use(async(req, res, next) => { + const response = await angularNodeAppEngine.handle(req); + if (response) { + writeResponseToNodeResponse(response, res); + } else { + next(); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, (error) => { + if (error) { + throw error; + } + + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-fastify.ts b/tests/e2e/tests/vite/ssr-entry-fastify.ts new file mode 100644 index 000000000000..c50e8c2200f6 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-fastify.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('fastify@5'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import fastify from 'fastify'; + + export function app() { + const server = fastify(); + const angularNodeAppEngine = new AngularNodeAppEngine(); + server.get('/api/*', (req, reply) => reply.send({ hello: 'foo' })); + server.get('*', async (req, reply) => { + try { + const response = await angularNodeAppEngine.handle(req.raw); + if (response) { + await writeResponseToNodeResponse(response, reply.raw); + } else { + reply.callNotFound(); + } + } catch (error) { + reply.send(error); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = +(process.env['PORT'] || 4000); + server.listen({ port }, () => { + console.log(\`Fastify server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(async (req, res) => { + await server.ready(); + server.server.emit('request', req, res); + }); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-h3.ts b/tests/e2e/tests/vite/ssr-entry-h3.ts new file mode 100644 index 000000000000..4a4f91066000 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-h3.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export function app() { + const server = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/api/**', + defineEventHandler(() => ({ hello: 'foo' })), + ); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.handle(toWebRequest(event))), + ); + + server.use(router); + + return server; + } + + const server = app(); + + export const reqHandler = createRequestHandler(toWebHandler(server)); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-hono.ts b/tests/e2e/tests/vite/ssr-entry-hono.ts new file mode 100644 index 000000000000..0b0b8220fe6b --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-hono.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('hono@4'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { Hono } from 'hono'; + + export function app() { + const server = new Hono(); + const angularAppEngine = new AngularAppEngine(); + + server.get('/api/*', (c) => c.json({ hello: 'foo' })); + server.get('/*', async (c) => { + const res = await angularAppEngine.handle(c.req.raw); + return res || undefined + }); + + return server; + } + + const server = app(); + export const reqHandler = createRequestHandler(server.fetch); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-error-stack.ts b/tests/e2e/tests/vite/ssr-error-stack.ts new file mode 100644 index 000000000000..8fce78b9e7ad --- /dev/null +++ b/tests/e2e/tests/vite/ssr-error-stack.ts @@ -0,0 +1,34 @@ +import { doesNotMatch, match } from 'node:assert'; +import { ng } from '../../utils/process'; +import { appendToFile } from '../../utils/fs'; +import { ngServe, useSha } from '../../utils/project'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; + +export default async function () { + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Create Error. + await appendToFile( + 'src/app/app.ts', + ` + (() => { + throw new Error('something happened!'); + })(); + `, + ); + + const port = await ngServe(); + const response = await fetch(`http://localhost:${port}/`); + const text = await response.text(); + + // The error is also sent in the browser, so we don't need to scrap the stderr. + match( + text, + /something happened.+at eval \(.+[\\/]+e2e-test[\\/]+test-project[\\/]+src[\\/]+app[\\/]+app\.ts:\d+:\d+\)/, + ); + doesNotMatch(text, /vite-root/); +} diff --git a/tests/e2e/tests/vite/ssr-new-dep-optimization.ts b/tests/e2e/tests/vite/ssr-new-dep-optimization.ts new file mode 100644 index 000000000000..291e8df20170 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-new-dep-optimization.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import { + execAndWaitForOutputToMatch, + ng, + waitForAnyProcessOutputToMatch, +} from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Enable caching to test real development workflow. + await ng('cache', 'clean'); + await ng('cache', 'on'); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', port.toString()], + /Application bundle generation complete/, + { ...process.env, CI: '0', NO_COLOR: 'true' }, + ); + await validateResponse('/', /Hello,/); + + await Promise.all([ + waitForAnyProcessOutputToMatch( + /optimized: @angular\/platform-browser\/animations\/async/, + 6000, + ), + writeFile( + 'src/app/app.config.ts', + ` + import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + ${(await readFile('src/app/app.config.ts')).replace('provideRouter(routes),', 'provideAnimationsAsync(), provideRouter(routes),')} + `, + ), + ]); + + // Verify the app still works. + await validateResponse('/', /Hello,/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + } +} diff --git a/tests/e2e/tests/vite/ssr-with-ssl.ts b/tests/e2e/tests/vite/ssr-with-ssl.ts new file mode 100644 index 000000000000..e07d01b06fcc --- /dev/null +++ b/tests/e2e/tests/vite/ssr-with-ssl.ts @@ -0,0 +1,71 @@ +import { Agent, fetch } from 'undici'; +import assert from 'node:assert'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe('--ssl'); + + // http 2 + await validateResponse('/main.js', /bootstrapApplication/, true); + await validateResponse('/home', /home works/, true); + + // http 1.1 + await validateResponse('/main.js', /bootstrapApplication/, false); + await validateResponse('/home', /home works/, false); + + async function validateResponse( + pathname: string, + match: RegExp, + allowH2: boolean, + ): Promise { + const response = await fetch(new URL(pathname, `https://localhost:${port}`), { + dispatcher: new Agent({ + connect: { + allowH2, + rejectUnauthorized: false, + }, + }), + }); + + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} diff --git a/tests/e2e/tests/vitest/browser-coverage-istanbul.ts b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts new file mode 100644 index 000000000000..66f73162d586 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts @@ -0,0 +1,35 @@ +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; +import { expectFileToExist, readFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + // Install ONLY Istanbul coverage package. + // This will trigger the auto-detection logic to use Istanbul even for Node tests. + await applyVitestBuilder({ + coverageIstanbul: true, + }); + + // Use the 'json' reporter to get a machine-readable output for assertions. + await updateJsonFile('angular.json', (json) => { + const project = Object.values(json['projects'])[0] as any; + const test = project['architect']['test']; + test.options = { + coverageReporters: ['json', 'text'], + }; + }); + + // Run tests with coverage (defaults to Node/jsdom environment) + const { stdout } = await ng('test', '--no-watch', '--coverage'); + + // Verify that tests passed + assert.match(stdout, /1 passed/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const coverageJsonPath = 'coverage/test-project/coverage-final.json'; + await expectFileToExist(coverageJsonPath); + + const coverageSummary = JSON.parse(await readFile(coverageJsonPath)); + assert.ok(Object.keys(coverageSummary).length > 0, 'Expected coverage report to not be empty.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts b/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts new file mode 100644 index 000000000000..476c15c41d38 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { expectFileToExist, readFile } from '../../utils/fs'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + coverageV8: true, + }); + + // Run tests with coverage in browser mode. + // We use the default passing tests generated for the project. + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromiumHeadless', + '--coverage', + '--coverage-reporters=json-summary', + ); + + // Verify that tests passed + assert.match(stdout, /pass/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const summaryPath = 'coverage/test-project/coverage-summary.json'; + await expectFileToExist(summaryPath); + + const summary = JSON.parse(await readFile(summaryPath)); + + // Find the key for app.ts (it might be an absolute path) + const appFileKey = Object.keys(summary).find((key) => key.endsWith('app.ts')); + assert.ok(appFileKey, 'Expected coverage summary to contain app.ts.'); + + const appCoverage = summary[appFileKey]; + assert.ok(appCoverage.lines.pct > 0, 'Expected lines percentage to be greater than 0.'); + + // Also verify that spec files are NOT present in the summary + const specFileKey = Object.keys(summary).find((key) => key.endsWith('.spec.ts')); + assert.ok(!specFileKey, 'Expected coverage report to not contain .spec.ts files.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-validation.ts b/tests/e2e/tests/vitest/browser-coverage-validation.ts new file mode 100644 index 000000000000..66342746a91a --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-validation.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { execAndCaptureError } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; +import { unlink } from 'node:fs/promises'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + // === Case 1: Browser configured via CLI option === + const error1 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + '--browsers', + 'firefox', + ]); + const output1 = stripVTControlCharacters(error1.message); + assert.match( + output1, + /Code coverage requires either "@vitest\/coverage-v8" or "@vitest\/coverage-istanbul" to be installed./, + 'Expected validation error for missing coverage packages.', + ); + + await applyVitestBuilder({ + playwright: true, + coverageV8: true, + }); + + const configPath = 'vitest.config.mts'; + const absoluteConfigPath = path.resolve(configPath); + + try { + // === Case 2: Browser configured via vitest.config.mts (name) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + coverage: { provider: 'v8' }, + browser: { + enabled: true, + name: 'firefox', + provider: playwright(), + }, + }, + }); + `, + ); + + const error2 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output2 = stripVTControlCharacters(error2.message); + assert.match( + output2, + /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, + 'Expected validation error for unsupported browser with coverage (config name).', + ); + assert.match( + output2, + /Please use the "istanbul" coverage provider/, + 'Expected the error to suggest the istanbul coverage provider.', + ); + + // === Case 3: Browser configured via vitest.config.mts (instances) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + coverage: { provider: 'v8' }, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'firefox' }], + } as any, + }, + }); + `, + ); + + const error3 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output3 = stripVTControlCharacters(error3.message); + assert.match( + output3, + /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, + 'Expected validation error for unsupported browser with coverage (config instances).', + ); + } finally { + // Clean up the config file so it doesn't affect other tests + try { + await unlink(configPath); + } catch {} + } +} diff --git a/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts b/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts new file mode 100644 index 000000000000..7865830f3b82 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + skipInstall: true, + }); + + await updateJsonFile('package.json', (json) => { + json.devDependencies = { + ...json.devDependencies, + '@vitest/browser-playwright': '^4', + 'vitest': '^4', + 'playwright': '^1', + }; + }); + + await installWorkspacePackages(); + + await ng('generate', 'component', 'my-comp'); + + // Create vitest-base.config.mts + await writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright({launchOptions: {executablePath: process.env['CHROME_BIN']}}), + instances: [ + { browser: 'chromium' }, + ], + headless: true, + }, + }, + }); + `, + ); + + // Create a spec file that asserts browser environment + await writeFile( + 'src/browser.spec.ts', + ` + describe('Browser Environment Check', () => { + it('should be running in Chromium', () => { + const ua = navigator.userAgent; + // Fail the test if it's not Chrome/Chromium + if (!ua.includes('Chrome') && !ua.includes('Chromium')) { + throw new Error('Expected to be running in Chrome/Chromium, but User Agent is: ' + ua); + } + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--runner-config'); + + assert.match(stdout, /3 passed/, 'Expected 3 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-custom-config.ts b/tests/e2e/tests/vitest/browser-custom-config.ts new file mode 100644 index 000000000000..ab9092d038af --- /dev/null +++ b/tests/e2e/tests/vitest/browser-custom-config.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await ng('generate', 'component', 'my-comp'); + + // Create vitest-base.config.mts + await writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright({launchOptions: {executablePath: process.env['CHROME_BIN']}}), + instances: [ + { browser: 'chromium' }, + ], + headless: true, + }, + }, + }); + `, + ); + + // Create a spec file that asserts browser environment + await writeFile( + 'src/browser.spec.ts', + ` + describe('Browser Environment Check', () => { + it('should be running in Chromium', () => { + const ua = navigator.userAgent; + // Fail the test if it's not Chrome/Chromium + if (!ua.includes('Chrome') && !ua.includes('Chromium')) { + throw new Error('Expected to be running in Chrome/Chromium, but User Agent is: ' + ua); + } + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--runner-config'); + + assert.match(stdout, /3 passed/, 'Expected 3 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-no-globals.ts b/tests/e2e/tests/vitest/browser-no-globals.ts new file mode 100644 index 000000000000..3a4fcde4b96c --- /dev/null +++ b/tests/e2e/tests/vitest/browser-no-globals.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +/** + * Allow `vitest` import in browser mode. + * @see https://github.com/angular/angular-cli/issues/31745 + */ +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await writeFile( + 'src/app/app.spec.ts', + ` + import { test, expect } from 'vitest'; + + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { stdout } = await ng('test', '--browsers', 'ChromiumHeadless'); + + assert.match(stdout, /1 passed/, 'Expected 1 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-playwright.ts b/tests/e2e/tests/vitest/browser-playwright.ts new file mode 100644 index 000000000000..54d744e10208 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-playwright.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + await ng('generate', 'component', 'my-comp'); + + await writeFile( + 'src/setup1.ts', + ` + import { getTestBed } from '@angular/core/testing'; + + getTestBed().configureTestingModule({}); + `, + ); + + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromiumHeadless', + '--setup-files', + 'src/setup1.ts', + ); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-sourcemaps.ts b/tests/e2e/tests/vitest/browser-sourcemaps.ts new file mode 100644 index 000000000000..4bc025a7d2b9 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-sourcemaps.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch', '--browsers', 'chromiumHeadless'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +} diff --git a/tests/e2e/tests/vitest/browser-webdriverio.ts b/tests/e2e/tests/vitest/browser-webdriverio.ts new file mode 100644 index 000000000000..231f5fca4041 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-webdriverio.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + webdriver: true, + }); + + await ng('generate', 'component', 'my-comp'); + + await writeFile( + 'src/setup1.ts', + ` + import { getTestBed } from '@angular/core/testing'; + + getTestBed().configureTestingModule({}); + `, + ); + + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromeHeadless', + '--setup-files', + 'src/setup1.ts', + ); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/component.ts b/tests/e2e/tests/vitest/component.ts new file mode 100644 index 000000000000..421587892196 --- /dev/null +++ b/tests/e2e/tests/vitest/component.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; + +export default async function (): Promise { + await applyVitestBuilder(); + await ng('generate', 'component', 'my-comp'); + + const { stdout } = await ng('test'); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/include.ts b/tests/e2e/tests/vitest/include.ts new file mode 100644 index 000000000000..4585194ef3c2 --- /dev/null +++ b/tests/e2e/tests/vitest/include.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import path from 'node:path'; + +export default async function (): Promise { + await applyVitestBuilder(); + + const { stdout: stdout1 } = await ng('test', '--include', path.resolve('src/app/app.spec.ts')); + assert.match(stdout1, /1 passed/, 'Expected 1 test to pass with absolute include.'); + + const { stdout: stdout2 } = await ng('test', '--include', path.normalize('src/app/app.spec.ts')); + assert.match(stdout2, /1 passed/, 'Expected 1 test to pass with relative include.'); +} diff --git a/tests/e2e/tests/vitest/larger-project-coverage.ts b/tests/e2e/tests/vitest/larger-project-coverage.ts new file mode 100644 index 000000000000..d5fa94c0b82c --- /dev/null +++ b/tests/e2e/tests/vitest/larger-project-coverage.ts @@ -0,0 +1,108 @@ +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; +import { updateJsonFile } from '../../utils/project'; +import { readFile } from '../../utils/fs'; + +export default async function () { + await applyVitestBuilder({ + coverageV8: true, + playwright: true, + }); + + // Add coverage and threshold configuration to ensure coverage is calculated. + // Use the 'json' reporter to get a machine-readable output for assertions. + await updateJsonFile('angular.json', (json) => { + const project = Object.values(json['projects'])[0] as any; + const test = project['architect']['test']; + test.options = { + coverageReporters: ['json', 'text'], + coverageThresholds: { + // The generated component/service/pipe files are basic + // A threshold of 75 should be safe. + statements: 75, + }, + }; + }); + + const artifactCount = 100; + const initialTestCount = 1; + const generatedFiles = await generateArtifactsInBatches(artifactCount); + + const totalTests = initialTestCount + artifactCount; + const expectedMessage = new RegExp(`${totalTests} passed`); + const coverageJsonPath = 'coverage/test-project/coverage-final.json'; + + // Run tests in default (JSDOM) mode with coverage + const { stdout: jsdomStdout } = await ng('test', '--no-watch', '--coverage'); + assert.match(jsdomStdout, expectedMessage, `Expected ${totalTests} tests to pass in JSDOM mode.`); + + // Assert that every generated file is in the coverage report by reading the JSON output. + const jsdomSummary = JSON.parse(await readFile(coverageJsonPath)); + const jsdomSummaryKeys = Object.keys(jsdomSummary); + for (const file of generatedFiles) { + const found = jsdomSummaryKeys.some((key) => key.endsWith(file)); + assert.ok(found, `Expected ${file} to be in the JSDOM coverage report.`); + } + + // Run tests in browser mode with coverage + const { stdout: browserStdout } = await ng( + 'test', + '--no-watch', + '--coverage', + '--browsers', + 'ChromiumHeadless', + ); + assert.match( + browserStdout, + expectedMessage, + `Expected ${totalTests} tests to pass in browser mode.`, + ); + + // Assert that every generated file is in the coverage report for browser mode. + const browserSummary = JSON.parse(await readFile(coverageJsonPath)); + const browserSummaryKeys = Object.keys(browserSummary); + for (const file of generatedFiles) { + const found = browserSummaryKeys.some((key) => key.endsWith(file)); + assert.ok(found, `Expected ${file} to be in the browser coverage report.`); + } +} + +async function generateArtifactsInBatches(artifactCount: number): Promise { + const BATCH_SIZE = 5; + const generatedFiles: string[] = []; + let commands: Promise[] = []; + + for (let i = 0; i < artifactCount; i++) { + const type = i % 3; + const name = `test-artifact-${i}`; + + let generateType: string; + let fileSuffix: string; + + switch (type) { + case 0: + generateType = 'component'; + fileSuffix = '.ts'; + break; + case 1: + generateType = 'service'; + fileSuffix = '.ts'; + break; + default: + generateType = 'pipe'; + fileSuffix = '-pipe.ts'; + break; + } + + commands.push(ng('generate', generateType, name, '--skip-tests=false')); + generatedFiles.push(`${name}${fileSuffix}`); + + if (commands.length === BATCH_SIZE || i === artifactCount - 1) { + await Promise.all(commands); + commands = []; + } + } + + return generatedFiles; +} diff --git a/tests/e2e/tests/vitest/larger-project.ts b/tests/e2e/tests/vitest/larger-project.ts new file mode 100644 index 000000000000..9b9db25bb067 --- /dev/null +++ b/tests/e2e/tests/vitest/larger-project.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +export default async function () { + await applyVitestBuilder({ + playwright: true, + }); + + const artifactCount = 500; + // A new project starts with 1 test file (app.spec.ts) + // Each generated artifact will add one more test file. + const initialTestCount = 1; + + await generateArtifactsInBatches(artifactCount); + + const totalTests = initialTestCount + artifactCount; + const expectedMessage = new RegExp(`${totalTests} passed`); + + // Run tests in default (JSDOM) mode + const { stdout: jsdomStdout } = await ng('test', '--no-watch'); + assert.match(jsdomStdout, expectedMessage, `Expected ${totalTests} tests to pass in JSDOM mode.`); + + // Run tests in browser mode + const { stdout: browserStdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'ChromiumHeadless', + ); + assert.match( + browserStdout, + expectedMessage, + `Expected ${totalTests} tests to pass in browser mode.`, + ); +} + +async function generateArtifactsInBatches(artifactCount: number): Promise { + const files: { [path: string]: string } = {}; + + for (let i = 0; i < artifactCount; i++) { + const type = i % 3; + const name = `test-artifact-${i}`; + + switch (type) { + case 0: + files[`src/app/${name}/${name}.ts`] = ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-${name}', + template: '', +}) +export class TestArtifact${i}Component {} +`; + files[`src/app/${name}/${name}.spec.ts`] = ` +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestArtifact${i}Component } from './${name}'; + +describe('TestArtifact${i}Component', () => { + let component: TestArtifact${i}Component; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestArtifact${i}Component], + }).compileComponents(); + + fixture = TestBed.createComponent(TestArtifact${i}Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); +`; + break; + case 1: + files[`src/app/${name}.ts`] = ` +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class TestArtifact${i}Service {} +`; + files[`src/app/${name}.spec.ts`] = ` +import { TestBed } from '@angular/core/testing'; +import { TestArtifact${i}Service } from './${name}'; + +describe('TestArtifact${i}Service', () => { + let service: TestArtifact${i}Service; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TestArtifact${i}Service); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); +`; + break; + default: + files[`src/app/${name}-pipe.ts`] = ` +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'testArtifact${i}', +}) +export class TestArtifact${i}Pipe implements PipeTransform { + transform(value: unknown): unknown { + return null; + } +} +`; + files[`src/app/${name}-pipe.spec.ts`] = ` +import { TestArtifact${i}Pipe } from './${name}-pipe'; + +describe('TestArtifact${i}Pipe', () => { + it('create an instance', () => { + const pipe = new TestArtifact${i}Pipe(); + expect(pipe).toBeTruthy(); + }); +}); +`; + break; + } + } + + const entries = Object.entries(files); + const CONCURRENCY_LIMIT = 100; + for (let i = 0; i < entries.length; i += CONCURRENCY_LIMIT) { + const chunk = entries.slice(i, i + CONCURRENCY_LIMIT); + await Promise.all( + chunk.map(async ([filePath, content]) => { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content.trim()); + }), + ); + } +} diff --git a/tests/e2e/tests/vitest/library.ts b/tests/e2e/tests/vitest/library.ts new file mode 100644 index 000000000000..0bb989057279 --- /dev/null +++ b/tests/e2e/tests/vitest/library.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { updateJsonFile } from '../../utils/project'; +import { ng } from '../../utils/process'; +import { appendToFile, createDir, writeFile } from '../../utils/fs'; + +export default async function (): Promise { + // Generate a library + await ng('generate', 'library', 'my-lib', '--test-runner', 'vitest'); + + // Setup Style Include Paths test + // 1. Create a shared SCSS file + await createDir('projects/my-lib/src/styles'); + await writeFile('projects/my-lib/src/styles/_vars.scss', '$primary-color: red;'); + + // 2. Update ng-package.json to include the styles directory + await updateJsonFile('projects/my-lib/ng-package.json', (json) => { + json['lib'] = { + ...json['lib'], + styleIncludePaths: ['./src/styles'], + }; + }); + + // 3. Update the component to use SCSS and import the shared file + // Rename CSS to SCSS + await ng('generate', 'component', 'styled-comp', '--project=my-lib', '--style=scss'); + + await appendToFile( + 'projects/my-lib/src/lib/styled-comp/styled-comp.scss', + ` + @use 'vars'; + p { color: vars.$primary-color; } + `, + ); + + // Run the library tests + const { stdout } = await ng('test', 'my-lib'); + + // Expect tests to pass + assert.match(stdout, /passed/, 'Expected library tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/node-sourcemaps.ts b/tests/e2e/tests/vitest/node-sourcemaps.ts new file mode 100644 index 000000000000..8e317b2b72cf --- /dev/null +++ b/tests/e2e/tests/vitest/node-sourcemaps.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder(); + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +} diff --git a/tests/e2e/tests/vitest/runner-config-path.ts b/tests/e2e/tests/vitest/runner-config-path.ts new file mode 100644 index 000000000000..456469b2d6a4 --- /dev/null +++ b/tests/e2e/tests/vitest/runner-config-path.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +export default async function (): Promise { + await applyVitestBuilder(); + + // Create a custom Vitest configuration file. + const customConfigPath = 'vitest.custom.mjs'; + await writeMultipleFiles({ + [customConfigPath]: ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + // A unique option to confirm this file is being used. + passWithNoTests: true, + }, + }); + `, + }); + + const absoluteConfigPath = path.resolve(customConfigPath); + const { stdout } = await ng('test', `--runner-config=${absoluteConfigPath}`); + + // Assert that the CLI logs the use of the specified configuration file. + assert.match( + stdout, + /vitest\.custom\.mjs/, + 'Expected a message confirming the use of the custom config file.', + ); +} diff --git a/tests/e2e/tests/vitest/snapshot.ts b/tests/e2e/tests/vitest/snapshot.ts new file mode 100644 index 000000000000..f099ba6f8d30 --- /dev/null +++ b/tests/e2e/tests/vitest/snapshot.ts @@ -0,0 +1,79 @@ +import { ng } from '../../utils/process'; +import { replaceInFile, readFile, writeFile } from '../../utils/fs'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert/strict'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function () { + // Set up the test project to use the vitest runner + await applyVitestBuilder(); + + // Add snapshot assertions to the test file + await replaceInFile( + 'src/app/app.spec.ts', + `describe('App', () => {`, + ` +describe('App', () => { + it('should match file snapshot', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect((app as any).title()).toMatchSnapshot(); + }); + + it('should match inline snapshot', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect((app as any).title()).toMatchInlineSnapshot(); + }); +`, + ); + + // Synchronize line endings for Vitest which currently may miscalculate line counts + // with mixed file line endings. + let content = await readFile('src/app/app.spec.ts'); + content = content.replace(/\r\n/g, '\n'); + content = content.replace(/\r/g, '\n'); + await writeFile('src/app/app.spec.ts', content); + + // First run: create snapshots + const { stdout: firstRunStdout } = await ng('test'); + assert.match( + stripVTControlCharacters(firstRunStdout), + /Snapshots\s+2 written/, + 'Snapshots were not written on the first run.', + ); + + const specContent = await readFile('src/app/app.spec.ts'); + assert.match( + specContent, + /toMatchInlineSnapshot\(`"test-project"`\)/, + 'Inline snapshot was not written to the spec file.', + ); + + const snapshotContent = await readFile('src/app/__snapshots__/app.spec.ts.snap'); + assert.match( + snapshotContent, + /exports\[`App > should match file snapshot 1`\] = `"test-project"`;/, + 'File snapshot was not written to disk.', + ); + + // Second run: tests should pass with existing snapshots + await ng('test'); + + // Modify component to break snapshots + await replaceInFile('src/app/app.ts', 'test-project', 'Snapshot is broken!'); + + // Third run: tests should fail with snapshot mismatch + await assert.rejects( + () => ng('test'), + (err: any) => { + assert.match( + stripVTControlCharacters(err.toString()), + /Snapshots\s+2 failed/, + 'Expected snapshot mismatch error, but a different error occurred.', + ); + return true; + }, + 'Snapshot mismatch did not cause the test to fail.', + ); +} diff --git a/tests/e2e/tests/vitest/tslib-resolution.ts b/tests/e2e/tests/vitest/tslib-resolution.ts new file mode 100644 index 000000000000..091f5105ba7f --- /dev/null +++ b/tests/e2e/tests/vitest/tslib-resolution.ts @@ -0,0 +1,70 @@ +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; + +export default async function () { + await applyVitestBuilder({ + playwright: true, + }); + + // Add a custom decorator to trigger tslib usage + await writeFile( + 'src/app/custom-decorator.ts', + ` + export function MyDecorator() { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + // do nothing + }; + } + `, + ); + + // Add a service that uses the decorator + await writeFile( + 'src/app/test.service.ts', + ` + import { Injectable } from '@angular/core'; + import { MyDecorator } from './custom-decorator'; + + @Injectable({ + providedIn: 'root' + }) + export class TestService { + @MyDecorator() + myMethod() { + return true; + } + } + `, + ); + + // Add a test for the service + await writeFile( + 'src/app/test.service.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { TestService } from './test.service'; + + describe('TestService', () => { + let service: TestService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TestService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('myMethod should return true', () => { + expect(service.myMethod()).toBe(true); + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--browsers', 'chromiumHeadless'); + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/windows-subst-drive.ts b/tests/e2e/tests/vitest/windows-subst-drive.ts new file mode 100644 index 000000000000..3106ff846b21 --- /dev/null +++ b/tests/e2e/tests/vitest/windows-subst-drive.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ng, silentExec } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + // This test uses `subst` to map the project directory to a virtual drive letter + // to simulate running tests from a non-C drive on Windows. + if (process.platform !== 'win32') { + return; + } + + await applyVitestBuilder(); + + const originalCwd = process.cwd(); + const driveLetter = 'X:'; // Pick a drive letter that is unlikely to be in use. + + try { + // 1. Map the parent directory of the project to the virtual drive. + // This avoids running the project from the root of the drive (X:\), which can cause + // issues with workspace detection. + const projectParentDir = path.dirname(originalCwd); + const projectName = path.basename(originalCwd); + + await silentExec('subst', driveLetter, projectParentDir); + + // 2. Change the current process's working directory to the project folder on the virtual drive. + const newCwd = path.join(driveLetter + '\\', projectName); + process.chdir(newCwd); + + // Verify that the file system mapping is working as expected. + assert(fs.existsSync('angular.json'), 'angular.json should exist on the subst drive'); + + // 3. Run `ng test`. + const { stdout } = await ng('test', '--watch=false'); + const output = stripVTControlCharacters(stdout); + + // 4. Verify that Vitest found the test file and identified the tests within it. + assert.match( + output, + /src\/app\/app\.spec\.ts \(2 tests/, + 'Expected tests to be discovered and loaded, even if execution fails due to subst aliasing.', + ); + } finally { + // 5. Teardown: Restore CWD and remove the virtual drive mapping. + try { + process.chdir(originalCwd); + } catch (e) { + console.error('Failed to restore CWD:', e); + } + + try { + await silentExec('subst', driveLetter, '/d'); + } catch (e) { + // Ignore errors if the drive wasn't mounted or if unmount fails (best effort) + console.error(`Failed to unmount ${driveLetter}:`, e); + } + } +} diff --git a/tests/e2e/utils/BUILD.bazel b/tests/e2e/utils/BUILD.bazel new file mode 100644 index 000000000000..dbbda05702cd --- /dev/null +++ b/tests/e2e/utils/BUILD.bazel @@ -0,0 +1,24 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "utils", + testonly = True, + srcs = glob(["**/*.ts"]), + data = [ + "//tests/e2e/ng-snapshot", + ], + deps = [ + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + "//:node_modules/@types/semver", + "//:node_modules/fast-glob", + "//:node_modules/puppeteer", + "//:node_modules/semver", + "//:node_modules/verdaccio", + "//:node_modules/verdaccio-auth-memory", + "//tests:node_modules/@types/tar-stream", + "//tests:node_modules/tar-stream", + ], +) diff --git a/tests/e2e/utils/assets.ts b/tests/e2e/utils/assets.ts index d1a8e0a98ce2..153fdaccc6de 100644 --- a/tests/e2e/utils/assets.ts +++ b/tests/e2e/utils/assets.ts @@ -1,42 +1,69 @@ -import {join} from 'path'; -import * as glob from 'glob'; -import {getGlobalVariable} from './env'; -import {relative} from 'path'; -import {copyFile} from './fs'; -import {useBuiltPackages} from './project'; -import {silentNpm} from './process'; - +import { join } from 'node:path'; +import { chmod } from 'node:fs/promises'; +import glob from 'fast-glob'; +import { getGlobalVariable } from './env'; +import { resolve } from 'node:path'; +import { copyFile } from './fs'; +import { installWorkspacePackages, setRegistry } from './packages'; +import { getTestProjectDir, useBuiltPackagesVersions } from './project'; export function assetDir(assetName: string) { - return join(__dirname, '../assets', assetName); + return join(__dirname, '../e2e/assets', assetName); } +export function copyProjectAsset(assetName: string, to?: string) { + const tempRoot = join(getGlobalVariable('projects-root'), 'test-project'); + const sourcePath = assetDir(assetName); + const targetPath = join(tempRoot, to || assetName); + + return copyFile(sourcePath, targetPath); +} -export function copyAssets(assetName: string) { - const tempRoot = join(getGlobalVariable('tmp-root'), 'assets', assetName); +export function copyAssets(assetName: string, to?: string) { + const seed = +Date.now(); + const tempRoot = join(getTestAssetsDir(), assetName + '-' + seed); const root = assetDir(assetName); return Promise.resolve() .then(() => { - const allFiles = glob.sync(join(root, '**/*'), { dot: true, nodir: true }); + const allFiles = glob.sync('**/*', { dot: true, cwd: root }); return allFiles.reduce((promise, filePath) => { - const relPath = relative(root, filePath); - const toPath = join(tempRoot, relPath); + const toPath = + to !== undefined ? resolve(getTestProjectDir(), to, filePath) : join(tempRoot, filePath); - return promise.then(() => copyFile(filePath, toPath)); + return promise + .then(() => copyFile(join(root, filePath), toPath)) + .then(() => chmod(toPath, 0o777)); }, Promise.resolve()); }) .then(() => tempRoot); } +/** + * @returns a method that once called will restore the environment + * to use the local NPM registry. + * */ +export async function createProjectFromAsset( + assetName: string, + useNpmPackages = false, + skipInstall = false, +): Promise<() => Promise> { + const dir = await copyAssets(assetName); + process.chdir(dir); -export function createProjectFromAsset(assetName: string) { - const packages = require('../../../lib/packages').packages; + await setRegistry(!useNpmPackages /** useTestRegistry */); - return Promise.resolve() - .then(() => copyAssets(assetName)) - .then(dir => process.chdir(dir)) - .then(() => useBuiltPackages()) - .then(() => silentNpm('install')); + if (!useNpmPackages) { + await useBuiltPackagesVersions(); + } + if (!skipInstall) { + await installWorkspacePackages(); + } + + return () => setRegistry(true /** useTestRegistry */); +} + +export function getTestAssetsDir(): string { + return join(getGlobalVariable('projects-root'), 'assets'); } diff --git a/tests/e2e/utils/ast.ts b/tests/e2e/utils/ast.ts deleted file mode 100644 index c3ac718b8a7e..000000000000 --- a/tests/e2e/utils/ast.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - insertImport as _insertImport, - addImportToModule as _addImportToModule, - NodeHost -} from '../../../packages/@angular/cli/lib/ast-tools'; - - -export function insertImport(file: string, symbol: string, module: string) { - return _insertImport(file, symbol, module) - .then(change => change.apply(NodeHost)); -} - -export function addImportToModule(file: string, symbol: string, module: string) { - return _addImportToModule(file, symbol, module) - .then(change => change.apply(NodeHost)); -} diff --git a/tests/e2e/utils/env.ts b/tests/e2e/utils/env.ts index dd80596f0698..d2f0feece0a7 100644 --- a/tests/e2e/utils/env.ts +++ b/tests/e2e/utils/env.ts @@ -1,13 +1,26 @@ -const global: {[name: string]: any} = Object.create(null); - +const ENV_PREFIX = 'LEGACY_CLI__'; export function setGlobalVariable(name: string, value: any) { - global[name] = value; + if (value === undefined) { + delete process.env[ENV_PREFIX + name]; + } else { + process.env[ENV_PREFIX + name] = JSON.stringify(value); + } } -export function getGlobalVariable(name: string): any { - if (!(name in global)) { +export function getGlobalVariable(name: string): T { + const value = process.env[ENV_PREFIX + name]; + if (value === undefined) { throw new Error(`Trying to access variable "${name}" but it's not defined.`); } - return global[name]; + return JSON.parse(value) as T; +} + +export function getGlobalVariablesEnv(): NodeJS.ProcessEnv { + return Object.keys(process.env) + .filter((v) => v.startsWith(ENV_PREFIX)) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); } diff --git a/tests/e2e/utils/fs.ts b/tests/e2e/utils/fs.ts index 13d424e81f17..2ebf7af9d28a 100644 --- a/tests/e2e/utils/fs.ts +++ b/tests/e2e/utils/fs.ts @@ -1,198 +1,134 @@ -import * as fs from 'fs-extra'; -import {dirname} from 'path'; -import {stripIndents} from 'common-tags'; - - -export function readFile(fileName: string) { - return new Promise((resolve, reject) => { - fs.readFile(fileName, 'utf-8', (err: any, data: string) => { - if (err) { - reject(err); - } else { - resolve(data); - } - }); - }); -} +import { promises as fs, constants } from 'node:fs'; +import { dirname, join } from 'node:path'; -export function writeFile(fileName: string, content: string, options?: any) { - return new Promise((resolve, reject) => { - fs.writeFile(fileName, content, options, (err: any) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); +export function readFile(fileName: string): Promise { + return fs.readFile(fileName, 'utf-8'); } - -export function deleteFile(path: string) { - return new Promise((resolve, reject) => { - fs.unlink(path, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); +export function writeFile(fileName: string, content: string, options?: any): Promise { + return fs.writeFile(fileName, content, options); } - -export function rimraf(path: string) { - return new Promise((resolve, reject) => { - fs.remove(path, (err?: any) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); +export function deleteFile(path: string): Promise { + return fs.unlink(path); } - -export function moveFile(from: string, to: string) { - return new Promise((resolve, reject) => { - fs.rename(from, to, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); +export function rimraf(path: string): Promise { + return fs.rm(path, { + force: true, + recursive: true, + maxRetries: 3, }); } +export function moveFile(from: string, to: string): Promise { + return fs.rename(from, to); +} -export function symlinkFile(from: string, to: string, type?: string) { - return new Promise((resolve, reject) => { - fs.symlink(from, to, type, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); +export function symlinkFile(from: string, to: string, type?: string): Promise { + return fs.symlink(from, to, type); } -export function createDir(path: string) { - return _recursiveMkDir(path); +export function createDir(path: string): Promise { + return fs.mkdir(path, { recursive: true }); } +export async function copyFile(from: string, to: string): Promise { + await createDir(dirname(to)); -function _recursiveMkDir(path: string): Promise { - if (fs.existsSync(path)) { - return Promise.resolve(); - } else { - return _recursiveMkDir(dirname(path)) - .then(() => fs.mkdirSync(path)); - } + return fs.copyFile(from, to, constants.COPYFILE_FICLONE); } -export function copyFile(from: string, to: string) { - return _recursiveMkDir(dirname(to)) - .then(() => new Promise((resolve, reject) => { - const rd = fs.createReadStream(from); - rd.on('error', (err: Error) => reject(err)); - - const wr = fs.createWriteStream(to); - wr.on('error', (err: Error) => reject(err)); - wr.on('close', () => resolve()); +export async function moveDirectory(from: string, to: string): Promise { + await rimraf(to); + await createDir(to); - rd.pipe(wr); - })); + for (const entry of await fs.readdir(from)) { + const fromEntry = join(from, entry); + const toEntry = join(to, entry); + if ((await fs.stat(fromEntry)).isFile()) { + await copyFile(fromEntry, toEntry); + } else { + await moveDirectory(fromEntry, toEntry); + } + } } - export function writeMultipleFiles(fs: { [path: string]: string }) { - return Promise.all(Object.keys(fs).map(fileName => writeFile(fileName, fs[fileName]))); + return Promise.all(Object.keys(fs).map((fileName) => writeFile(fileName, fs[fileName]))); } - export function replaceInFile(filePath: string, match: RegExp | string, replacement: string) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, content.replace(match, replacement))); + return readFile(filePath).then((content: string) => + writeFile(filePath, content.replace(match, replacement)), + ); } - export function appendToFile(filePath: string, text: string, options?: any) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, content.concat(text), options)); + return readFile(filePath).then((content: string) => + writeFile(filePath, content.concat(text), options), + ); } - export function prependToFile(filePath: string, text: string, options?: any) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, text.concat(content), options)); + return readFile(filePath).then((content: string) => + writeFile(filePath, text.concat(content), options), + ); } +export async function expectFileMatchToExist(dir: string, regex: RegExp): Promise { + const files = await fs.readdir(dir); + const fileName = files.find((name) => regex.test(name)); -export function expectFileMatchToExist(dir: string, regex: RegExp) { - return new Promise((resolve, reject) => { - const [fileName] = fs.readdirSync(dir).filter(name => name.match(regex)); - if (!fileName) { - reject(new Error(`File ${regex} was expected to exist but not found...`)); - } - resolve(fileName); - }); + if (!fileName) { + throw new Error(`File ${regex} was expected to exist but not found...`); + } + + return fileName; } -export function expectFileNotToExist(fileName: string) { - return new Promise((resolve, reject) => { - fs.exists(fileName, (exist) => { - if (exist) { - reject(new Error(`File ${fileName} was expected not to exist but found...`)); - } else { - resolve(); - } - }); - }); +export async function expectFileNotToExist(fileName: string): Promise { + try { + await fs.access(fileName, constants.F_OK); + } catch { + return; + } + + throw new Error(`File ${fileName} was expected not to exist but found...`); } -export function expectFileToExist(fileName: string) { - return new Promise((resolve, reject) => { - fs.exists(fileName, (exist) => { - if (exist) { - resolve(); - } else { - reject(new Error(`File ${fileName} was expected to exist but not found...`)); - } - }); - }); +export async function expectFileToExist(fileName: string): Promise { + try { + await fs.access(fileName, constants.F_OK); + } catch { + throw new Error(`File ${fileName} was expected to exist but not found...`); + } +} + +export async function expectFileToMatch(fileName: string, regEx: RegExp | string): Promise { + const content = await readFile(fileName); + + const found = typeof regEx === 'string' ? content.includes(regEx) : content.match(regEx); + + if (!found) { + throw new Error( + `File "${fileName}" did not contain "${regEx}"...\nContent:\n${content}\n------`, + ); + } } -export function expectFileToMatch(fileName: string, regEx: RegExp | string) { - return readFile(fileName) - .then(content => { - if (typeof regEx == 'string') { - if (content.indexOf(regEx) == -1) { - throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... - Content: - ${content} - ------ - `); - } - } else { - if (!content.match(regEx)) { - throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... - Content: - ${content} - ------ - `); - } - } - }); -} - -export function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { - return readFile(fileName) - .then(content => { - if (content.length > sizeInBytes) { - throw new Error(`File "${fileName}" exceeded file size of "${sizeInBytes}".`); - } - }); +export async function getFileSize(fileName: string) { + const stats = await fs.stat(fileName); + + return stats.size; +} + +export async function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { + const fileSize = await getFileSize(fileName); + + if (fileSize > sizeInBytes) { + throw new Error( + `File "${fileName}" exceeded file size of "${sizeInBytes}". Size is ${fileSize}.`, + ); + } } diff --git a/tests/e2e/utils/git.ts b/tests/e2e/utils/git.ts index 35d7dd876eac..39bb47ce7d52 100644 --- a/tests/e2e/utils/git.ts +++ b/tests/e2e/utils/git.ts @@ -1,37 +1,21 @@ -import {git, silentGit} from './process'; +import { git, silentGit } from './process'; - -export function gitClean() { - console.log(' Cleaning git...'); - return silentGit('clean', '-df') - .then(() => silentGit('reset', '--hard')) - .then(() => { - // Checkout missing files - return silentGit('status', '--porcelain') - .then(({ stdout }) => stdout - .split(/[\n\r]+/g) - .filter(line => line.match(/^ D/)) - .map(line => line.replace(/^\s*\S+\s+/, ''))) - .then(files => silentGit('checkout', ...files)); - }) - .then(() => expectGitToBeClean()); +export async function gitClean(): Promise { + await silentGit('clean', '-df'); + await silentGit('reset', '--hard'); } -export function expectGitToBeClean() { - return git('status', '--porcelain') - .then(({ stdout }) => { - if (stdout != '') { - throw new Error('Git repo is not clean...'); - } - }); +export async function expectGitToBeClean(): Promise { + const { stdout } = await silentGit('status', '--porcelain'); + if (stdout != '') { + throw new Error('Git repo is not clean...\n' + stdout); + } } -export function gitCommit(message: string) { - return git('add', '-A') - .then(() => git('status', '--porcelain')) - .then(({ stdout }) => { - if (stdout != '') { - return git('commit', '-am', message); - } - }); +export async function gitCommit(message: string): Promise { + await git('add', '-A'); + const { stdout } = await silentGit('status', '--porcelain'); + if (stdout != '') { + await git('commit', '-am', message); + } } diff --git a/tests/e2e/utils/http.ts b/tests/e2e/utils/http.ts deleted file mode 100644 index fa0fbc32c3c5..000000000000 --- a/tests/e2e/utils/http.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {IncomingMessage} from 'http'; -import * as _request from 'request'; - - -export function request(url: string): Promise { - return new Promise((resolve, reject) => { - let options = { - url: url, - headers: { 'Accept': 'text/html' }, - agentOptions: { rejectUnauthorized: false } - }; - _request(options, (error: any, response: IncomingMessage, body: string) => { - if (error) { - reject(error); - } else if (response.statusCode >= 400) { - reject(new Error(`Requesting "${url}" returned status code ${response.statusCode}.`)); - } else { - resolve(body); - } - }); - }); -} diff --git a/tests/e2e/utils/network.ts b/tests/e2e/utils/network.ts new file mode 100644 index 000000000000..48602f0000d2 --- /dev/null +++ b/tests/e2e/utils/network.ts @@ -0,0 +1,30 @@ +import { createServer } from 'node:net'; + +/** + * Finds an available network port on the loopback interface (127.0.0.1). + * This is useful for tests that need to bind to a free port to avoid conflicts. + * Explicitly binds to IPv4 localhost to avoid firewall prompts, IPv6 binding issues, and ensure consistency. + * + * @returns A promise that resolves with an available port number. + */ +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once('listening', () => { + const address = srv.address(); + if (!address || typeof address === 'string') { + // Should not happen with TCP, but good for type safety + srv.close(() => reject(new Error('Failed to get server address'))); + return; + } + const port = address.port; + srv.close((e) => (e ? reject(e) : resolve(port))); + }); + + // If an error happens (e.g. during bind), the server is not listening, + // so we should not call close(). + srv.once('error', (e) => reject(e)); + // Explicitly listen on IPv4 localhost to avoid firewall prompts, IPv6 binding issues, and ensure consistency. + srv.listen(0, '127.0.0.1'); + }); +} diff --git a/tests/e2e/utils/packages.ts b/tests/e2e/utils/packages.ts new file mode 100644 index 000000000000..087d771f14b8 --- /dev/null +++ b/tests/e2e/utils/packages.ts @@ -0,0 +1,79 @@ +import { getGlobalVariable } from './env'; +import { ProcessOutput, silentBun, silentNpm, silentPnpm, silentYarn } from './process'; + +export interface PkgInfo { + readonly name: string; + readonly version: string; + readonly path: string; +} + +export function getActivePackageManager(): 'npm' | 'yarn' | 'bun' | 'pnpm' { + return getGlobalVariable('package-manager'); +} + +export async function installWorkspacePackages(options?: { force?: boolean }): Promise { + switch (getActivePackageManager()) { + case 'npm': + const npmArgs = ['install']; + if (options?.force) { + npmArgs.push('--force'); + } + await silentNpm(...npmArgs); + break; + case 'yarn': + await silentYarn('install'); + break; + case 'pnpm': + await silentPnpm('install'); + break; + case 'bun': + await silentBun('install'); + break; + } +} + +export function installPackage(specifier: string, registry?: string): Promise { + const registryOption = registry ? [`--registry=${registry}`] : []; + switch (getActivePackageManager()) { + case 'npm': + return silentNpm('install', specifier, ...registryOption); + case 'yarn': + return silentYarn('add', specifier, ...registryOption); + case 'bun': + return silentBun('add', specifier, ...registryOption); + case 'pnpm': + return silentPnpm('add', specifier, ...registryOption); + } +} + +export async function uninstallPackage(name: string): Promise { + try { + switch (getActivePackageManager()) { + case 'npm': + await silentNpm('uninstall', name); + break; + case 'yarn': + await silentYarn('remove', name); + break; + case 'bun': + await silentBun('remove', name); + break; + case 'pnpm': + await silentPnpm('remove', name); + break; + } + } catch (e) { + // Yarn throws an error when trying to remove a package that is not installed. + console.error(e); + } +} + +export async function setRegistry(useTestRegistry: boolean): Promise { + const url = useTestRegistry + ? getGlobalVariable('package-registry') + : 'https://registry.npmjs.org'; + + // Ensure local test registry is used when outside a project + // Yarn supports both `NPM_CONFIG_REGISTRY` and `YARN_REGISTRY`. + process.env['NPM_CONFIG_REGISTRY'] = url; +} diff --git a/tests/e2e/utils/process.ts b/tests/e2e/utils/process.ts index 49a47e4e114e..102acb0af65a 100644 --- a/tests/e2e/utils/process.ts +++ b/tests/e2e/utils/process.ts @@ -1,44 +1,66 @@ -import * as child_process from 'child_process'; -import {blue, yellow} from 'chalk'; -import {getGlobalVariable} from './env'; -import {rimraf, writeFile} from './fs'; -const treeKill = require('tree-kill'); - +import { spawn, SpawnOptions } from 'node:child_process'; +import * as child_process from 'node:child_process'; +import { getGlobalVariable, getGlobalVariablesEnv } from './env'; +import { delimiter, join, resolve } from 'node:path'; +import { stripVTControlCharacters, styleText } from 'node:util'; +import { assertIsError } from './utils'; interface ExecOptions { silent?: boolean; waitForMatch?: RegExp; + env?: NodeJS.ProcessEnv; + stdin?: string; + cwd?: string; } +/** + * While `NPM_CONFIG_` and `YARN_` are case insensitive we filter based on case. + * This is because when invoking a command using `yarn` it will add a bunch of these variables in lower case. + * This causes problems when we try to update the variables during the test setup. + */ +const NPM_CONFIG_RE = /^(NPM_CONFIG_|YARN_|NO_UPDATE_NOTIFIER)/; let _processes: child_process.ChildProcess[] = []; -type ProcessOutput = { +export type ProcessOutput = { stdout: string; stderr: string; }; - function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { - let stdout = ''; - let stderr = ''; - const cwd = process.cwd(); + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + console.log( - `==========================================================================================` + `==========================================================================================`, ); - args = args.filter(x => x !== undefined); + // Ensure the custom npm and yarn global bin is on the PATH + // https://docs.npmjs.com/cli/v8/configuring-npm/folders#executables + const paths = [ + join(getGlobalVariable('yarn-global'), 'bin'), + join(getGlobalVariable('npm-global'), process.platform.startsWith('win') ? '' : 'bin'), + env.PATH || process.env['PATH'], + ].join(delimiter); + + args = args.filter((x) => x !== undefined); const flags = [ options.silent && 'silent', - options.waitForMatch && `matching(${options.waitForMatch})` + options.waitForMatch && `matching(${options.waitForMatch})`, ] - .filter(x => !!x) // Remove false and undefined. + .filter((x) => !!x) // Remove false and undefined. .join(', ') - .replace(/^(.+)$/, ' [$1]'); // Proper formatting. + .replace(/^(.+)$/, ' [$1]'); // Proper formatting. - console.log(blue(`Running \`${cmd} ${args.map(x => `"${x}"`).join(' ')}\`${flags}...`)); - console.log(blue(`CWD: ${cwd}`)); - const spawnOptions: any = {cwd}; + console.log( + styleText(['blue'], `Running \`${cmd} ${args.map((x) => `"${x}"`).join(' ')}\`${flags}...`), + ); + console.log(styleText(['blue'], `CWD: ${cwd}`)); + + const spawnOptions: SpawnOptions = { + cwd, + env: { ...env, PATH: paths }, + }; if (process.platform.startsWith('win')) { args.unshift('/c', cmd); @@ -47,81 +69,232 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { - stdout += data.toString('utf-8'); - if (options.silent) { - return; - } - data.toString('utf-8') - .split(/[\n\r]+/) - .filter(line => line !== '') - .forEach(line => console.log(' ' + line)); - }); - childProcess.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8'); - if (options.silent) { - return; - } - data.toString('utf-8') - .split(/[\n\r]+/) - .filter(line => line !== '') - .forEach(line => console.error(yellow(' ' + line))); - }); _processes.push(childProcess); // Create the error here so the stack shows who called this function. - const err = new Error(`Running "${cmd} ${args.join(' ')}" returned error code `); - return new Promise((resolve, reject) => { - childProcess.on('exit', (error: any) => { - _processes = _processes.filter(p => p !== childProcess); - - if (!error) { - resolve({ stdout }); - } else { - err.message += `${error}...\n\nSTDOUT:\n${stdout}\n`; - reject(err); + const error = new Error(); + + const processPromise = new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let matched = false; + + // Return log info about the current process status + function envDump() { + return `STDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`; + } + + childProcess.stdout!.on('data', (data: Buffer) => { + stdout += data.toString('utf-8'); + + if (options.waitForMatch && stdout.match(options.waitForMatch)) { + resolve({ stdout, stderr }); + matched = true; } + + if (options.silent) { + return; + } + + data + .toString('utf-8') + .split(/[\n\r]+/) + .filter((line) => line !== '') + .forEach((line) => console.log(' ' + line)); }); - if (options.waitForMatch) { - childProcess.stdout.on('data', (data: Buffer) => { - if (data.toString().match(options.waitForMatch)) { - resolve({ stdout, stderr }); - } - }); + childProcess.stderr!.on('data', (data: Buffer) => { + stderr += data.toString('utf-8'); + + if (options.waitForMatch && stderr.match(options.waitForMatch)) { + resolve({ stdout, stderr }); + matched = true; + } + + if (options.silent) { + return; + } + + data + .toString('utf-8') + .split(/[\n\r]+/) + .filter((line) => line !== '') + .forEach((line) => console.error(styleText(['yellow'], ' ' + line))); + }); + + childProcess.on('close', (code) => { + _processes = _processes.filter((p) => p !== childProcess); + + if (options.waitForMatch && !matched) { + reject( + `Process output didn't match - "${cmd} ${args.join(' ')}": '${ + options.waitForMatch + }': ${code}...\n\n${envDump()}\n`, + ); + return; + } + + if (!code) { + resolve({ stdout, stderr }); + return; + } + + reject(`Process exit error - "${cmd} ${args.join(' ')}": ${code}...\n\n${envDump()}\n`); + }); + + childProcess.on('error', (err) => { + reject(`Process error - "${cmd} ${args.join(' ')}": ${err}...\n\n${envDump()}\n`); + }); + + // Provide input to stdin if given. + if (options.stdin) { + childProcess.stdin!.write(options.stdin); + childProcess.stdin!.end(); } + }).catch((err) => { + error.message = err.toString(); + return Promise.reject(error); }); + + if (!options.waitForMatch) { + return processPromise; + } + + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise: Promise = new Promise((_resolve, reject) => { + // Wait for 60 seconds and timeout. + const duration = 60_000; + timeout = setTimeout(() => { + reject( + new Error(`Waiting for ${options.waitForMatch} timed out (timeout: ${duration}msec)...`), + ); + }, duration); + }); + + return Promise.race([timeoutPromise, processPromise]).finally( + () => timeout && clearTimeout(timeout), + ); +} + +export function extractNpmEnv() { + return Object.keys(process.env) + .filter((v) => NPM_CONFIG_RE.test(v)) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); +} + +export function extractCIAndInfraEnv(): NodeJS.ProcessEnv { + return Object.keys(process.env) + .filter( + (v) => + v.startsWith('SAUCE_') || + v === 'CI' || + v === 'CHROME_BIN' || + v === 'CHROME_PATH' || + v === 'CHROMEDRIVER_BIN' || + v.startsWith('JS_BINARY__'), + ) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); +} + +function extractNgEnv() { + return Object.keys(process.env) + .filter((v) => v.startsWith('NG_')) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); } -export function waitForAnyProcessOutputToMatch(match: RegExp, - timeout = 30000): Promise { +export async function waitForAnyProcessOutputToMatch( + match: RegExp, + timeout = 30000, +): Promise { + let timeoutId: ReturnType | null = null; + // Race between _all_ processes, and the timeout. First one to resolve/reject wins. - return Promise.race(_processes.map(childProcess => new Promise(resolve => { - let stdout = ''; - let stderr = ''; - childProcess.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - if (data.toString().match(match)) { - resolve({ stdout, stderr }); - } - }); - childProcess.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); + const timeoutPromise: Promise = new Promise((_resolve, reject) => { + // Wait for 30 seconds and timeout. + timeoutId = setTimeout(() => { + reject(new Error(`Waiting for ${match} timed out (timeout: ${timeout}msec)...`)); + }, timeout); + }); + + const matchPromises: Promise[] = _processes.map( + (childProcess) => + new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + + childProcess.stdout!.on('data', (data: Buffer) => { + stdout += data.toString(); + if (stripVTControlCharacters(stdout).match(match)) { + resolve({ stdout, stderr }); + } + }); + + childProcess.stderr!.on('data', (data: Buffer) => { + stderr += data.toString(); + if (stripVTControlCharacters(stderr).match(match)) { + resolve({ stdout, stderr }); + } + }); + }), + ); + + const matchingProcess = await Promise.race(matchPromises.concat([timeoutPromise])); + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + return matchingProcess; +} + +/** + * Kills a process by PID + * @param pid The PID of the process to kill + * @param signal The signal to send to the process + */ +async function killProcess(pid: number, signal: NodeJS.Signals): Promise { + if (process.platform === 'win32') { + // /T kills child processes, /F forces it + await new Promise((resolve) => { + child_process.exec(`taskkill /pid ${pid} /T /F`, () => resolve()); }); - })).concat([ - new Promise((resolve, reject) => { - // Wait for 30 seconds and timeout. - setTimeout(() => { - reject(new Error(`Waiting for ${match} timed out (timeout: ${timeout}msec)...`)); - }, timeout); - }) - ])); + } else { + // Use -pid to signal the entire process group + try { + process.kill(-pid, signal); + } catch (error) { + assertIsError(error); + if (error.code !== 'ESRCH') { + throw error; + } + } + } } -export function killAllProcesses(signal = 'SIGTERM') { - _processes.forEach(process => treeKill(process.pid, signal)); - _processes = []; +/** + * Kills all tracked processes + */ +export async function killAllProcesses(signal: NodeJS.Signals = 'SIGTERM'): Promise { + const processesToKill: Promise[] = []; + + while (_processes.length) { + const childProc = _processes.pop(); + if (!childProc || childProc.pid === undefined || childProc.killed) { + continue; + } + + processesToKill.push(killProcess(childProc.pid, signal)); + } + + await Promise.all(processesToKill); } export function exec(cmd: string, ...args: string[]) { @@ -132,36 +305,64 @@ export function silentExec(cmd: string, ...args: string[]) { return _exec({ silent: true }, cmd, args); } -export function execAndWaitForOutputToMatch(cmd: string, args: string[], match: RegExp) { - return _exec({ waitForMatch: match }, cmd, args); +export function execWithEnv(cmd: string, args: string[], env: NodeJS.ProcessEnv, stdin?: string) { + return _exec({ env, stdin }, cmd, args); } -export function silentExecAndWaitForOutputToMatch(cmd: string, args: string[], match: RegExp) { - return _exec({ silent: true, waitForMatch: match }, cmd, args); +export async function execAndCaptureError( + cmd: string, + args: string[], + env?: NodeJS.ProcessEnv, + stdin?: string, +): Promise { + try { + await _exec({ env, stdin }, cmd, args); + throw new Error('Tried to capture subprocess exception, but it completed successfully.'); + } catch (err) { + if (err instanceof Error) { + return err; + } + throw new Error('Subprocess exception was not an Error instance'); + } } +export async function execAndWaitForOutputToMatch( + cmd: string, + args: string[], + match: RegExp, + env?: NodeJS.ProcessEnv, +) { + if (cmd === 'ng' && args[0] === 'serve') { + // Accept matches up to 20 times after the initial match. + // Useful because the Webpack watcher can rebuild a few times due to files changes that + // happened just before the build (e.g. `git clean`). + // This seems to be due to host file system differences, see + // https://nodejs.org/docs/latest/api/fs.html#fs_caveats + const maxRetries = 20; + let lastResult = await _exec({ waitForMatch: match, env }, cmd, args); + + for (let i = 0; i < maxRetries; i++) { + try { + lastResult = await waitForAnyProcessOutputToMatch(match, 2500); + } catch { + // If we timeout (no new match found), we assume the process is stable. + break; + } + } + + return lastResult; + } else { + return _exec({ waitForMatch: match, env }, cmd, args); + } +} -let npmInstalledEject = false; export function ng(...args: string[]) { const argv = getGlobalVariable('argv'); const maybeSilentNg = argv['nosilent'] ? noSilentNg : silentNg; - if (['build', 'serve', 'test', 'e2e', 'xi18n'].indexOf(args[0]) != -1) { - // If we have the --eject, use webpack for the test. - if (args[0] == 'build' && argv.eject) { - return maybeSilentNg('eject', ...args.slice(1), '--force') - .then(() => { - if (!npmInstalledEject) { - npmInstalledEject = true; - // We need to run npm install on the first eject. - return silentNpm('install'); - } - }) - .then(() => rimraf('dist')) - .then(() => _exec({silent: true}, 'node_modules/.bin/webpack', [])); - } else if (args[0] == 'e2e') { + if (['build', 'serve', 'test', 'e2e', 'extract-i18n'].indexOf(args[0]) != -1) { + if (args[0] == 'e2e') { // Wait 1 second before running any end-to-end test. - return new Promise(resolve => setTimeout(resolve, 1000)) - .then(() => maybeSilentNg(...args)); + return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => maybeSilentNg(...args)); } return maybeSilentNg(...args); @@ -175,25 +376,124 @@ export function noSilentNg(...args: string[]) { } export function silentNg(...args: string[]) { - return _exec({silent: true}, 'ng', args); + return _exec({ silent: true }, 'ng', args); +} + +export function silentNpm(...args: string[]): Promise; +export function silentNpm(args: string[], options?: { cwd?: string }): Promise; +export function silentNpm( + ...args: string[] | [args: string[], options?: { cwd?: string }] +): Promise { + if (Array.isArray(args[0])) { + const [params, options] = args; + return _exec( + { + silent: true, + cwd: (options as { cwd?: string } | undefined)?.cwd, + }, + 'npm', + params, + ); + } else { + return _exec({ silent: true }, 'npm', args as string[]); + } +} + +export function silentYarn(...args: string[]) { + return _exec({ silent: true }, 'yarn', args); } -export function silentNpm(...args: string[]) { - return _exec({silent: true}, 'npm', args); +export function silentPnpm(...args: string[]) { + return _exec({ silent: true }, 'pnpm', args); } -export function npm(...args: string[]) { - return _exec({}, 'npm', args); +export function silentBun(...args: string[]) { + return _exec({ silent: true }, 'bun', args); +} + +export function globalNpm(args: string[], env?: NodeJS.ProcessEnv) { + if (!process.env.LEGACY_CLI_RUNNER) { + throw new Error( + 'The global npm cli should only be executed from the primary e2e runner process', + ); + } + + return _exec({ silent: true, env }, 'npm', args); } export function node(...args: string[]) { - return _exec({}, 'node', args); + return _exec({}, process.execPath, args); } export function git(...args: string[]) { - return _exec({}, 'git', args); + return _exec({}, process.env.GIT_BIN || 'git', args); } export function silentGit(...args: string[]) { - return _exec({silent: true}, 'git', args); + return _exec({ silent: true }, process.env.GIT_BIN || 'git', args); +} + +/** + * Launch the given entry in an child process isolated to the test environment. + * + * The test environment includes the local NPM registry, isolated NPM globals, + * the PATH variable only referencing the local node_modules and local NPM + * registry (not the test runner or standard global node_modules). + */ +export async function launchTestProcess(entry: string, ...args: any[]): Promise { + // NOTE: do NOT use the bazel TEST_TMPDIR. When sandboxing is not enabled the + // TEST_TMPDIR is not sandboxed and has symlinks into the src dir in a + // parent directory. Symlinks into the src dir will include package.json, + // .git and other files/folders that may effect e2e tests. + + const tempRoot: string = getGlobalVariable('tmp-root'); + const TEMP = process.env.TEMP ?? process.env.TMPDIR ?? tempRoot; + + // Extract explicit environment variables for the test process. + const env: NodeJS.ProcessEnv = { + TEMP, + TMPDIR: TEMP, + HOME: TEMP, + + // Use BAZEL_TARGET as a metadata variable to show it is a + // process managed by bazel + BAZEL_TARGET: process.env.BAZEL_TARGET, + + ...extractNpmEnv(), + ...extractCIAndInfraEnv(), + ...extractNgEnv(), + ...getGlobalVariablesEnv(), + }; + + // Only include paths within the sandboxed test environment or external + // non angular-cli paths such as /usr/bin for generic commands. + env.PATH = process.env + .PATH!.split(delimiter) + .filter((p) => p.startsWith(tempRoot) || p.startsWith(TEMP) || !p.includes('angular-cli')) + .join(delimiter); + + const testProcessArgs = [ + // Note: `__dirname` is the bundle directory here. + resolve(__dirname, 'e2e/utils/test_process.js'), + entry, + ...args, + ]; + + return new Promise((resolve, reject) => { + spawn(process.execPath, testProcessArgs, { + stdio: 'inherit', + env, + }) + .on('close', (code) => { + if (!code) { + resolve(); + return; + } + + reject(`Process error - "${testProcessArgs}`); + }) + .on('error', (err) => { + reject(`Process exit error - "${testProcessArgs}]\n\n${err}`); + }); + }); } diff --git a/tests/e2e/utils/project.ts b/tests/e2e/utils/project.ts index 093dc49bd92c..0deb6ea48262 100644 --- a/tests/e2e/utils/project.ts +++ b/tests/e2e/utils/project.ts @@ -1,258 +1,250 @@ -import { readFile, writeFile, replaceInFile } from './fs'; -import { execAndWaitForOutputToMatch, silentNpm, ng } from './process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { prerelease, SemVer } from 'semver'; import { getGlobalVariable } from './env'; +import { readFile, replaceInFile, writeFile } from './fs'; +import { gitCommit } from './git'; +import { findFreePort } from './network'; +import { installWorkspacePackages, PkgInfo } from './packages'; +import { execAndWaitForOutputToMatch, git, ng } from './process'; +import { join } from 'node:path'; -const packages = require('../../../lib/packages').packages; +export function updateJsonFile(filePath: string, fn: (json: any) => any | void) { + return readFile(filePath).then((tsConfigJson) => { + // Remove single and multiline comments + const tsConfig = JSON.parse(tsConfigJson.replace(/\/\*\s(.|\n|\r)*\s\*\/|\/\/.*/g, '')) as any; + const result = fn(tsConfig) || tsConfig; + + return writeFile(filePath, JSON.stringify(result, null, 2)); + }); +} +export function updateTsConfig(fn: (json: any) => any | void) { + return updateJsonFile('tsconfig.json', fn); +} -const tsConfigPath = 'src/tsconfig.app.json'; +export async function ngServe(...args: string[]) { + const port = await findFreePort(); + const esbuild = getGlobalVariable('argv')['esbuild']; + const validBundleRegEx = esbuild ? /complete\./ : /Compiled successfully\./; -export function updateJsonFile(filePath: string, fn: (json: any) => any | void) { - return readFile(filePath) - .then(tsConfigJson => { - const tsConfig = JSON.parse(tsConfigJson); - const result = fn(tsConfig) || tsConfig; + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', String(port), ...args], + validBundleRegEx, + ); - return writeFile(filePath, JSON.stringify(result, null, 2)); - }); + return port; } +export async function prepareProjectForE2e(name: string) { + const argv: Record = getGlobalVariable('argv'); -export function updateTsConfig(fn: (json: any) => any | void) { - return updateJsonFile(tsConfigPath, fn); -} + await git('config', 'user.email', 'angular-core+e2e@google.com'); + await git('config', 'user.name', 'Angular CLI E2E'); + await git('config', 'commit.gpgSign', 'false'); + await git('config', 'core.longpaths', 'true'); + if (argv['ng-snapshots'] || argv['ng-tag']) { + await useSha(); + } -export function ngServe(...args: string[]) { - return execAndWaitForOutputToMatch('ng', - ['serve', ...args], - /webpack: bundle is now VALID|webpack: Compiled successfully./); -} + console.log(`Project ${name} created... Installing packages.`); + await installWorkspacePackages(); + await useCIChrome(name, ''); + await useCIDefaults(name); -export function createProject(name: string, ...args: string[]) { - const argv: any = getGlobalVariable('argv'); - - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => ng('new', name, '--skip-install', ...args)) - .then(() => process.chdir(name)) - .then(() => useBuiltPackages()) - .then(() => useCIChrome()) - .then(() => useCIDefaults()) - .then(() => argv['ng2'] ? useNg2() : Promise.resolve()) - .then(() => argv.nightly || argv['ng-sha'] ? useSha() : Promise.resolve()) - .then(() => console.log(`Project ${name} created... Installing npm.`)) - .then(() => silentNpm('install')); + // Force sourcemaps to be from the root of the filesystem. + await updateJsonFile('tsconfig.json', (json) => { + json['compilerOptions']['sourceRoot'] = '/'; + }); + await gitCommit('prepare-project-for-e2e'); } -export function useBuiltPackages() { - return Promise.resolve() - .then(() => updateJsonFile('package.json', json => { - if (!json['dependencies']) { - json['dependencies'] = {}; - } - if (!json['devDependencies']) { - json['devDependencies'] = {}; - } +export function useBuiltPackagesVersions(): Promise { + const packages: { [name: string]: PkgInfo } = getGlobalVariable('package-tars'); - for (const packageName of Object.keys(packages)) { - if (json['dependencies'].hasOwnProperty(packageName)) { - json['dependencies'][packageName] = packages[packageName].tar; - } else if (json['devDependencies'].hasOwnProperty(packageName)) { - json['devDependencies'][packageName] = packages[packageName].tar; - } + return updateJsonFile('package.json', (json) => { + json['dependencies'] ??= {}; + json['devDependencies'] ??= {}; + + for (const packageName of Object.keys(packages)) { + if (packageName in json['dependencies']) { + json['dependencies'][packageName] = packages[packageName].version; + } else if (packageName in json['devDependencies']) { + json['devDependencies'][packageName] = packages[packageName].version; } - })); + } + }); } -export function useSha() { - if (argv.nightly || argv['ng-sha']) { - const label = argv['ng-sha'] ? `#2.0.0-${argv['ng-sha']}` : ''; - return updateJsonFile('package.json', json => { - // Install over the project with nightly builds. - Object.keys(json['dependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { +export async function useSha(): Promise { + const argv = getGlobalVariable('argv'); + if (!argv['ng-snapshots'] && !argv['ng-tag']) { + return; + } + + // We need more than the sha here, version is also needed. Examples of latest tags: + // 7.0.0-beta.4+dd2a650 + // 6.1.6+4a8d56a + const label = argv['ng-tag'] || ''; + const ngSnapshotVersions = require('../ng-snapshot/package.json'); + + return updateJsonFile('package.json', (json) => { + // Install over the project with snapshot builds. + function replaceDependencies(key: string) { + const missingSnapshots: string[] = []; + Object.keys(json[key] || {}) + .filter((name) => name.startsWith('@angular/')) + .forEach((name) => { const pkgName = name.split(/\//)[1]; - if (pkgName == 'cli') { + if (pkgName === 'cli' || pkgName === 'ssr' || pkgName === 'build') { return; } - json['dependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); - Object.keys(json['devDependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - if (pkgName == 'cli') { - return; + if (label) { + json[key][`@angular/${pkgName}`] = `github:angular/${pkgName}-builds${label}`; + } else { + const replacement = ngSnapshotVersions.dependencies[`@angular/${pkgName}`]; + if (!replacement) { + missingSnapshots.push(`missing @angular/${pkgName}`); + } + json[key][`@angular/${pkgName}`] = replacement; } - json['devDependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; }); - }); - } else { - return Promise.resolve(); - } + if (missingSnapshots.length > 0) { + throw new Error( + 'e2e test with --ng-snapshots requires all angular packages be ' + + 'listed in tests/e2e/ng-snapshot/package.json.\nErrors:\n' + + missingSnapshots.join('\n '), + ); + } + } + + replaceDependencies('dependencies'); + replaceDependencies('devDependencies'); + }); } -export function useCIDefaults() { - return updateJsonFile('.angular-cli.json', configJson => { - // Auto-add some flags to ng commands that build or test the app. - // --no-progress disables progress logging, which in CI logs thousands of lines. - // --no-sourcemaps disables sourcemaps, making builds faster. - // We add these flags before other args so that they can be overriden. - // e.g. `--no-sourcemaps --sourcemaps` will still generate sourcemaps. - const defaults = configJson.defaults; - defaults.build = { - sourcemaps: false, - progress: false - }; - }) +export function useCIDefaults(projectName = 'test-project'): Promise { + return updateJsonFile('angular.json', (workspaceJson) => { + // Disable progress reporting on CI to reduce spam. + const project = workspaceJson.projects[projectName]; + const appTargets = project.targets || project.architect; + appTargets.build.options.progress = false; + appTargets.test.options.progress = false; + + if (appTargets.serve) { + // Use a random port in serve. + appTargets.serve.options ??= {}; + appTargets.serve.options.port = 0; + } + }); } -export function useCIChrome() { - // There's a race condition happening in Chrome. Enabling logging in chrome used by - // protractor actually fixes it. Logging is piped to a file so it doesn't affect our setup. - // --no-sandbox is needed for Circle CI. - // Travis can use headless chrome, but not appveyor. - return Promise.resolve() - .then(() => replaceInFile('protractor.conf.js', `'browserName': 'chrome'`, - `'browserName': 'chrome', - chromeOptions: { - args: [ - "--enable-logging", - "--no-sandbox", - ${process.env['TRAVIS'] ? '"--headless", "--disable-gpu"' : ''} - ] - } - `)) - // Not a problem if the file can't be found. - .catch(() => null) - .then(() => replaceInFile('karma.conf.js', `browsers: ['Chrome'],`, - `browsers: ['ChromeCI'], +export async function useCIChrome(projectName: string, projectDir = ''): Promise { + const karmaConf = path.join(projectDir, 'karma.conf.js'); + if (fs.existsSync(karmaConf)) { + // Ensure the headless sandboxed chrome is configured in the karma config + await replaceInFile( + karmaConf, + `browsers: ['Chrome'],`, + `browsers: ['ChromeHeadlessNoSandbox'], customLaunchers: { - ChromeCI: { - base: '${process.env['TRAVIS'] ? 'ChromeHeadless' : 'Chrome'}', - flags: ['--no-sandbox'] - } - }, - `)) - .catch(() => null); -} + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'], + }, + },`, + ); + } -// Convert a Angular 4 project to Angular 2. -export function useNg2() { - const ng2Deps: any = { - 'dependencies': { - '@angular/common': '^2.4.0', - '@angular/compiler': '^2.4.0', - '@angular/core': '^2.4.0', - '@angular/forms': '^2.4.0', - '@angular/http': '^2.4.0', - '@angular/platform-browser': '^2.4.0', - '@angular/platform-browser-dynamic': '^2.4.0', - '@angular/router': '^3.4.0', - 'zone.js': '^0.7.4' - }, - 'devDependencies': { - '@angular/compiler-cli': '^2.4.0', - '@types/jasmine': '~2.2.0', - '@types/jasminewd2': undefined, - 'typescript': '~2.0.0' - } - }; - - const tsconfigAppJson: any = { - 'compilerOptions': { - 'sourceMap': true, - 'declaration': false, - 'moduleResolution': 'node', - 'emitDecoratorMetadata': true, - 'experimentalDecorators': true, - 'target': 'es5', - 'lib': [ - 'es2017', - 'dom' - ], - 'outDir': '../out-tsc/app', - 'module': 'es2015', - 'baseUrl': '', - 'types': [] - }, - 'exclude': [ - 'test.ts', - '**/*.spec.ts' - ] - }; - - const tsconfigSpecJson: any = { - 'compilerOptions': { - 'sourceMap': true, - 'declaration': false, - 'moduleResolution': 'node', - 'emitDecoratorMetadata': true, - 'experimentalDecorators': true, - 'lib': [ - 'es2017', - 'dom' - ], - 'outDir': '../out-tsc/spec', - 'module': 'commonjs', - 'target': 'es5', - 'baseUrl': '', - 'types': [ - 'jasmine', - 'node' - ] - }, - 'files': [ - 'test.ts' - ], - 'include': [ - '**/*.spec.ts', - '**/*.d.ts' - ] - }; - - const tsconfigE2eJson: any = { - 'compilerOptions': { - 'sourceMap': true, - 'declaration': false, - 'moduleResolution': 'node', - 'emitDecoratorMetadata': true, - 'experimentalDecorators': true, - 'lib': [ - 'es2017' - ], - 'outDir': '../out-tsc/e2e', - 'module': 'commonjs', - 'target': 'es5', - 'types': [ - 'jasmine', - 'node' - ] + // Update to use the headless sandboxed chrome + return updateJsonFile('angular.json', (workspaceJson) => { + const project = workspaceJson.projects[projectName]; + const appTargets = project.targets || project.architect; + if (appTargets.test.builder === '@angular/build:unit-test') { + appTargets.test.options.browsers = ['ChromeHeadlessNoSandbox']; + } else { + appTargets.test.options.browsers = 'ChromeHeadlessNoSandbox'; } - }; + }); +} +export function getNgCLIVersion(): SemVer { + const packages: { [name: string]: PkgInfo } = getGlobalVariable('package-tars'); + + return new SemVer(packages['@angular/cli'].version); +} - return Promise.resolve() - .then(() => updateJsonFile('package.json', json => { - Object.keys(ng2Deps['dependencies']).forEach(pkgName => { - json['dependencies'][pkgName] = ng2Deps['dependencies'][pkgName]; +export function isPrereleaseCli(): boolean { + return (prerelease(getNgCLIVersion())?.length ?? 0) > 0; +} + +export function updateServerFileForEsbuild(filepath: string): Promise { + return writeFile( + filepath, + ` + import { APP_BASE_HREF } from '@angular/common'; + import { CommonEngine } from '@angular/ssr/node'; + import express from 'express'; + import { join, resolve } from 'node:path'; + import bootstrap from './main.server'; + + // The Express app is exported so that it can be used by serverless Functions. + export function app(): express.Express { + const server = express(); + const serverDistFolder = import.meta.dirname; + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const indexHtml = join(serverDistFolder, 'index.server.html'); + + const commonEngine = new CommonEngine(); + + server.set('view engine', 'html'); + server.set('views', browserDistFolder); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: false, + })); + + // All regular routes use the Angular engine + server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; + + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: \`\${protocol}://\${headers.host}\${originalUrl}\`, + publicPath: browserDistFolder, + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); }); - Object.keys(ng2Deps['devDependencies']).forEach(pkgName => { - json['devDependencies'][pkgName] = ng2Deps['devDependencies'][pkgName]; + + return server; + } + + function run(): void { + const port = process.env['PORT'] || 4000; + const server = app(); + server.listen(port, (error) => { + if (error) { + throw error; + } + console.log(\`Node Express server listening on http://localhost:\${port}\`); }); - console.log(JSON.stringify(json)) - })) - .then(() => updateJsonFile('src/tsconfig.app.json', json => - Object.assign(json, tsconfigAppJson))) - .then(() => updateJsonFile('src/tsconfig.spec.json', json => - Object.assign(json, tsconfigSpecJson))) - .then(() => updateJsonFile('e2e/tsconfig.e2e.json', json => - Object.assign(json, tsconfigE2eJson))); + } + + run(); + `, + ); +} + +export function getTestProjectDir(): string { + return join(getGlobalVariable('projects-root'), 'test-project'); } diff --git a/tests/e2e/utils/puppeteer.ts b/tests/e2e/utils/puppeteer.ts new file mode 100644 index 000000000000..ae8aae45761a --- /dev/null +++ b/tests/e2e/utils/puppeteer.ts @@ -0,0 +1,93 @@ +import { type Page, launch } from 'puppeteer'; +import { execAndWaitForOutputToMatch, killAllProcesses } from './process'; +import { stripVTControlCharacters } from 'node:util'; + +export interface BrowserTestOptions { + project?: string; + configuration?: string; + baseUrl?: string; + checkFn?: (page: Page) => Promise; + expectedTitleText?: string; +} + +export async function executeBrowserTest(options: BrowserTestOptions = {}) { + let url = options.baseUrl; + let hasStartedServer = false; + + try { + 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', '--no-watch', '--no-live-reload']; + if (options.project) { + serveArgs.push(options.project); + } + if (options.configuration) { + serveArgs.push(`--configuration=${options.configuration}`); + } + + const { stdout } = await execAndWaitForOutputToMatch('ng', serveArgs, match, { + ...process.env, + 'NO_COLOR': '1', + }); + url = stripVTControlCharacters(stdout).match(match)?.[1]; + if (!url) { + throw new Error('Could not find serving URL'); + } + hasStartedServer = true; + } + + const browser = await launch({ + executablePath: process.env['CHROME_BIN'], + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + try { + const page = await browser.newPage(); + + // Capture errors + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + page.on('pageerror', (err) => { + const error = `${err}`; + if (!error.includes('Hot Module Replacement is disabled')) { + errors.push(error); + } + }); + + await page.goto(url); + + if (options.checkFn) { + await options.checkFn(page); + } else { + // Default check: verify h1 content and no browser errors + const expectedText = options.expectedTitleText || 'Hello, test-project'; + + // Wait for the h1 element to appear and contain the expected text + await page.waitForFunction( + (selector: string, text: string) => { + const doc = (globalThis as any).document; + return doc.querySelector(selector)?.textContent?.includes(text); + }, + { timeout: 10000 }, // Max 10 seconds wait time + 'h1', + expectedText, + ); + } + + if (errors.length > 0) { + throw new Error(`Browser console errors detected:\n${errors.join('\n')}`); + } + } finally { + await browser.close(); + } + } finally { + if (hasStartedServer) { + await killAllProcesses(); + } + } +} diff --git a/tests/e2e/utils/registry.ts b/tests/e2e/utils/registry.ts new file mode 100644 index 000000000000..5a871fb20ce6 --- /dev/null +++ b/tests/e2e/utils/registry.ts @@ -0,0 +1,124 @@ +import { ChildProcess, fork } from 'node:child_process'; +import { on } from 'node:events'; +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { getGlobalVariable } from './env'; +import { writeFile, readFile } from './fs'; +import { existsSync } from 'node:fs'; + +export async function createNpmRegistry( + port: number, + httpsPort: number, + withAuthentication = false, +): Promise { + // Setup local package registry + const registryPath = join(getGlobalVariable('tmp-root'), 'registry'); + if (!existsSync(registryPath)) { + await mkdir(registryPath); + } + + const configFileName = withAuthentication ? 'verdaccio_auth.yaml' : 'verdaccio.yaml'; + let configContent = await readFile(join(__dirname, '../', configFileName)); + configContent = configContent + .replace(/\$\{HTTP_PORT\}/g, String(port)) + .replace(/\$\{HTTPS_PORT\}/g, String(httpsPort)); + const configPath = join(registryPath, configFileName); + + await writeFile(configPath, configContent); + + const verdaccioBin = join(dirname(require.resolve('verdaccio/package.json')), 'bin/verdaccio'); + const verdaccioServer = fork(verdaccioBin, ['-c', configPath]); + for await (const events of on(verdaccioServer, 'message', { + signal: AbortSignal.timeout(30_000), + })) { + if ( + events.some( + (event: unknown) => + event && + typeof event === 'object' && + 'verdaccio_started' in event && + event.verdaccio_started, + ) + ) { + break; + } + } + + return verdaccioServer; +} + +// Token was generated using `echo -n 'testing:s3cret' | openssl base64`. +const VALID_TOKEN = `dGVzdGluZzpzM2NyZXQ=`; + +export async function createNpmConfigForAuthentication( + /** + * When true, the authentication token will be scoped to the registry URL. + * @example + * ```ini + * //localhost:4876/:_auth="dGVzdGluZzpzM2NyZXQ=" + * ``` + * + * When false, the authentication will be added as seperate key. + * @example + * ```ini + * _auth="dGVzdGluZzpzM2NyZXQ="` + * ``` + */ + scopedAuthentication = true, + /** When true, an incorrect token is used. Use this to validate authentication failures. */ + invalidToken = false, +): Promise { + const token = invalidToken ? `invalid=` : VALID_TOKEN; + const registry = (getGlobalVariable('package-secure-registry') as string).replace(/^\w+:/, ''); + + await writeFile( + '.npmrc', + scopedAuthentication + ? ` +${registry}/:_auth="${token}" +${registry}/:always-auth=true +always-auth=true +registry=http:${registry} +` + : ` +_auth="${token}" +always-auth=true +registry=http:${registry} +`, + ); + + await writeFile( + '.yarnrc', + scopedAuthentication + ? ` +${registry}/:_auth "${token}" +${registry}/:always-auth true +always-auth true +registry http:${registry} +` + : ` +_auth "${token}" +always-auth true +registry http:${registry} +`, + ); +} + +export function setNpmEnvVarsForAuthentication( + /** When true, an incorrect token is used. Use this to validate authentication failures. */ + invalidToken = false, + /** When true, `YARN_REGISTRY` is used instead of `NPM_CONFIG_REGISTRY`. */ + useYarnEnvVariable = false, +): void { + delete process.env['YARN_REGISTRY']; + delete process.env['NPM_CONFIG_REGISTRY']; + + const registryKey = useYarnEnvVariable ? 'YARN_REGISTRY' : 'NPM_CONFIG_REGISTRY'; + process.env[registryKey] = getGlobalVariable('package-secure-registry'); + + process.env['NPM_CONFIG__AUTH'] = invalidToken ? `invalid=` : VALID_TOKEN; + + // Needed for verdaccio when used with yarn + // https://verdaccio.org/docs/en/cli-registry#yarn + process.env['NPM_CONFIG_ALWAYS_AUTH'] = 'true'; +} diff --git a/tests/e2e/utils/tar.ts b/tests/e2e/utils/tar.ts new file mode 100644 index 000000000000..016e88f25024 --- /dev/null +++ b/tests/e2e/utils/tar.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +import { createReadStream } from 'node:fs'; +import { normalize } from 'node:path'; +import { createGunzip } from 'node:zlib'; +import { extract } from 'tar-stream'; + +/** + * Extract and return the contents of a single file out of a tar file. + * + * @param tarball the tar file to extract from + * @param filePath the path of the file to extract + * @returns the Buffer of file or an error on fs/tar error or file not found + */ +export function extractFile(tarball: string, filePath: string): Promise { + const normalizedFilePath = normalize(filePath); + + return new Promise((resolve, reject) => { + const extractor = extract(); + + extractor.on('entry', (header, stream, next) => { + if (normalize(header.name) !== normalizedFilePath) { + stream.resume(); + next(); + + return; + } + + const chunks: Buffer[] = []; + stream.on('data', (chunk) => chunks.push(chunk as Buffer)); + stream.on('error', reject); + stream.on('end', () => { + resolve(Buffer.concat(chunks)); + next(); + }); + }); + + extractor.on('finish', () => reject(new Error(`'${filePath}' not found in '${tarball}'.`))); + + const readStream = createReadStream(tarball); + const gunzip = createGunzip(); + + readStream.on('error', reject); + gunzip.on('error', reject); + extractor.on('error', reject); + + readStream.pipe(gunzip).pipe(extractor as unknown as NodeJS.WritableStream); + }); +} diff --git a/tests/e2e/utils/test_process.ts b/tests/e2e/utils/test_process.ts new file mode 100644 index 000000000000..af6bd61af365 --- /dev/null +++ b/tests/e2e/utils/test_process.ts @@ -0,0 +1,23 @@ +import { killAllProcesses } from './process'; + +const testScript: string = process.argv[2]; +const testModule = require(testScript); +const testFunction: () => Promise | void = + typeof testModule == 'function' + ? testModule + : typeof testModule.default == 'function' + ? testModule.default + : () => { + throw new Error('Invalid test module.'); + }; + +(async () => { + try { + await testFunction(); + } catch (e) { + console.error('Test Process error', e); + process.exitCode = -1; + } finally { + killAllProcesses().finally(() => process.exit()); + } +})(); diff --git a/tests/e2e/utils/utils.ts b/tests/e2e/utils/utils.ts index 8189ed771b4e..54cfa2d70fa8 100644 --- a/tests/e2e/utils/utils.ts +++ b/tests/e2e/utils/utils.ts @@ -1,16 +1,46 @@ +import assert from 'node:assert'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; -export function expectToFail(fn: () => Promise, errorMessage?: string): Promise { - return fn() - .then(() => { +export function expectToFail(fn: () => Promise, errorMessage?: string): Promise { + return fn().then( + () => { const functionSource = fn.name || (fn).source || fn.toString(); const errorDetails = errorMessage ? `\n\tDetails:\n\t${errorMessage}` : ''; throw new Error( - `Function ${functionSource} was expected to fail, but succeeded.${errorDetails}`); - }, () => { }); + `Function ${functionSource} was expected to fail, but succeeded.${errorDetails}`, + ); + }, + (err) => { + return err instanceof Error ? err : new Error(err); + }, + ); } -export function wait(msecs: number) { - return new Promise((resolve) => { - setTimeout(resolve, msecs); - }); +export async function mktempd(prefix: string, tempRoot?: string): Promise { + return realpath(await mkdtemp(path.join(tempRoot ?? tmpdir(), prefix))); +} + +export async function mockHome(cb: (home: string) => Promise): Promise { + const tempHome = await mktempd('angular-cli-e2e-home-'); + + const oldHome = process.env.HOME; + process.env.HOME = tempHome; + + try { + await cb(tempHome); + } finally { + process.env.HOME = oldHome; + + await rm(tempHome, { recursive: true, force: true }); + } +} + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); } diff --git a/tests/e2e/utils/version.ts b/tests/e2e/utils/version.ts new file mode 100644 index 000000000000..34dc29720b91 --- /dev/null +++ b/tests/e2e/utils/version.ts @@ -0,0 +1,13 @@ +import * as fs from 'node:fs'; +import * as semver from 'semver'; + +export function readNgVersion(): string { + const packageJson: any = JSON.parse( + fs.readFileSync('./node_modules/@angular/core/package.json', 'utf8'), + ) as { version: string }; + return packageJson['version']; +} + +export function ngVersionMatches(range: string): boolean { + return semver.satisfies(readNgVersion(), range); +} diff --git a/tests/e2e/utils/vitest.ts b/tests/e2e/utils/vitest.ts new file mode 100644 index 000000000000..ebb9d9d12298 --- /dev/null +++ b/tests/e2e/utils/vitest.ts @@ -0,0 +1,88 @@ +import { createRequire } from 'node:module'; +import { installWorkspacePackages } from './packages'; +import { updateJsonFile } from './project'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +/** Updates the `test` builder in the current workspace to use Vitest. */ +export async function applyVitestBuilder(options?: { + coverageV8?: boolean; + coverageIstanbul?: boolean; + playwright?: boolean; + webdriver?: boolean; + skipInstall?: boolean; +}): Promise { + const { coverageV8, coverageIstanbul, playwright, webdriver, skipInstall } = options ?? {}; + + const schematicsAngular = createRequire(process.cwd() + '/').resolve( + '@schematics/angular/package.json', + ); + + const latestVersionsPath = resolve(schematicsAngular, '../utility/latest-versions/package.json'); + + const { dependencies: latestVersion } = JSON.parse(await readFile(latestVersionsPath, 'utf-8')); + + await updateJsonFile('package.json', (json) => { + [ + 'karma', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'jasmine-core', + '@types/jasmine', + 'istanbul-lib-instrument', + ].forEach((dep) => { + delete json.devDependencies[dep]; + }); + + json.devDependencies['vitest'] = latestVersion['vitest']; + json.devDependencies['jsdom'] = latestVersion['jsdom']; + + if (webdriver) { + json.devDependencies['webdriverio'] = latestVersion['webdriverio']; + json.devDependencies['@vitest/browser-webdriverio'] = + latestVersion['@vitest/browser-webdriverio']; + } + + if (playwright) { + json.devDependencies['playwright'] = latestVersion['playwright']; + json.devDependencies['@vitest/browser-playwright'] = + latestVersion['@vitest/browser-playwright']; + } + + if (coverageV8) { + json.devDependencies['@vitest/coverage-v8'] = latestVersion['@vitest/coverage-v8']; + } + + if (coverageIstanbul) { + json.devDependencies['@vitest/coverage-istanbul'] = + latestVersion['@vitest/coverage-istanbul']; + } + }); + + if (!skipInstall) { + await installWorkspacePackages(); + } + + await updateJsonFile('angular.json', (json) => { + const projects = Object.values(json['projects']); + if (projects.length !== 1) { + throw new Error( + `Expected exactly one project but found ${projects.length} projects named ${Object.keys( + json['projects'], + ).join(', ')}`, + ); + } + const project = projects[0]! as any; + + // Update to Vitest builder. + const test = project['architect']['test']; + test['builder'] = '@angular/build:unit-test'; + test['options'] = {}; + }); + + await updateJsonFile('tsconfig.spec.json', (tsconfig) => { + tsconfig['compilerOptions']['types'] = ['vitest/globals']; + }); +} diff --git a/tests/e2e_runner.ts b/tests/e2e_runner.ts index 21e79a502564..3f1945905089 100644 --- a/tests/e2e_runner.ts +++ b/tests/e2e_runner.ts @@ -1,122 +1,195 @@ -// This may seem awkward but we're using Logger in our e2e. At this point the unit tests -// have run already so it should be "safe", teehee. -import { - ConsoleLoggerStack, - LogEntry, - IndentLogger, - NullLogger -} from '../packages/@ngtools/logger/src/index'; -import {blue, bold, green, red, yellow, white} from 'chalk'; -import {gitClean} from './e2e/utils/git'; -import * as glob from 'glob'; -import * as minimist from 'minimist'; -import * as path from 'path'; -import {setGlobalVariable} from './e2e/utils/env'; - -// RxJS -import 'rxjs/add/operator/filter'; -import 'rxjs/add/observable/empty'; - +import { parseArgs, styleText } from 'node:util'; +import glob from 'fast-glob'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { getGlobalVariable, setGlobalVariable } from './e2e/utils/env'; +import { gitClean } from './e2e/utils/git'; +import { createNpmRegistry } from './e2e/utils/registry'; +import { launchTestProcess } from './e2e/utils/process'; +import { delimiter, dirname, join } from 'node:path'; +import { findFreePort } from './e2e/utils/network'; +import { extractFile } from './e2e/utils/tar'; +import { realpathSync } from 'node:fs'; +import { PkgInfo } from './e2e/utils/packages'; +import { getTestProjectDir } from './e2e/utils/project'; +import { mktempd } from './e2e/utils/utils'; Error.stackTraceLimit = Infinity; +// tslint:disable:no-global-tslint-disable no-console /** * Here's a short description of those flags: - * --debug If a test fails, block the thread so the temporary directory isn't deleted. - * --noproject Skip creating a project or using one. - * --nobuild Skip building the packages. Use with --nolink and --reuse to quickly - * rerun tests. - * --nolink Skip linking your local @angular/cli directory. Can save a few seconds. - * --nosilent Never silence ng commands. - * --ng-sha=SHA Use a specific ng-sha. Similar to nightly but point to a master SHA instead - * of using the latest. - * --glob Run tests matching this glob pattern (relative to tests/e2e/). - * --ignore Ignore tests matching this glob pattern. - * --nightly Install angular nightly builds over the test project. - * --reuse=/path Use a path instead of create a new project. That project should have been - * created, and npm installed. Ideally you want a project created by a previous - * run of e2e. - * --nb-shards Total number of shards that this is part of. Default is 2 if --shard is - * passed in. - * --shard Index of this processes' shard. + * --debug If a test fails, block the thread so the temporary directory isn't deleted. + * --noproject Skip creating a project or using one. + * --noglobal Skip linking your local @angular/cli directory. Can save a few seconds. + * --nosilent Never silence ng commands. + * --ng-tag=TAG Use a specific tag for build snapshots. Similar to ng-snapshots but point to a + * tag instead of using the latest `main`. + * --ng-snapshots Install angular snapshot builds in the test project. + * --glob Run tests matching this glob pattern (relative to tests/e2e/). + * --ignore Ignore tests matching this glob pattern. + * --nb-shards Total number of shards that this is part of. Default is 2 if --shard is + * passed in. + * --shard Index of this processes' shard. + * --package-manager Package manager to use. + * --package=path An npm package to be published before running tests + * * If unnamed flags are passed in, the list of tests will be filtered to include only those passed. */ -const argv = minimist(process.argv.slice(2), { - 'boolean': [ - 'appveyor', - 'debug', - 'eject', - 'nightly', - 'nolink', - 'nosilent', - 'noproject', - 'verbose', - ], - 'string': ['glob', 'ignore', 'reuse', 'ng-sha', ], - 'number': ['nb-shards', 'shard'] +const parsed = parseArgs({ + allowPositionals: true, + options: { + 'debug': { type: 'boolean', default: !!process.env.BUILD_WORKSPACE_DIRECTORY }, + 'esbuild': { type: 'boolean' }, + 'glob': { type: 'string', default: 'tests/**/*.js' }, + 'ignore': { type: 'string', multiple: true }, + 'ng-snapshots': { type: 'boolean' }, + 'ng-tag': { type: 'string' }, + 'ng-version': { type: 'string' }, + 'noglobal': { type: 'boolean' }, + 'noproject': { type: 'boolean' }, + 'nosilent': { type: 'boolean' }, + 'package': { type: 'string', multiple: true, default: ['./dist/_*.tgz'] }, + 'package-manager': { type: 'string', default: 'npm' }, + 'verbose': { type: 'boolean' }, + + 'nb-shards': { type: 'string' }, + 'shard': { type: 'string' }, + }, }); +const argv = { + ...parsed.values, + _: parsed.positionals, + 'nb-shards': + parsed.values['nb-shards'] ?? + (Number(process.env.E2E_SHARD_TOTAL ?? 1) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) || 1), + shard: + parsed.values.shard ?? + (process.env.E2E_SHARD_INDEX === undefined && process.env.TEST_SHARD_INDEX === undefined + ? undefined + : Number(process.env.E2E_SHARD_INDEX ?? 0) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) + + Number(process.env.TEST_SHARD_INDEX ?? 0)), +}; + +// Indicate sharding support for Bazel. +if (process.env['TEST_SHARD_STATUS_FILE']) { + fs.writeFileSync(process.env['TEST_SHARD_STATUS_FILE'], '', 'utf8'); +} /** * Set the error code of the process to 255. This is to ensure that if something forces node * to exit without finishing properly, the error code will be 255. Right now that code is not used. * - * When tests succeed we already call `process.exit(0)`, so this doesn't change any correct + * - 1 When tests succeed we already call `process.exit(0)`, so this doesn't change any correct * behaviour. * * One such case that would force node <= v6 to exit with code 0, is a Promise that doesn't resolve. */ process.exitCode = 255; +/** + * Mark this process as the main e2e_runner + */ +process.env.LEGACY_CLI_RUNNER = '1'; -ConsoleLoggerStack.start(new IndentLogger('name')) - .filter((entry: LogEntry) => (entry.level != 'debug' || argv.verbose)) - .subscribe((entry: LogEntry) => { - let color: (s: string) => string = white; - let output = process.stdout; - switch (entry.level) { - case 'info': color = white; break; - case 'warn': color = yellow; break; - case 'error': color = red; output = process.stderr; break; - case 'fatal': color = (x: string) => bold(red(x)); output = process.stderr; break; - } +/** + * Add external git toolchain onto PATH + */ +if (process.env.GIT_BIN) { + process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.GIT_BIN!); +} - output.write(color(entry.message) + '\n'); - }); +/** + * Add external browser toolchains onto PATH + */ +if (process.env.CHROME_BIN) { + process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.CHROME_BIN!); +} -const testGlob = argv.glob || 'tests/**/*.ts'; -let currentFileName = null; +// Under bazel the compiled file (.js) and types (.d.ts) are available. +const SRC_FILE_EXT_RE = /\.js$/; +const testGlob = (process.env.TESTBRIDGE_TEST_ONLY ?? argv.glob).replace(/\.ts$/, '.js'); const e2eRoot = path.join(__dirname, 'e2e'); -const allSetups = glob.sync(path.join(e2eRoot, 'setup/**/*.ts'), { nodir: true }) - .map(name => path.relative(e2eRoot, name)) - .sort(); -const allTests = glob.sync(path.join(e2eRoot, testGlob), { nodir: true, ignore: argv.ignore }) - .map(name => path.relative(e2eRoot, name)) - .sort(); +const allSetups = glob.sync(`setup/**/*.js`, { cwd: e2eRoot }).sort(); +const allInitializers = glob.sync(`initialize/**/*.js`, { cwd: e2eRoot }).sort(); +const isWindows = process.platform === 'win32'; + +const allTests = glob + .sync(testGlob, { cwd: e2eRoot, ignore: argv.ignore }) + // Replace windows slashes. + .map((name) => name.replace(/\\/g, '/')) + .filter((name) => { + if (name.endsWith('/setup.js')) { + return false; + } + if (!SRC_FILE_EXT_RE.test(name)) { + return false; + } -const shardId = ('shard' in argv) ? argv['shard'] : null; -const nbShards = (shardId === null ? 1 : argv['nb-shards']) || 2; -const tests = allTests - .filter(name => { - // Check for naming tests on command line. - if (argv._.length == 0) { - return true; + // The below is to exclude specific tests that are not intented to run for the current package manager. + // This is also important as without the trickery the tests that take the longest ex: update.ts (2.5mins) + // will be executed on the same shard. + const fileName = path.basename(name); + if ( + (fileName.startsWith('yarn-') && argv['package-manager'] !== 'yarn') || + (fileName.startsWith('npm-') && argv['package-manager'] !== 'npm') + ) { + return false; } - return argv._.some(argName => { - return path.join(process.cwd(), argName) == path.join(__dirname, 'e2e', name) - || argName == name - || argName == name.replace(/\.ts$/, ''); - }); + if (isWindows) { + if (fileName.startsWith('unix-')) { + return false; + } + } else if (fileName.startsWith('windows-')) { + return false; + } + + return true; + }) + .sort(); + +const shardId = argv['shard'] !== undefined ? Number(argv['shard']) : null; +const nbShards = shardId === null ? 1 : Number(argv['nb-shards']); +const tests = allTests.filter((name) => { + // Check for naming tests on command line. + if (argv._.length == 0) { + return true; + } + + return argv._.some((argName) => { + return ( + path.join(process.cwd(), argName + '') == path.join(__dirname, 'e2e', name) || + argName == name || + argName == name.replace(SRC_FILE_EXT_RE, '') + ); }); +}); + +console.log(`Running with shard configuration:`); +console.log(`Total shards: ${nbShards}, current shard: ${shardId}`); // Remove tests that are not part of this shard. -const shardedTests = tests - .filter((name, i) => (shardId === null || (i % nbShards) == shardId)); -const testsToRun = allSetups.concat(shardedTests); +const testsToRun = tests.filter((name, i) => shardId === null || i % nbShards == shardId); + +if (testsToRun.length === 0) { + if (shardId !== null && tests.length <= shardId) { + console.log(`No tests to run on shard ${shardId}, exiting`); + console.log(`Without sharding, there were ${tests.length} tests found.`); + process.exit(0); + } else { + console.log(`No tests would be ran, aborting.`); + process.exit(1); + } +} +if (shardId !== null) { + console.log(`Running shard ${shardId} of ${nbShards}`); +} /** * Load all the files from the e2e, filter and sort them and build a promise of their default @@ -125,117 +198,227 @@ const testsToRun = allSetups.concat(shardedTests); if (testsToRun.length == allTests.length) { console.log(`Running ${testsToRun.length} tests`); } else { - console.log(`Running ${testsToRun.length} tests (${allTests.length + allSetups.length} total)`); + console.log(`Running ${testsToRun.length} tests (${allTests.length} total)`); } +console.log(['Tests:', ...testsToRun].join('\n ')); + setGlobalVariable('argv', argv); +setGlobalVariable('package-manager', argv['package-manager']); + +// Use the chrome supplied by bazel or the puppeteer chrome and webdriver-manager driver outside. +// This is needed by karma-chrome-launcher, protractor etc. +// https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer +// +// Resolve from relative paths to absolute paths within the bazel runfiles tree +// so subprocesses spawned in a different working directory can still find them. +process.env.CHROME_BIN = path.resolve(process.env.CHROME_BIN!); +process.env.CHROME_PATH = path.resolve(process.env.CHROME_PATH!); +process.env.CHROMEDRIVER_BIN = path.resolve(process.env.CHROMEDRIVER_BIN!); + +(async () => { + const tempRoot = await mktempd('angular-cli-e2e-', process.env.E2E_TEMP); + setGlobalVariable('tmp-root', tempRoot); + + process.on('SIGINT', deleteTemporaryRoot); + process.on('exit', deleteTemporaryRoot); + + const [httpPort, httpsPort, packageTars] = await Promise.all([ + findFreePort(), + findFreePort(), + findPackageTars(), + ]); + setGlobalVariable('package-registry', 'http://localhost:' + httpPort); + setGlobalVariable('package-secure-registry', 'http://localhost:' + httpsPort); + setGlobalVariable('package-tars', packageTars); + + // NPM registries for the lifetime of the test execution + const registryProcess = await createNpmRegistry(httpPort, httpPort); + const secureRegistryProcess = await createNpmRegistry(httpPort, httpsPort, true); + + try { + console.log(` Using "${tempRoot}" as temporary directory for a new project.`); + + await runSteps(runSetup, allSetups, 'setup'); + await runSteps(runInitializer, allInitializers, 'initializer'); + await runSteps(runTest, testsToRun, 'test'); + + if (shardId !== null) { + console.log(styleText(['green'], `Done shard ${shardId} of ${nbShards}.`)); + } else { + console.log(styleText(['green'], 'Done.')); + } + + process.exitCode = 0; + } catch (err) { + if (err instanceof Error) { + console.log('\n'); + console.error(styleText(['red'], err.message)); + if (err.stack) { + console.error(styleText(['red'], err.stack)); + } + } else { + console.error(styleText(['red'], String(err))); + } + + if (argv.debug) { + console.log(`Current Directory: ${process.cwd()}`); + console.log('Will loop forever while you debug... CTRL-C to quit.'); -testsToRun.reduce((previous, relativeName, testIndex) => { - // Make sure this is a windows compatible path. - let absoluteName = path.join(e2eRoot, relativeName); - if (/^win/.test(process.platform)) { - absoluteName = absoluteName.replace(/\\/g, path.posix.sep); + // Wait forever until user explicitly cancels. + await new Promise(() => {}); + } + + process.exitCode = 1; + } finally { + registryProcess.kill(); + secureRegistryProcess.kill(); } +})().catch((err) => { + console.error(styleText(['red'], `Unkown Error: ${err}`)); + process.exitCode = 1; +}); - return previous.then(() => { - currentFileName = relativeName.replace(/\.ts$/, ''); - const start = +new Date(); - - const module = require(absoluteName); - const fn: (...args: any[]) => Promise | any = - (typeof module == 'function') ? module - : (typeof module.default == 'function') ? module.default - : () => { throw new Error('Invalid test module.'); }; - - let clean = true; - let previousDir = null; - return Promise.resolve() - .then(() => printHeader(currentFileName, testIndex)) - .then(() => previousDir = process.cwd()) - .then(() => ConsoleLoggerStack.push(currentFileName)) - .then(() => fn(() => clean = false)) - .then(() => ConsoleLoggerStack.pop(), (err: any) => { ConsoleLoggerStack.pop(); throw err; }) - .then(() => console.log('----')) - .then(() => { - // If we're not in a setup, change the directory back to where it was before the test. - // This allows tests to chdir without worrying about keeping the original directory. - if (allSetups.indexOf(relativeName) == -1 && previousDir) { - process.chdir(previousDir); - } - }) - .then(() => { - // Only clean after a real test, not a setup step. Also skip cleaning if the test - // requested an exception. - if (allSetups.indexOf(relativeName) == -1 && clean) { - ConsoleLoggerStack.push(NullLogger); - return gitClean() - .then(() => ConsoleLoggerStack.pop(), (err: any) => { - ConsoleLoggerStack.pop(); - throw err; - }); - } - }) - .then(() => printFooter(currentFileName, start), - (err) => { - printFooter(currentFileName, start); - console.error(err); - throw err; - }); - }); -}, Promise.resolve()) - .then(() => { - console.log(green('Done.')); - process.exit(0); - }, - (err) => { +async function runSteps( + run: (name: string) => Promise | void, + steps: string[], + type: 'setup' | 'test' | 'initializer', +) { + const capsType = type[0].toUpperCase() + type.slice(1); + + for (const [stepIndex, relativeName] of steps.entries()) { + // Make sure this is a windows compatible path. + let absoluteName = path.join(e2eRoot, relativeName).replace(SRC_FILE_EXT_RE, ''); + if (/^win/.test(process.platform)) { + absoluteName = absoluteName.replace(/\\/g, path.posix.sep); + } + + const name = relativeName.replace(SRC_FILE_EXT_RE, ''); + const start = Date.now(); + + printHeader(name, stepIndex, steps.length, type); + + // Run the test function with the current file. + try { + await run(absoluteName); + } catch (e) { console.log('\n'); - console.error(red(`Test "${currentFileName}" failed...`)); - console.error(red(err.message)); - console.error(red(err.stack)); - - if (argv.debug) { - console.log(`Current Directory: ${process.cwd()}`); - console.log('Will loop forever while you debug... CTRL-C to quit.'); - - /* eslint-disable no-constant-condition */ - while (1) { - // That's right! - } - } + console.error(styleText(['red'], `${capsType} "${name}" failed...`)); - process.exit(1); - }); + throw e; + } + + console.log('----'); + printFooter(name, type, start); + } +} +function runSetup(absoluteName: string): Promise { + const module = require(absoluteName); -function encode(str) { - return str.replace(/[^A-Za-z\d\/]+/g, '-').replace(/\//g, '.').replace(/[\/-]$/, ''); + return (typeof module === 'function' ? module : module.default)(); } -function isTravis() { - return process.env['TRAVIS']; +/** + * Run a file from the projects root directory in a subprocess via launchTestProcess(). + */ +function runInitializer(absoluteName: string): Promise { + process.chdir(getGlobalVariable('projects-root')); + + return launchTestProcess(absoluteName); } -function printHeader(testName: string, testIndex: number) { - const text = `${testIndex + 1} of ${testsToRun.length}`; - const fullIndex = (testIndex < allSetups.length ? testIndex - : (testIndex - allSetups.length) * nbShards + shardId + allSetups.length) + 1; - const length = tests.length + allSetups.length; - const shard = shardId === null ? '' - : yellow(` [${shardId}:${nbShards}]` + bold(` (${fullIndex}/${length})`)); - console.log(green(`Running "${bold(blue(testName))}" (${bold(white(text))}${shard})...`)); - - if (isTravis()) { - console.log(`travis_fold:start:${encode(testName)}`); - } +/** + * Run a file from the main 'test-project' directory in a subprocess via launchTestProcess(). + */ +async function runTest(absoluteName: string): Promise { + process.chdir(getTestProjectDir()); + + await launchTestProcess(absoluteName); + await cleanTestProject(); } -function printFooter(testName, startTime) { - if (isTravis()) { - console.log(`travis_fold:end:${encode(testName)}`); - } +async function cleanTestProject() { + await gitClean(); + + const testProject = getTestProjectDir(); + + // Note: Dist directory is not cleared between tests, as `git clean` + // doesn't delete it. + await rm(join(testProject, 'dist/'), { recursive: true, force: true }); +} + +function printHeader( + testName: string, + testIndex: number, + count: number, + type: 'setup' | 'initializer' | 'test', +) { + const text = `(${testIndex + 1} of ${count})`; + const fullIndex = testIndex * nbShards + (shardId ?? 0) + 1; + const shard = + shardId === null || type !== 'test' + ? '' + : styleText( + ['yellow'], + ` [${shardId}:${nbShards}]` + styleText(['bold'], ` (${fullIndex}/${tests.length})`), + ); + console.log( + styleText(['green'], `Running ${type} "`) + + styleText(['bold', 'blue'], testName) + + styleText(['green'], '" ') + + styleText(['bold', 'white'], text) + + shard + + styleText(['green'], '...'), + ); +} + +function printFooter(testName: string, type: 'setup' | 'initializer' | 'test', startTime: number) { + const capsType = type[0].toUpperCase() + type.slice(1); // Round to hundredth of a second. const t = Math.round((Date.now() - startTime) / 10) / 100; - console.log(green('Last step took ') + bold(blue(t)) + green('s...')); + console.log( + styleText(['green'], `${capsType} "`) + + styleText(['bold', 'blue'], testName) + + styleText(['green'], '" took ') + + styleText(['bold', 'blue'], t.toFixed(2)) + + styleText(['green'], 's...'), + ); console.log(''); } + +// Collect the packages passed as arguments and return as {package-name => pkg-path} +async function findPackageTars(): Promise<{ [pkg: string]: PkgInfo }> { + const pkgs: string[] = (getGlobalVariable('argv').package as string[]).flatMap((p) => + glob.sync(p), + ); + + const pkgJsons = await Promise.all( + pkgs.map((pkg) => realpathSync(pkg)).map((pkg) => extractFile(pkg, 'npm_package/package.json')), + ); + + return pkgs.reduce( + (all, pkg, i) => { + const json = pkgJsons[i].toString('utf8'); + const { name, version } = JSON.parse(json) as { name: string; version: string }; + if (!name) { + throw new Error(`Package ${pkg} - package.json name/version not found`); + } + + all[name] = { path: realpathSync(pkg), name, version }; + return all; + }, + {} as { [pkg: string]: PkgInfo }, + ); +} + +function deleteTemporaryRoot(): void { + try { + fs.rmSync(getGlobalVariable('tmp-root'), { + recursive: true, + force: true, + maxRetries: 3, + }); + } catch {} +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts deleted file mode 100644 index d43890018f06..000000000000 --- a/tests/helpers/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -const ng: ((parameters: string[]) => Promise) = require('./ng'); -const tmp = require('./tmp'); - -export function setupProject() { - beforeEach((done) => { - spyOn(console, 'error'); - - tmp.setup('./tmp') - .then(() => process.chdir('./tmp')) - .then(() => ng(['new', 'foo', '--skip-install'])) - .then(done, done.fail); - }, 10000); - - afterEach((done) => { - tmp.teardown('./tmp').then(done, done.fail); - }); -} - -export { - ng -}; diff --git a/tests/helpers/mock-ui.js b/tests/helpers/mock-ui.js deleted file mode 100644 index 5ba3f686989c..000000000000 --- a/tests/helpers/mock-ui.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -const UI = require('@angular/cli/ember-cli/lib/ui'); -const through = require('through'); - -module.exports = MockUI; -function MockUI() { - this.output = ''; - - UI.call(this, { - inputStream: through(), - outputStream: through(function (data) { - this.output += data; - }.bind(this)), - errorStream: through(function (data) { - this.errors += data; - }.bind(this)) - }); -} - -MockUI.prototype = Object.create(UI.prototype); -MockUI.prototype.constructor = MockUI; -MockUI.prototype.clear = function () { - this.output = ''; -}; diff --git a/tests/helpers/ng.js b/tests/helpers/ng.js deleted file mode 100644 index d02a614cfc5a..000000000000 --- a/tests/helpers/ng.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -const MockUI = require('./mock-ui'); -const cli = require('@angular/cli/lib/cli'); - -module.exports = function ng(args) { - process.env.PWD = process.cwd(); - - return cli({ - inputStream: [], - outputStream: [], - cliArgs: args, - UI: MockUI, - testing: true - }); -}; diff --git a/tests/helpers/tmp.js b/tests/helpers/tmp.js deleted file mode 100644 index 2da69c711e19..000000000000 --- a/tests/helpers/tmp.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -const fs = require('fs-extra'); - -const root = process.cwd(); - -module.exports.setup = function (path) { - process.chdir(root); - - return fs.remove(path).then(function () { - fs.mkdirsSync(path); - }); -}; - -module.exports.teardown = function (path) { - process.chdir(root); - - if (fs.pathExistsSync(path)) { - return fs.remove(path); - } else { - return Promise.resolve(); - } -}; diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 000000000000..1068e992bfef --- /dev/null +++ b/tests/package.json @@ -0,0 +1,7 @@ +{ + "devDependencies": { + "@types/tar-stream": "3.1.5", + "@angular-devkit/schematics": "workspace:*", + "tar-stream": "3.2.2" + } +} diff --git a/tests/rollup.config.mjs b/tests/rollup.config.mjs new file mode 100644 index 000000000000..c48299094fef --- /dev/null +++ b/tests/rollup.config.mjs @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import glob from 'fast-glob'; + +const testFiles = [ + 'e2e_runner.js', + 'e2e/utils/test_process.js', + ...glob.sync('e2e/(initialize|setup|tests)/**/*.js'), +]; + +// Generate chunks to keep the original folder structure. +// Needed as we dynamically load these files. +const chunks = {}; +for (const file of testFiles) { + chunks[file.slice(0, -'.js'.length)] = file; +} + +export default { + input: chunks, + external: ['undici', 'puppeteer'], // This cannot be bundled as `node:sqlite` is experimental in node.js 22. Remove once this feature is no longer behind a flag + plugins: [ + nodeResolve({ + preferBuiltins: true, + browser: false, + }), + json(), + commonjs({ + // Test runner uses dynamic requires, and those are fine. + // Rollup should not try to process them. + ignoreDynamicRequires: true, + }), + ], + output: { + dir: './runner_bundled_out', + exports: 'auto', + }, +}; diff --git a/tests/run_e2e.js b/tests/run_e2e.js deleted file mode 100644 index 065b7d84dfdf..000000000000 --- a/tests/run_e2e.js +++ /dev/null @@ -1,4 +0,0 @@ -/*eslint-disable no-console */ -'use strict'; -require('../lib/bootstrap-local'); -require('./e2e_runner.ts'); diff --git a/tests/runner.js b/tests/runner.js deleted file mode 100644 index 59f114a01aa8..000000000000 --- a/tests/runner.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -require('../lib/bootstrap-local'); -const glob = require('glob'); - -const path = require('path'); -const Jasmine = require('jasmine'); - -const projectBaseDir = path.join(__dirname, ''); - -// Create a Jasmine runner and configure it. -const jasmine = new Jasmine({ projectBaseDir: projectBaseDir }); -jasmine.loadConfig({}); -// Manually set exit code (needed with custom reporters) -jasmine.onComplete((success) => process.exitCode = !success); - -// Run the tests. -const allTests = - glob.sync('tests/acceptance/**/*.spec.ts') - .map(p => path.relative(projectBaseDir, p)); - -jasmine.execute(allTests); diff --git a/tests/schematics/update/packages/README.md b/tests/schematics/update/packages/README.md new file mode 100644 index 000000000000..83526f2cd776 --- /dev/null +++ b/tests/schematics/update/packages/README.md @@ -0,0 +1,14 @@ + +## Update Package Group packages + +* `update-package-group-1@1.0.0` -> `update-package-group2@^1` +* `update-package-group-2@1.0.0` -> `update-package-group1@1.0.0` +---- + +* `update-package-group-1@1.0.0` -> `update-package-group2@^1` +* `update-package-group-2@1.1.0` -> `update-package-group1@1.0.0` +---- + +* `update-package-group-1@1.2.0` -> `update-package-group2@^2` +* `update-package-group-2@2.0.0` -> `update-package-group1@^1` +---- diff --git a/tests/schematics/update/packages/update-base/package.json b/tests/schematics/update/packages/update-base/package.json new file mode 100644 index 000000000000..19520c8638b3 --- /dev/null +++ b/tests/schematics/update/packages/update-base/package.json @@ -0,0 +1,5 @@ +{ + "name": "@angular-devkit-tests/update-base", + "version": "1.1.0", + "description": "Tests" +} diff --git a/tests/schematics/update/packages/update-migrations-external/package.json b/tests/schematics/update/packages/update-migrations-external/package.json new file mode 100644 index 000000000000..adaa40d219bf --- /dev/null +++ b/tests/schematics/update/packages/update-migrations-external/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-migrations-external", + "version": "1.6.0", + "description": "Tests", + "ng-update": { + "migrations": "@angular-devkit-tests/update-migrations/migrations.json" + }, + "dependencies": { + "@angular-devkit-tests/update-migrations": "1.6.0" + } +} diff --git a/tests/schematics/update/packages/update-migrations/migrations.json b/tests/schematics/update/packages/update-migrations/migrations.json new file mode 100644 index 000000000000..f00f3ffbbd6d --- /dev/null +++ b/tests/schematics/update/packages/update-migrations/migrations.json @@ -0,0 +1,9 @@ +{ + "schematics": { + "1": { + "factory": "./v1_5.js", + "version": "1.5", + "description": "Test." + } + } +} diff --git a/tests/schematics/update/packages/update-migrations/package.json b/tests/schematics/update/packages/update-migrations/package.json new file mode 100644 index 000000000000..d68cb22a6802 --- /dev/null +++ b/tests/schematics/update/packages/update-migrations/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular-devkit-tests/update-migrations", + "version": "1.6.0", + "description": "Tests", + "ng-update": { + "migrations": "./migrations.json" + } +} diff --git a/tests/schematics/update/packages/update-migrations/v1_5.js b/tests/schematics/update/packages/update-migrations/v1_5.js new file mode 100644 index 000000000000..becf300fecdf --- /dev/null +++ b/tests/schematics/update/packages/update-migrations/v1_5.js @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +exports.default = function () { + return function (tree) { + tree.create('/version1_5', ''); + }; +}; diff --git a/tests/schematics/update/packages/update-package-group-1/package.json b/tests/schematics/update/packages/update-package-group-1/package.json new file mode 100644 index 000000000000..58a55973dd36 --- /dev/null +++ b/tests/schematics/update/packages/update-package-group-1/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-package-group-1", + "version": "1.2.0", + "description": "Tests", + "ng-update": { + "packageGroup": { + "@angular-devkit-tests/update-package-group-1": "", + "@angular-devkit-tests/update-package-group-2": "^2" + } + } +} diff --git a/tests/schematics/update/packages/update-package-group-2/package.json b/tests/schematics/update/packages/update-package-group-2/package.json new file mode 100644 index 000000000000..39231fc6a190 --- /dev/null +++ b/tests/schematics/update/packages/update-package-group-2/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-package-group-2", + "version": "2.0.0", + "description": "Tests", + "ng-update": { + "packageGroup": { + "@angular-devkit-tests/update-package-group-1": "^1", + "@angular-devkit-tests/update-package-group-2": "" + } + } +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-1/package.json b/tests/schematics/update/packages/update-peer-dependencies-1/package.json new file mode 100644 index 000000000000..4fac13f17181 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-1/package.json @@ -0,0 +1,5 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-1", + "version": "1.1.0", + "description": "Tests" +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-2/package.json b/tests/schematics/update/packages/update-peer-dependencies-2/package.json new file mode 100644 index 000000000000..f8624d818e18 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-2/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-2", + "version": "1.1.0", + "description": "Tests", + "peerDependencies": { + "@angular-devkit-tests/update-peer-dependencies-2": "1.0.0" + } +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-angular-5-2/package.json b/tests/schematics/update/packages/update-peer-dependencies-angular-5-2/package.json new file mode 100644 index 000000000000..dc79c9b83e52 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-angular-5-2/package.json @@ -0,0 +1,10 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-angular-5-2", + "version": "1.0.0", + "description": "Tests", + "peerDependencies": { + "@angular/animations": ">= 1 < 5.0.0 || >= 5.0.0-rc.0 < 6.0.0", + "@angular/compiler": ">= 1 < 5.0.0 || >= 5.0.0-rc.0 < 6.0.0", + "@angular/platform-browser": ">= 1 < 5.0.0 || >= 5.0.0-rc.0 < 6.0.0" + } +} diff --git a/tests/schematics/update/packages/update-peer-dependencies-angular-5/package.json b/tests/schematics/update/packages/update-peer-dependencies-angular-5/package.json new file mode 100644 index 000000000000..57c3fdd07db6 --- /dev/null +++ b/tests/schematics/update/packages/update-peer-dependencies-angular-5/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular-devkit-tests/update-peer-dependencies-angular-5", + "version": "1.0.0", + "description": "Tests", + "peerDependencies": { + "@angular/core": "^5.0.0" + } +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 000000000000..5070cc5b6927 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig-test.json", + "compilerOptions": { "paths": {} }, + "exclude": ["e2e/assets/**"] +} diff --git a/tests/verdaccio.yaml b/tests/verdaccio.yaml new file mode 100644 index 000000000000..a398f671b43e --- /dev/null +++ b/tests/verdaccio.yaml @@ -0,0 +1,51 @@ +storage: ./storage + +auth: + auth-memory: + users: {} +listen: localhost:${HTTP_PORT} +uplinks: + npmjs: + url: https://registry.npmjs.org/ + cache: false + maxage: 20m + max_fails: 32 + timeout: 60s + agent_options: + keepAlive: true + maxSockets: 32 + maxFreeSockets: 8 + +packages: + '@angular/{build,create,cli,pwa,ssr}': + access: $all + publish: $all + + '@angular-devkit/*': + access: $all + publish: $all + + '@ngtools/webpack': + access: $all + publish: $all + + '@schematics/{angular,schematics,update}': + access: $all + publish: $all + + '@*/*': + access: $all + proxy: npmjs + + '**': + access: $all + proxy: npmjs + +log: + type: stdout + format: pretty + level: warn + +# https://github.com/verdaccio/verdaccio/issues/301 +server: + keepAliveTimeout: 0 diff --git a/tests/verdaccio_auth.yaml b/tests/verdaccio_auth.yaml new file mode 100644 index 000000000000..1cb53f5d8b5f --- /dev/null +++ b/tests/verdaccio_auth.yaml @@ -0,0 +1,33 @@ +storage: ./storage +auth: + auth-memory: + users: + testing: + name: testing + password: s3cret +listen: localhost:${HTTPS_PORT} +uplinks: + local: + url: http://localhost:${HTTP_PORT} + cache: false + maxage: 20m + max_fails: 32 + timeout: 60s + agent_options: + keepAlive: true + maxSockets: 32 + maxFreeSockets: 8 + +packages: + '**': + access: $authenticated + proxy: local + +log: + type: stdout + format: pretty + level: warn + +# https://github.com/verdaccio/verdaccio/issues/301 +server: + keepAliveTimeout: 0 diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel new file mode 100644 index 000000000000..4804a9ee59a6 --- /dev/null +++ b/tools/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "js_binary") + +package(default_visibility = ["//visibility:public"]) + +platform( + name = "windows_x64", + constraint_values = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], +) + +exports_files([ + "package_json_release_filter.jq", +]) + +js_binary( + name = "ng_cli_schema", + data = [ + "ng_cli_schema_generator.js", + ], + entry_point = "ng_cli_schema_generator.js", +) + +js_binary( + name = "quicktype_runner", + data = [ + "quicktype_runner.js", + "//:node_modules/quicktype-core", + ], + entry_point = "quicktype_runner.js", +) diff --git a/tools/bazel/BUILD.bazel b/tools/bazel/BUILD.bazel new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tools/bazel/npm_package.bzl b/tools/bazel/npm_package.bzl new file mode 100644 index 000000000000..3e2843f37946 --- /dev/null +++ b/tools/bazel/npm_package.bzl @@ -0,0 +1,125 @@ +load("@aspect_rules_js//npm:defs.bzl", _npm_package = "npm_package") +load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") +load("@bazel_lib//lib:expand_template.bzl", "expand_template") +load("@bazel_lib//lib:utils.bzl", "to_label") +load("@jq.bzl//jq:jq.bzl", "jq") +load("@rules_pkg//:pkg.bzl", "pkg_tar") +load("//tools:link_package_json_to_tarballs.bzl", "link_package_json_to_tarballs") +load("//tools:snapshot_repo_filter.bzl", "SNAPSHOT_REPO_JQ_FILTER") +load("//tools:substitutions.bzl", "substitutions") + +def npm_package( + name, + deps = [], + visibility = None, + pkg_deps = [], + stamp_files = [], + pkg_json = "package.json", + extra_substitutions = {}, + replace_prefixes = {}, + **kwargs): + if name != "pkg": + fail("Expected npm_package to be named `pkg`. " + + "This is needed for pnpm workspace integration.") + + # Merge package.json with root package.json and perform various substitutions to + # prepare it for release. For jq docs, see https://stedolan.github.io/jq/manual/. + jq( + name = "basic_substitutions", + # Note: this jq filter relies on the order of the inputs + # buildifier: do not sort + srcs = ["//:package.json", pkg_json], + filter_file = "//tools:package_json_release_filter.jq", + args = ["--slurp"], + out = "substituted/package.json", + ) + + # Copy package.json files to bazel-out so we can use their bazel-out paths to determine + # the corresponding package npm package tgz path for substitutions. + copy_to_bin( + name = "package_json_copy", + srcs = [pkg_json], + ) + pkg_deps_copies = [] + for pkg_dep in pkg_deps: + pkg_label = to_label(pkg_dep) + if pkg_label.name != "package.json": + fail("ERROR: only package.json files allowed in pkg_deps of pkg_npm macro") + pkg_deps_copies.append("@@%s//%s:package_json_copy" % (pkg_label.repo_name, pkg_label.package)) + + # Substitute dependencies on other packages in this repo with tarballs. + link_package_json_to_tarballs( + name = "tar_substitutions", + src = "substituted/package.json", + pkg_deps = [":package_json_copy"] + pkg_deps_copies, + out = "substituted_with_tars/package.json", + ) + + # Substitute dependencies on other packages in this repo with snapshot repos. + jq( + name = "snapshot_repo_substitutions", + srcs = ["substituted/package.json"], + filter = SNAPSHOT_REPO_JQ_FILTER, + out = "substituted_with_snapshot_repos/package.json", + ) + + nostamp_subs = dict(substitutions["nostamp"], **extra_substitutions) + stamp_subs = dict(substitutions["stamp"], **extra_substitutions) + + expand_template( + name = "final_package_json", + template = select({ + # Do local tar substitution if config_setting is true. + "//:package_json_use_tar_deps": "substituted_with_tars/package.json", + # Do snapshot repo substitution if config_setting is true. + "//:package_json_use_snapshot_repo_deps": "substituted_with_snapshot_repos/package.json", + "//conditions:default": "substituted/package.json", + }), + out = "substituted_final/package.json", + substitutions = nostamp_subs, + stamp_substitutions = stamp_subs, + ) + + stamp_targets = [] + for f in stamp_files: + expand_template( + name = "stamp_file_%s" % f, + template = f, + out = "substituted/%s" % f, + substitutions = nostamp_subs, + stamp_substitutions = stamp_subs, + ) + + stamp_targets.append("stamp_file_%s" % f) + + _npm_package( + name = "npm_package", + visibility = visibility, + # Note: Order matters here! Last file takes precedence after replaced prefixes. + srcs = deps + stamp_targets + [":final_package_json"], + replace_prefixes = dict({ + "substituted_final/": "", + "substituted_with_tars/": "", + "substituted_with_snapshot_repos/": "", + "substituted/": "", + }, **replace_prefixes), + allow_overwrites = True, + **kwargs + ) + + # Note: For now, in hybrid mode with RNJS and RJS, we ensure + # both `:pkg` and `:npm_package` work. + native.alias( + name = "pkg", + actual = ":npm_package", + ) + + if pkg_json: + pkg_tar( + name = "npm_package_archive", + srcs = [":pkg"], + extension = "tgz", + # should not be built unless it is a dependency of another rule + tags = ["manual"], + visibility = visibility, + ) diff --git a/tools/defaults.bzl b/tools/defaults.bzl new file mode 100644 index 000000000000..20153614ac80 --- /dev/null +++ b/tools/defaults.bzl @@ -0,0 +1,98 @@ +load("@aspect_rules_jasmine//jasmine:defs.bzl", _jasmine_test = "jasmine_test") +load("@aspect_rules_js//js:defs.bzl", _js_binary = "js_binary") +load("@bazel_lib//lib:copy_to_bin.bzl", _copy_to_bin = "copy_to_bin") +load("@devinfra//bazel/ts_project:index.bzl", "strict_deps_test") +load("@rules_angular//src/ng_package:index.bzl", _ng_package = "ng_package") +load("@rules_angular//src/ts_project:index.bzl", _ts_project = "ts_project") +load("//tools:substitutions.bzl", "substitutions") +load("//tools/bazel:npm_package.bzl", _npm_package = "npm_package") + +def ts_project( + name, + deps = [], + tsconfig = None, + testonly = False, + source_map = True, + visibility = None, + **kwargs): + if tsconfig == None: + tsconfig = "//:test-tsconfig" if testonly else "//:build-tsconfig" + + package_json = kwargs.pop("package_json", None) + if not package_json and native.glob(["package.json"], allow_empty = True): + package_json = "package.json" + + _ts_project( + name = name, + testonly = testonly, + declaration = True, + source_map = source_map, + tsconfig = tsconfig, + visibility = visibility, + deps = deps, + **kwargs + ) + + strict_deps_test( + name = "%s_strict_deps_test" % name, + srcs = kwargs.get("srcs", []), + data = kwargs.get("data", []), + package_json = package_json, + tsconfig = tsconfig, + deps = deps, + ) + +def npm_package(**kwargs): + _npm_package(**kwargs) + +def copy_to_bin(**kwargs): + _copy_to_bin(**kwargs) + +def js_binary(**kwargs): + _js_binary(**kwargs) + +def ng_package(deps = [], extra_substitutions = {}, **kwargs): + nostamp_subs = dict(substitutions["nostamp"], **extra_substitutions) + stamp_subs = dict(substitutions["stamp"], **extra_substitutions) + + _ng_package( + deps = deps, + license = "//:LICENSE", + substitutions = select({ + "//:stamp": stamp_subs, + "//conditions:default": nostamp_subs, + }), + **kwargs + ) + +def jasmine_test(data = [], args = [], **kwargs): + # Create relative path to root, from current package dir. Necessary as + # we change the `chdir` below to the package directory. + relative_to_root = "/".join([".."] * len(native.package_name().split("/"))) + + # Chromium browser toolchain + env = kwargs.pop("env", {}) + env.update({ + "CHROME_BIN": "$(CHROME-HEADLESS-SHELL)", + "CHROME_PATH": "$(CHROME-HEADLESS-SHELL)", + "CHROMEDRIVER_BIN": "$(CHROMEDRIVER)", + }) + toolchains = kwargs.pop("toolchains", []) + toolchains = toolchains + ["@rules_browsers//browsers/chromium:toolchain_alias"] + data = data + ["@rules_browsers//browsers/chromium"] + + _jasmine_test( + node_modules = "//:node_modules", + chdir = native.package_name(), + env = env, + toolchains = toolchains, + args = [ + "--require=%s/node_modules/source-map-support/register.js" % relative_to_root, + # Escape so that the `js_binary` launcher triggers Bash expansion. + "'**/*+(.|_)spec.js'", + "'**/*+(.|_)spec.mjs'", + "'**/*+(.|_)spec.cjs'", + ] + args, + data = data + ["//:node_modules/source-map-support"], + **kwargs + ) diff --git a/tools/link_package_json_to_tarballs.bzl b/tools/link_package_json_to_tarballs.bzl new file mode 100644 index 000000000000..4a31cd194bf7 --- /dev/null +++ b/tools/link_package_json_to_tarballs.bzl @@ -0,0 +1,87 @@ +# Copyright Google LLC. 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.dev/license +load("@bazel_lib//lib:utils.bzl", "to_label") +load("@jq.bzl//jq:jq.bzl", "jq") + +def link_package_json_to_tarballs(name, src, pkg_deps, out): + """Substitute tar paths into a package.json file for the packages it depends on. + + src and pkg_deps must be labels in the bazel-out tree for the derived path to the npm_package_archive.tgz to be correct. + + Args: + name: Name of the rule + src: package.json file to perform substitions on + pkg_deps: package.json files of dependencies to substitute + out: Output package.json file + """ + + src_pkg = to_label(src).package + + # Generate partial jq filters for each dependent package that, when run + # against a package.json file, can replace its dependency with a tar path. + filter_files = [] + for i, pkg_dep in enumerate(pkg_deps): + pkg_dep_name = "%s_%s.name" % (name, i) + pkg_dep_filter = "%s_%s.filter" % (name, i) + jq( + name = "%s_%s_name" % (name, i), + srcs = [pkg_dep], + filter = ".name", + out = pkg_dep_name, + ) + + srcs = [ + pkg_dep_name, + pkg_dep, + ] + + # Add dependent tars as srcs to include them in the dependency graph, except + # for the tar for this package as that would create a circular dependency. + pkg_label = to_label(pkg_dep) + if pkg_label.package != src_pkg: + pkg_tar = "@@%s//%s:npm_package_archive.tgz" % (pkg_label.repo_name, pkg_label.package) + srcs.append(pkg_tar) + + # Deriving the absolute path to the tar in the execroot requries different + # commands depending on whether or not the action is sandboxed. + abs_path_sandbox = "readlink $(execpath {pkg_dep})".format(pkg_dep = pkg_dep) + abs_path_nosandbox = "(cd $$(dirname $(execpath {pkg_dep})) && pwd)".format(pkg_dep = pkg_dep) + + native.genrule( + name = "%s_%s_filter" % (name, i), + srcs = srcs, + cmd = """ + TAR=$$(dirname $$({abs_path_sandbox} || {abs_path_nosandbox}))/npm_package_archive.tgz + PKGNAME=$$(cat $(execpath {pkg_name})) + if [[ "$$TAR" != *bazel-out* ]]; then + echo "ERROR: package.json passed to substitute_tar_deps must be in the output tree. You can use copy_to_bin to copy a source file to the output tree." + exit 1 + fi + echo "(..|objects|select(has($${{PKGNAME}})))[$${{PKGNAME}}] |= \\"file:$${{TAR}}\\"" > $@ + """.format( + pkg_name = pkg_dep_name, + abs_path_sandbox = abs_path_sandbox, + abs_path_nosandbox = abs_path_nosandbox, + ), + outs = [pkg_dep_filter], + ) + filter_files.append(pkg_dep_filter) + + # Combine all of the filter files into a single filter by joining with | + filter = "%s.filter" % name + native.genrule( + name = "%s_filter" % name, + srcs = filter_files, + cmd = "cat $(SRCS) | sed '$$!s#$$# |#' > $@", + outs = [filter], + ) + + # Generate final package.json with tar substitutions using the above filter + jq( + name = name, + srcs = [src], + filter_file = filter, + out = out, + ) diff --git a/tools/ng_cli_schema_generator.bzl b/tools/ng_cli_schema_generator.bzl new file mode 100644 index 000000000000..86d9552dd70c --- /dev/null +++ b/tools/ng_cli_schema_generator.bzl @@ -0,0 +1,13 @@ +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +def cli_json_schema(name, src, out, data = []): + js_run_binary( + name = name, + outs = [out], + tags = ["schema"], + srcs = [src] + data, + tool = "//tools:ng_cli_schema", + progress_message = "Generating CLI interface from %s" % src, + mnemonic = "NgCliJsonSchema", + args = ["$(rootpath %s)" % src, "$(rootpath %s)" % out], + ) diff --git a/tools/ng_cli_schema_generator.js b/tools/ng_cli_schema_generator.js new file mode 100644 index 000000000000..88ea5531c41c --- /dev/null +++ b/tools/ng_cli_schema_generator.js @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +const { readFileSync, writeFileSync, mkdirSync } = require('fs'); +const { resolve, dirname } = require('path'); + +/** + * Generator the Angular CLI workspace schema file. + */ +function generate(inPath, outPath) { + // While on paper we could use quicktype for this. + // Quicktype doesn't handle `patternProperties` and `oneOf` that well. + + const jsonSchema = readFileSync(inPath, 'utf8'); + const nestedDefinitions = {}; + const schemaParsed = JSON.parse(jsonSchema, (key, value) => { + if (key === '$ref' && typeof value === 'string' && !value.startsWith('#')) { + // Resolve $ref and camelize key + const definitionKey = value + .replace(/(\.json|src)/g, '') + .split(/\\|\/|_|-|\./) + .filter((p) => !!p) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + + const nestedSchemaPath = resolve(dirname(inPath), value); + const nestedSchema = readFileSync(nestedSchemaPath, 'utf8'); + const nestedSchemaJson = JSON.parse(nestedSchema, (key, value) => { + switch (key) { + case '$ref': + if (value.startsWith('#/definitions/')) { + return value.replace('#/definitions/', `#/definitions/${definitionKey}/definitions/`); + } else { + throw new Error(`Error while resolving $ref ${value} in ${nestedSchemaPath}.`); + } + case '$id': + case 'id': + case '$schema': + case 'required': + case 'x-prompt': + case 'x-user-analytics': + return undefined; + default: + return value; + } + }); + + nestedDefinitions[definitionKey] = nestedSchemaJson; + + return `#/definitions/${definitionKey}`; + } + + return key === '' + ? { + ...value, + definitions: { + ...value.definitions, + ...nestedDefinitions, + }, + } + : value; + }); + + const buildWorkspaceDirectory = process.env['BUILD_WORKSPACE_DIRECTORY'] || '.'; + outPath = resolve(buildWorkspaceDirectory, outPath); + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync( + outPath, + JSON.stringify( + schemaParsed, + (key, value) => { + if (key === 'x-deprecated') { + // Needed for IDEs, and will be replaced to 'deprecated' later on. This must be a boolean. + // https://json-schema.org/draft/2020-12/json-schema-validation.html#name-deprecated + return !!value; + } + + return value; + }, + 2, + ).replace(/"x-deprecated"/g, '"deprecated"'), + ); +} + +if (require.main === module) { + const argv = process.argv.slice(2); + if (argv.length !== 2) { + console.error('Must include 2 arguments.'); + process.exit(1); + } + + const [inPath, outPath] = argv; + + try { + generate(inPath, outPath); + } catch (error) { + console.error('An error happened:'); + console.error(error); + process.exit(127); + } +} + +exports.generate = generate; diff --git a/tools/package_json_release_filter.jq b/tools/package_json_release_filter.jq new file mode 100644 index 000000000000..4e7c3c639c67 --- /dev/null +++ b/tools/package_json_release_filter.jq @@ -0,0 +1,37 @@ +# Copyright Google LLC. 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.dev/license +# +# This filter combines a subproject package.json with the root package.json +# and performs substitutions to prepare it for release. It should be called +# with the --slurp argument and be passed the root pacakge.json followed by +# the subproject package.json. +# +# See jq docs for filter syntax: https://stedolan.github.io/jq/manual/. + +.[0] as $root +| .[1] as $proj + +# Get the fields from root package.json that should override the project +# package.json, i.e., every field except the following +| ($root + | del(.bin, .description, .dependencies, .name, .main, .peerDependencies, .optionalDependencies, .typings, .version, .private, .workspaces, .resolutions, .scripts, .["ng-update"], .pnpm, .dependenciesMeta, .packageManager) +) as $root_overrides + +# Use the project package.json as a base and override other fields from root +| $proj + $root_overrides + +# Combine keywords from both +| .keywords = ($root.keywords + $proj.keywords | unique) + +# Remove devDependencies +| del(.devDependencies) + +# Add engines; versions substituted via pkg_npm ++ {"engines": {"node": "0.0.0-ENGINES-NODE", "npm": "0.0.0-ENGINES-NPM", "yarn": "0.0.0-ENGINES-YARN"}} + +# Remove all `workspace:` pnpm prefixes. Afterwards we can conveniently rely on +# substitutions from the stamp values. Note that we are doing it this way because +# substitutions can apply to multiple files, and `workspace:` can't be reliably replaced. +| walk(if type == "string" and startswith("workspace:") then sub("workspace:"; "") else . end) diff --git a/tools/publish/package.json b/tools/publish/package.json deleted file mode 100644 index 916b533cb471..000000000000 --- a/tools/publish/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "publish", - "version": "0.0.0", - "description": "Tool for managing releases in the CLI.", - "main": "./src/main.js", - "keywords": [ - "angular", - "cli", - "tool", - "project management", - "publish" - ], - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "angular", - "license": "MIT", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli", - "engines": { - "node": ">= 6.1.0", - "npm": ">= 3.0.0" - }, - "dependencies": { - "@angular/core": "^2.4.0", - "@ngtools/logger": "^1.0.0", - "chalk": "^1.1.3", - "minimist": "^1.2.0" - } -} diff --git a/tools/publish/src/build-schema.ts b/tools/publish/src/build-schema.ts deleted file mode 100644 index 458095fff759..000000000000 --- a/tools/publish/src/build-schema.ts +++ /dev/null @@ -1,29 +0,0 @@ -import * as fs from 'fs'; -import {SchemaClassFactory} from '@ngtools/json-schema'; -import {Logger} from '@ngtools/logger'; - - -export function buildSchema(inFile: string, _logger: Logger): string { - const jsonSchema = JSON.parse(fs.readFileSync(inFile, 'utf-8')); - const SchemaClass = SchemaClassFactory(jsonSchema); - const schemaInstance = new SchemaClass({}); - - return schemaInstance.$$serialize('text/x.dts', 'CliConfig'); -} - - -export function build(args: string[], _opts: any, logger: Logger): void { - const inFile = args[1] as string; - const outFile = args[2] as string; - if (!inFile) { - logger.fatal('Command build-schema needs an input file.'); - return; - } - - const output = require('./build-schema').buildSchema(inFile, logger); - if (outFile) { - fs.writeFileSync(outFile, output, { encoding: 'utf-8' }); - } else { - logger.info(output); - } -} diff --git a/tools/publish/src/build.ts b/tools/publish/src/build.ts deleted file mode 100644 index b06956889e59..000000000000 --- a/tools/publish/src/build.ts +++ /dev/null @@ -1,275 +0,0 @@ -import {Logger} from '@ngtools/logger'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import {buildSchema} from './build-schema'; - -const denodeify = require('denodeify'); -const glob = denodeify(require('glob')); -const tar = require('tar'); -const npmRun = require('npm-run'); - - -const root = path.join(__dirname, '../../..'); -const dist = path.join(root, 'dist'); -const packagesRoot = path.join(root, 'packages'); -const toolsRoot = path.join(root, 'tools'); - - -function copy(from: string, to: string): Promise { - from = path.relative(process.cwd(), from); - to = path.relative(process.cwd(), to); - return new Promise((resolve, reject) => { - const rs = fs.createReadStream(from); - const ws = fs.createWriteStream(to); - - rs.on('error', reject); - ws.on('error', reject); - ws.on('close', resolve); - - rs.pipe(ws); - }); -} - - -function rm(p: string): Promise { - p = path.relative(process.cwd(), p); - return new Promise((resolve, reject) => { - fs.unlink(p, err => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - - -function tarFiles(out: string, dir: string): Promise { - // const files = fs.readdirSync(dir); - return tar.create({ gzip: true, strict: true, portable: true, cwd: dir, file: out }, ['.']); -} - - -function getDeps(pkg: any): any { - const packageJson = require(pkg.packageJson); - return Object.assign({}, packageJson['dependencies'], packageJson['devDependencies']); -} - - -export default function build(packagesToBuild: string[], opts: { local: boolean }, - logger: Logger): Promise { - const { packages, tools } = require('../../../lib/packages'); - - const willBuildEverything = packagesToBuild.length == 0; - if (packagesToBuild.length == 0) { - packagesToBuild = Object.keys(packages).concat(Object.keys(tools)); - } - - // First delete the dist folder. - return Promise.resolve() - .then(() => logger.info('Deleting dist folder...')) - .then(() => { - if (willBuildEverything) { - return fs.remove(dist); - } - }) - .then(() => logger.info('Creating schema.d.ts...')) - .then(() => { - const input = path.join(root, 'packages/@angular/cli/lib/config/schema.json'); - const output = path.join(root, 'packages/@angular/cli/lib/config/schema.d.ts'); - fs.writeFileSync(output, buildSchema(input, logger), { encoding: 'utf-8' }); - }) - .then(() => logger.info('Compiling packages...')) - .then(() => { - const packagesLogger = new Logger('packages', logger); - // Order packages in order of dependency. - // We use bubble sort because we need a full topological sort but adding another dependency - // or implementing a full topo sort would be too much work and I'm lazy. We don't anticipate - // any large number of - const sortedPackages = Object.keys(packages); - let swapped = false; - do { - swapped = false; - for (let i = 0; i < sortedPackages.length - 1; i++) { - for (let j = i + 1; j < sortedPackages.length; j++) { - const a = sortedPackages[i]; - const b = sortedPackages[j]; - - if (Object.keys(getDeps(packages[a])).indexOf(b) != -1) { - // Swap them. - [sortedPackages[i], sortedPackages[i + 1]] - = [sortedPackages[i + 1], sortedPackages[i]]; - swapped = true; - } - } - } - } while (swapped); - - return sortedPackages - .filter(pkgName => packagesToBuild.indexOf(pkgName) != -1) - .reduce((promise, packageName) => { - const pkg = packages[packageName]; - const name = path.relative(packagesRoot, pkg.root); - - return promise.then(() => { - packagesLogger.info(name); - try { - return npmRun.execSync(`tsc -p ${path.relative(process.cwd(), pkg.root)}`); - } catch (err) { - packagesLogger.fatal(`Compilation error.\n${err.stdout}`); - } - }); - }, Promise.resolve()); - }) - .then(() => logger.info('Compiling tools...')) - .then(() => { - const toolsLogger = new Logger('packages', logger); - - return Object.keys(tools) - .filter(toolName => packagesToBuild.indexOf(toolName) != -1) - .reduce((promise, packageName) => { - const pkg = tools[packageName]; - const name = path.relative(toolsRoot, pkg.root); - - return promise.then(() => { - toolsLogger.info(name); - try { - return npmRun.execSync(`tsc -p ${path.relative(process.cwd(), pkg.root)}`); - } catch (err) { - toolsLogger.fatal(`Compilation error.\n${err.stdout}`); - } - }); - }, Promise.resolve()); - }) - .then(() => logger.info('Copying uncompiled resources...')) - .then(() => glob(path.join(packagesRoot, '**/*'), { dot: true })) - .then((files: string[]) => { - logger.info(`Found ${files.length} files...`); - - return files - .map((fileName) => path.relative(packagesRoot, fileName)) - .filter((fileName) => { - if (/^@angular[\\\/]cli[\\\/]blueprints/.test(fileName)) { - return true; - } - if (/\.d\.ts$/.test(fileName)) { - // The last thing we want is d.ts files... - return false; - } - if (/\.spec\.ts$/.test(fileName)) { - // Also spec.ts files... - return false; - } - if (/spec-utils.ts/.test(fileName)) { - // TODO: get rid of this by splitting spec utils in its own package. - return false; - } - if (/\.ts$/.test(fileName)) { - // Verify that it was actually built. - if (!fs.existsSync(path.join(dist, fileName).replace(/ts$/, 'js'))) { - throw new Error(`Source found but compiled file not found: "${fileName}".`); - } - - // Skip all source files, since we have their JS files now. - return false; - } - - // The only remaining file we want to ignore is tsconfig and spec files. - return !(/tsconfig\.json$/.test(fileName)) - && !(/\.spec\./.test(fileName)) - && !(/[\/\\]tests[\/\\]/.test(fileName)); - }) - .map((fileName) => { - const source = path.join(packagesRoot, fileName); - const dest = path.join(dist, fileName); - - if (fs.statSync(source).isDirectory()) { - try { - fs.mkdirSync(dest); - } catch (err) { - if (err.code != 'EEXIST') { - throw err; - } - } - } else { - return copy(source, dest); - } - }) - .reduce((promise, current) => { - return promise.then(() => current); - }, Promise.resolve()); - }) - .then(() => glob(path.join(dist, '**/*.spec.*'))) - .then((specFiles: string[]) => specFiles.filter(fileName => { - return !/[\\\/]@angular[\\\/]cli[\\\/]blueprints/.test(fileName); - })) - .then(specFiles => { - logger.info(`Found ${specFiles.length} spec files...`); - return Promise.all(specFiles.map(rm)); - }) - .then(() => { - // Copy all resources that might have been missed. - const extraFiles = ['CONTRIBUTING.md', 'README.md']; - return Promise.all(extraFiles.map(fileName => { - logger.info(`Copying ${fileName}...`); - return copy(fileName, path.join('dist/@angular/cli', fileName)); - })); - }) - .then(() => { - // Copy LICENSE into all the packages - logger.info('Copying LICENSE...'); - - const licenseLogger = new Logger('license', logger); - return Promise.all(Object.keys(packages).map(pkgName => { - const pkg = packages[pkgName]; - licenseLogger.info(pkgName); - return copy('LICENSE', path.join(pkg.dist, 'LICENSE')); - })); - }) - .then(() => { - if (!opts.local) { - return; - } - - logger.info('Changing dependencies between packages to tar files...'); - logger.warn('================================================='); - logger.warn('= THIS SHOULD NOT BE USED FOR PUBLISHING TO NPM ='); - logger.warn('================================================='); - - Object.keys(packages).forEach(pkgName => { - const pkg = packages[pkgName]; - const json = JSON.parse(fs.readFileSync(pkg.packageJson).toString()); - - if (!json['dependencies']) { - json['dependencies'] = {}; - } - if (!json['devDependencies']) { - json['devDependencies'] = {}; - } - - for (const packageName of Object.keys(packages)) { - if (json['dependencies'].hasOwnProperty(packageName)) { - json['dependencies'][packageName] = packages[packageName].tar; - } else if (json['devDependencies'].hasOwnProperty(packageName)) { - json['devDependencies'][packageName] = packages[packageName].tar; - } - } - - fs.writeFileSync(pkg.distPackageJson, JSON.stringify(json, null, 2)); - }); - }) - .then(() => { - logger.info('Tarring all packages...'); - - const tarLogger = new Logger('license', logger); - return Promise.all(Object.keys(packages).map(pkgName => { - const pkg = packages[pkgName]; - tarLogger.info(`${pkgName} => ${pkg.tar}`); - return tarFiles(pkg.tar, pkg.dist); - })); - }) - .then(() => process.exit(0), (err) => { - logger.fatal(err); - }); -} diff --git a/tools/publish/src/changelog.ts b/tools/publish/src/changelog.ts deleted file mode 100755 index 6bdeb8cbbd92..000000000000 --- a/tools/publish/src/changelog.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Just a small command-line wrapper around the conventional-changelog npm module - * (https://www.npmjs.com/package/conventional-changelog), which also prepends - * changes to CHANGELOG.md. - * - * Appends CHANGELOG.md with the changes between tag and HEAD. - * NOTE: only `fix`, `feat`, `perf` and `revert` commits are used - * see: - * https://github.com/conventional-changelog/conventional-changelog/blob/v0.2.1/presets/angular.js - */ -import {Logger} from '@ngtools/logger'; -import * as fs from 'fs'; - -const cl = require('conventional-changelog'); -const exec = require('child_process').exec; - -const changelogStream = fs.createWriteStream('CHANGELOG-delta.md'); - - -const config = { - preset: 'angular', - releaseCount: 1 -}; - -function prependDelta() { - return exec( - 'cat CHANGELOG-delta.md CHANGELOG.md > CHANGELOG-new.md &&' + - 'mv CHANGELOG-new.md CHANGELOG.md &&' + - 'rm CHANGELOG-delta.md'); -} - - -export default function changelog(args: string[], _opts: any, logger: Logger): void { - if (args.length == 0) { - logger.fatal('publish changelog '); - return; - } - - cl(config, null, {from: args[0]}) - .on('error', function (err: Error) { - logger.error('Failed to generate changelog: ' + err); - }) - .pipe(changelogStream) - .on('close', prependDelta); -} diff --git a/tools/publish/src/main.ts b/tools/publish/src/main.ts deleted file mode 100644 index 0b1b8a5cecad..000000000000 --- a/tools/publish/src/main.ts +++ /dev/null @@ -1,51 +0,0 @@ -import 'reflect-metadata'; - -import {IndentLogger, LogEntry} from '@ngtools/logger'; -import {bold, red, yellow, white} from 'chalk'; -import * as minimist from 'minimist'; - - -import 'rxjs/add/operator/filter'; - - -const argv = minimist(process.argv.slice(2), { - boolean: ['verbose'] -}); - -const rootLogger = new IndentLogger('cling'); - -rootLogger - .filter((entry: LogEntry) => (entry.level != 'debug' || argv['verbose'])) - .subscribe((entry: LogEntry) => { - let color: (s: string) => string = white; - let output = process.stdout; - switch (entry.level) { - case 'info': color = white; break; - case 'warn': color = yellow; break; - case 'error': color = red; output = process.stderr; break; - case 'fatal': color = (x: string) => bold(red(x)); output = process.stderr; break; - } - - output.write(color(entry.message) + '\n'); - }); - -rootLogger - .filter((entry: LogEntry) => entry.level == 'fatal') - .subscribe(() => { - process.stderr.write('A fatal error happened. See details above.'); - process.exit(100); - }); - - -const command = argv._.shift(); -let commandFn: (...args: any[]) => void = null; -switch (command) { - case 'build': commandFn = require('./build').default; break; - case 'build-schema': commandFn = require('./build-schema').default; break; - case 'update-version': commandFn = require('./update-version').default; break; - case 'changelog': commandFn = require('./changelog').default; break; -} - -if (commandFn) { - commandFn(argv._, argv, rootLogger); -} diff --git a/tools/publish/src/update-version.ts b/tools/publish/src/update-version.ts deleted file mode 100644 index daac7625fd35..000000000000 --- a/tools/publish/src/update-version.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {Logger} from '@ngtools/logger'; -import * as fs from 'fs'; -import * as path from 'path'; -import {SemVer} from 'semver'; - -export default function patch(args: string[], opts: any, logger: Logger): void { - const newVersion = args[0]; - const incFn = opts.patch ? (v: SemVer) => v.inc('patch') : (v: SemVer) => v; - - if (!newVersion) { - logger.fatal('Need to pass in a new version.'); - return; - } - - const { packages } = require('../../../lib/packages'); - const rootPackageJson = path.join(__dirname, '../../../package.json'); - - for (const packageName of Object.keys(packages)) { - if (packageName == 'angular-cli') { - // Skip the main package. - continue; - } - - const pkgJson = require(packages[packageName].packageJson); - const version = new SemVer(pkgJson['version']); - const oldVersion = version.toString(); - - pkgJson['version'] = incFn(version).toString(); - fs.writeFileSync(packages[packageName].packageJson, JSON.stringify(pkgJson, null, 2) + '\n'); - - logger.info(`${packageName}: ${oldVersion} => ${pkgJson['version']}`); - } - - // Do the main package. - const angularCliPackageJson = require(rootPackageJson); - angularCliPackageJson['version'] = newVersion; - fs.writeFileSync(rootPackageJson, JSON.stringify(angularCliPackageJson, null, 2) + '\n'); -} diff --git a/tools/publish/tsconfig.json b/tools/publish/tsconfig.json deleted file mode 100644 index 58fa3018924e..000000000000 --- a/tools/publish/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.json", - - "compilerOptions": { - "outDir": "../../dist/tools/publish", - "rootDir": ".", - "baseUrl": "", - "paths": { - "@ngtools/json-schema": [ "../../dist/@ngtools/json-schema/src" ], - "@ngtools/logger": [ "../../dist/@ngtools/logger/src" ] - } - } -} diff --git a/tools/quicktype_runner.js b/tools/quicktype_runner.js new file mode 100644 index 000000000000..be4745f62842 --- /dev/null +++ b/tools/quicktype_runner.js @@ -0,0 +1,179 @@ +/** + * @license + * Copyright Google LLC 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.dev/license + */ + +const fs = require('fs'); +const path = require('path'); +const { + InputData, + JSONSchemaInput, + JSONSchemaStore, + TypeScriptTargetLanguage, + parseJSON, + quicktype, +} = require('quicktype-core'); + +/** + * This file is pure JavaScript because Bazel only support compiling to ES5, while quicktype is + * ES2015. This results in an incompatible call to `super()` in the FetchingJSONSchemaStore + * class as it tries to call JSONSchemaStore's constructor in ES5. + * TODO: move this file to typescript when Bazel supports ES2015 output. + * + * This file wraps around quicktype and can do one of two things; + * + * `node quicktype_runner.js ` + * Reads the in path and outputs the TS file at the out_path. + * + * Using `-` as the out_path will output on STDOUT instead of a file. + */ + +// Header to add to all files. +const header = ` +// 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 ...). + +`; + +// Footer to add to all files. +const footer = ``; + +/** + * The simplest Node JSONSchemaStore implementation we can build which supports our custom protocol. + * Supports reading from ng-cli addresses, valid URLs and files (absolute). + */ +class FetchingJSONSchemaStore extends JSONSchemaStore { + constructor(inPath) { + super(); + this._inPath = inPath; + } + + async fetch(address) { + const URL = require('url'); + const url = URL.parse(address); + let content = null; + if (url.protocol === 'ng-cli:') { + let filePath = path.join(__dirname, '../packages/angular/cli', url.hostname, url.path); + content = fs.readFileSync(filePath, 'utf-8').trim(); + } else if (url.hostname) { + try { + const response = await fetch(address); + content = response.text(); + } catch (e) { + content = null; + } + } + + if (content === null && !path.isAbsolute(address)) { + const resolvedPath = path.join(path.dirname(this._inPath), address); + + // Check relative to inPath + if (fs.existsSync(resolvedPath)) { + content = fs.readFileSync(resolvedPath, 'utf-8'); + } + } + + if (content === null && fs.existsSync(address)) { + content = fs.readFileSync(address, 'utf-8').trim(); + } + + if (content == null) { + return undefined; + } + + content = appendDeprecatedDescription(content); + + return parseJSON(content, 'JSON Schema', address); + } +} + +/** + * Create the TS file from the schema, and overwrite the outPath (or log). + * @param {string} inPath + * @param {string} outPath + */ +async function main(inPath, outPath) { + const content = await generate(inPath); + + if (outPath === '-') { + console.log(content); + process.exit(0); + } + + const buildWorkspaceDirectory = process.env['BUILD_WORKSPACE_DIRECTORY'] || '.'; + outPath = path.resolve(buildWorkspaceDirectory, outPath); + fs.writeFileSync(outPath, content, 'utf-8'); +} + +async function generate(inPath) { + // Best description of how to use the API was found at + // https://blog.quicktype.io/customizing-quicktype/ + const inputData = new InputData(); + const content = fs.readFileSync(inPath, 'utf-8'); + const source = { name: 'Schema', schema: appendDeprecatedDescription(content) }; + + await inputData.addSource('schema', source, () => { + return new JSONSchemaInput(new FetchingJSONSchemaStore(inPath)); + }); + + const lang = new TypeScriptTargetLanguage(); + + const { lines } = await quicktype({ + lang, + inputData, + alphabetizeProperties: true, + rendererOptions: { + 'prefer-types': true, + 'just-types': true, + 'prefer-unions': false, + 'explicit-unions': true, + 'acronym-style': 'camel', + 'prefer-unknown': false, + }, + }); + + return header + lines.join('\n') + footer; +} + +/** + * Converts `x-deprecated` to `@deprecated` comments. + * @param {string} schema + */ +function appendDeprecatedDescription(schema) { + const content = JSON.parse(schema); + const props = content.properties; + + for (const key in props) { + let { description = '', 'x-deprecated': deprecated } = props[key]; + if (!deprecated) { + continue; + } + + description += '\n@deprecated' + (typeof deprecated === 'string' ? ` ${deprecated}` : ''); + props[key].description = description; + } + + return JSON.stringify(content); +} + +if (require.main === module) { + // Parse arguments and run main(). + const argv = process.argv.slice(2); + if (argv.length < 2 || argv.length > 3) { + console.error('Must include 2 or 3 arguments.'); + process.exit(1); + } + + main(argv[0], argv[1]) + .then(() => process.exit(0)) + .catch((err) => { + console.error('An error happened:'); + console.error(err); + process.exit(127); + }); +} + +exports.generate = generate; diff --git a/tools/snapshot_repo_filter.bzl b/tools/snapshot_repo_filter.bzl new file mode 100644 index 000000000000..fbd9672c07a9 --- /dev/null +++ b/tools/snapshot_repo_filter.bzl @@ -0,0 +1,28 @@ +# Copyright Google LLC. 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.dev/license + +load("//:constants.bzl", "SNAPSHOT_REPOS") + +def _generate_snapshot_repo_filter(): + individual_pkg_filters = [] + for pkg_name, snapshot_repo in SNAPSHOT_REPOS.items(): + individual_pkg_filters.append( + """ + . as $root + | [paths(..)] + | [(.[] | select( + contains(["{pkg_name}"]) and + contains(["peerDependenciesMeta"]) != true))] as $paths + | $paths | reduce $paths[] as $path ($root; setpath($path; "github:{snapshot_repo}#BUILD_SCM_HASH-PLACEHOLDER")) | . + """.format( + pkg_name = pkg_name, + snapshot_repo = snapshot_repo, + ), + ) + + return " | ".join(individual_pkg_filters) + +# jq filter that replaces package.json dependencies with snapshot repos +SNAPSHOT_REPO_JQ_FILTER = _generate_snapshot_repo_filter() diff --git a/tools/substitutions.bzl b/tools/substitutions.bzl new file mode 100644 index 000000000000..dc40701971ff --- /dev/null +++ b/tools/substitutions.bzl @@ -0,0 +1,37 @@ +load( + "//:constants.bzl", + "ANGULAR_FW_PEER_DEP", + "ANGULAR_FW_VERSION", + "BASELINE_DATE", + "NG_PACKAGR_PEER_DEP", + "NG_PACKAGR_VERSION", + "RELEASE_ENGINES_NODE", + "RELEASE_ENGINES_NPM", + "RELEASE_ENGINES_YARN", +) + +_stamp_substitutions = { + # Version of the local package being built, generated via the `--workspace_status_command` flag. + "0.0.0-PLACEHOLDER": "{{STABLE_PROJECT_VERSION}}", + "0.0.0-EXPERIMENTAL-PLACEHOLDER": "{{STABLE_PROJECT_EXPERIMENTAL_VERSION}}", + # --- + "BUILD_SCM_HASH-PLACEHOLDER": "{{BUILD_SCM_ABBREV_HASH}}", + "BASELINE-DATE-PLACEHOLDER": BASELINE_DATE, + "0.0.0-ENGINES-NODE": RELEASE_ENGINES_NODE, + "0.0.0-ENGINES-NPM": RELEASE_ENGINES_NPM, + "0.0.0-ENGINES-YARN": RELEASE_ENGINES_YARN, + "0.0.0-NG-PACKAGR-VERSION": NG_PACKAGR_VERSION, + "0.0.0-NG-PACKAGR-PEER-DEP": NG_PACKAGR_PEER_DEP, + "0.0.0-ANGULAR-FW-VERSION": ANGULAR_FW_VERSION, + "0.0.0-ANGULAR-FW-PEER-DEP": ANGULAR_FW_PEER_DEP, +} + +_no_stamp_substitutions = dict(_stamp_substitutions, **{ + "0.0.0-PLACEHOLDER": "0.0.0", + "0.0.0-EXPERIMENTAL-PLACEHOLDER": "0.0.0", +}) + +substitutions = { + "stamp": _stamp_substitutions, + "nostamp": _no_stamp_substitutions, +} diff --git a/tools/test/BUILD.bazel b/tools/test/BUILD.bazel new file mode 100644 index 000000000000..71ef3e3c2794 --- /dev/null +++ b/tools/test/BUILD.bazel @@ -0,0 +1,32 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@jq.bzl//jq:jq.bzl", "jq") + +jq( + name = "final_package_json", + # This jq filter relies on the order of the inputs + # buildifier: do not sort + srcs = [ + "root_package.json", + "project_package.json", + ], + args = [ + "--slurp", + ], + filter_file = "//tools:package_json_release_filter.jq", +) + +# jq outputs CR on windows https://github.com/stedolan/jq/issues/92 +# strip the CRs to do a correct comparison on all platforms +genrule( + name = "final_package_json_cr_stripped", + srcs = [":final_package_json"], + outs = ["final_package_json_cr_stripped.json"], + cmd = "cat $(execpath :final_package_json) | sed \"s#\\r##\" > $@", +) + +# Test correctness of the filter that prepares each project's package.json file for release +diff_test( + name = "package_json_filter_test", + file1 = "expected_package.json", + file2 = ":final_package_json_cr_stripped", +) diff --git a/tools/test/expected_package.json b/tools/test/expected_package.json new file mode 100644 index 000000000000..6630c9062f8a --- /dev/null +++ b/tools/test/expected_package.json @@ -0,0 +1,42 @@ +{ + "name": "project", + "version": "0.0.0-SNAPSHOT", + "description": "Project package.json", + "main": "project/index.js", + "bin": { + "projectfoo": "./bin/project-foo.js" + }, + "keywords": [ + "a", + "b", + "c" + ], + "scripts": { + "build": "node project-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "@project/foo": "1.0.0", + "@project/bar": "2.0.0" + }, + "ng-update": { + "migrations": "@project/migration-collection.json", + "packageGroup": { + "@project/abc": "0.0.0" + } + }, + "engines": { + "node": "0.0.0-ENGINES-NODE", + "npm": "0.0.0-ENGINES-NPM", + "yarn": "0.0.0-ENGINES-YARN" + } +} diff --git a/tools/test/project_package.json b/tools/test/project_package.json new file mode 100644 index 000000000000..894c9f4cf3f2 --- /dev/null +++ b/tools/test/project_package.json @@ -0,0 +1,39 @@ +{ + "name": "project", + "version": "0.0.0-SNAPSHOT", + "description": "Project package.json", + "main": "project/index.js", + "bin": { + "projectfoo": "./bin/project-foo.js" + }, + "keywords": [ + "b", + "c" + ], + "scripts": { + "build": "node project-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "@project/foo": "1.0.0", + "@project/bar": "2.0.0" + }, + "devDependencies": { + "@project/devdep": "1.2.3" + }, + "ng-update": { + "migrations": "@project/migration-collection.json", + "packageGroup": { + "@project/abc": "0.0.0" + } + } +} diff --git a/tools/test/root_package.json b/tools/test/root_package.json new file mode 100644 index 000000000000..e0b263aef042 --- /dev/null +++ b/tools/test/root_package.json @@ -0,0 +1,37 @@ +{ + "name": "root", + "version": "1.0.0-next.1", + "private": true, + "description": "Root package.json", + "bin": { + "root-foo": "./bin/root-foo.js", + "root-bar": "./bin/root-bar.js" + }, + "keywords": [ + "a", + "b" + ], + "scripts": { + "build": "node root-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "workspaces": { + "packages": ["packages/root/foo/*", "packages/root/bar/*"] + }, + "resolutions": { + "root/foo/bar": "1.0.0" + }, + "devDependencies": { + "@root/foo": "1.0.0", + "@root/bar": "2.0.0" + } +} diff --git a/tools/toolchain_info.bzl b/tools/toolchain_info.bzl new file mode 100644 index 000000000000..b920c8a8f88a --- /dev/null +++ b/tools/toolchain_info.bzl @@ -0,0 +1,31 @@ +# look at the toolchains registered in the workspace file with nodejs_register_toolchains + +# the name can be anything the user wants this is just added to the target to create unique names +# the order will match against the order in the TOOLCHAIN_VERSION list. +TOOLCHAINS_NAMES = [ + "node22", + "node24", + "node26", +] + +# this is the list of toolchains that should be used and are registered with nodejs_register_toolchains in the WORKSPACE file +TOOLCHAINS_VERSIONS = [ + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node22_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node22_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node22_windows_amd64//:node_toolchain", + }), + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node24_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node24_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node24_windows_amd64//:node_toolchain", + }), + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node26_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node26_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node26_windows_amd64//:node_toolchain", + }), +] + +# A default toolchain for use when only one is necessary +DEFAULT_TOOLCHAIN_VERSION = TOOLCHAINS_VERSIONS[len(TOOLCHAINS_VERSIONS) - 1] diff --git a/tools/toolchains/BUILD.bazel b/tools/toolchains/BUILD.bazel new file mode 100644 index 000000000000..09bb94865046 --- /dev/null +++ b/tools/toolchains/BUILD.bazel @@ -0,0 +1,32 @@ +load("@rules_cc//cc:defs.bzl", "cc_toolchain") +load(":dummy_cc_toolchain.bzl", "dummy_cc_toolchain_config") + +# This defines a dummy C++ toolchain for Windows. +# Without this, the build fails with "Unable to find a CC toolchain using toolchain resolution". +dummy_cc_toolchain_config(name = "dummy_cc_toolchain_config") + +filegroup(name = "empty") + +cc_toolchain( + name = "dummy_cc_toolchain", + all_files = ":empty", + compiler_files = ":empty", + dwp_files = ":empty", + linker_files = ":empty", + objcopy_files = ":empty", + strip_files = ":empty", + supports_param_files = 0, + toolchain_config = ":dummy_cc_toolchain_config", + toolchain_identifier = "dummy_cc_toolchain", +) + +toolchain( + name = "dummy_cc_windows_no_exec_toolchain", + exec_compatible_with = [], + target_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], + toolchain = ":dummy_cc_toolchain", + toolchain_type = "@rules_cc//cc:toolchain_type", +) diff --git a/tools/toolchains/dummy_cc_toolchain.bzl b/tools/toolchains/dummy_cc_toolchain.bzl new file mode 100644 index 000000000000..a18a9c780915 --- /dev/null +++ b/tools/toolchains/dummy_cc_toolchain.bzl @@ -0,0 +1,28 @@ +""" +This file defines a dummy C++ toolchain for Windows. +It is needed to satisfy Bazel's toolchain resolution when cross-compiling for Windows on Linux. +Some rules (e.g. rules_nodejs, js_test) or their dependencies may trigger C++ toolchain resolution +even if no actual C++ compilation is performed for the target platform. +Without this, the build fails with "Unable to find a CC toolchain using toolchain resolution". +""" + +load("@rules_cc//cc:defs.bzl", "cc_common") + +def _impl(ctx): + return cc_common.create_cc_toolchain_config_info( + ctx = ctx, + toolchain_identifier = "dummy-toolchain", + host_system_name = "local", + target_system_name = "local", + target_cpu = "x64_windows", + target_libc = "unknown", + compiler = "dummy", + abi_version = "unknown", + abi_libc_version = "unknown", + ) + +dummy_cc_toolchain_config = rule( + implementation = _impl, + attrs = {}, + provides = [CcToolchainConfigInfo], +) diff --git a/tools/ts_json_schema.bzl b/tools/ts_json_schema.bzl new file mode 100644 index 000000000000..dab651d0d7e0 --- /dev/null +++ b/tools/ts_json_schema.bzl @@ -0,0 +1,20 @@ +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +# Generates a TS file that contains the interface for a JSON Schema file. Takes a single `src` +# argument as input, an optional data field for reference files, and produces a +# _ts_json_schema_interface() rule containing the typescript interface. +# The file produced will have the same name, with the extension replaced from `.json` to `.ts`. +# Any filename collision will be an error thrown by Bazel. +def ts_json_schema(name, src, data = []): + out = src.replace(".json", ".ts") + + js_run_binary( + name = name + ".interface", + outs = [out], + srcs = [src] + data, + tags = ["schema"], + tool = "//tools:quicktype_runner", + progress_message = "Generating TS interface for %s" % src, + mnemonic = "TsJsonSchema", + args = ["$(rootpath %s)" % src, "$(rootpath %s)" % out], + ) diff --git a/tsconfig-build-esm.json b/tsconfig-build-esm.json new file mode 100644 index 000000000000..d70bbf8b2568 --- /dev/null +++ b/tsconfig-build-esm.json @@ -0,0 +1,19 @@ +/** + * Root tsconfig file for use building all Angular packages. Note there is no rootDir + * and therefore any tsconfig in the package directory will need to define its own + * rootDir. + */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "target": "es2022", + "lib": ["es2022"], + "moduleResolution": "bundler", + // don't auto-discover @types/node, it results in a ///=2.2.7 <3" - -abbrev@1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f" - -accepts@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca" - dependencies: - mime-types "~2.1.11" - negotiator "0.6.1" - -acorn-dynamic-import@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4" - dependencies: - acorn "^4.0.3" - -acorn-jsx@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b" - dependencies: - acorn "^3.0.4" - -acorn@^3.0.4: - version "3.3.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" - -acorn@^4.0.3: - version "4.0.13" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" - -acorn@^5.0.0, acorn@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.1.tgz#53fe161111f912ab999ee887a90a0bc52822fd75" - -ajv-keywords@^1.0.0: - version "1.5.1" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c" - -ajv-keywords@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0" - -ajv@^4.7.0, ajv@^4.9.1: - version "4.11.8" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536" - dependencies: - co "^4.6.0" - json-stable-stringify "^1.0.1" - -ajv@^5.0.0, ajv@^5.1.5: - version "5.2.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39" - dependencies: - co "^4.6.0" - fast-deep-equal "^1.0.0" - json-schema-traverse "^0.3.0" - json-stable-stringify "^1.0.1" - -align-text@^0.1.1, align-text@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" - dependencies: - kind-of "^3.0.2" - longest "^1.0.1" - repeat-string "^1.5.2" - -alphanum-sort@^1.0.1, alphanum-sort@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" - -amdefine@>=0.0.4: - version "1.0.1" - resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" - -ansi-escapes@^1.1.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - -ansi-escapes@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b" - -ansi-html@0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - -ansi-styles@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88" - dependencies: - color-convert "^1.9.0" - -anymatch@^1.3.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" - dependencies: - micromatch "^2.1.5" - normalize-path "^2.0.0" - -aproba@^1.0.3: - version "1.1.2" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.1.2.tgz#45c6629094de4e96f693ef7eab74ae079c240fc1" - -are-we-there-yet@~1.1.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz#bb5dca382bb94f05e15194373d16fd3ba1ca110d" - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - -argparse@^1.0.7: - version "1.0.9" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86" - dependencies: - sprintf-js "~1.0.2" - -arr-diff@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" - dependencies: - arr-flatten "^1.0.1" - -arr-flatten@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" - -array-filter@~0.0.0: - version "0.0.1" - resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec" - -array-find-index@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - -array-flatten@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296" - -array-ify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" - -array-map@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662" - -array-reduce@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b" - -array-union@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" - dependencies: - array-uniq "^1.0.1" - -array-uniq@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" - -array-unique@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" - -arrify@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - -asap@~2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - -asn1.js@^4.0.0: - version "4.9.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40" - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -asn1@~0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - -assert-plus@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-0.2.0.tgz#d74e1b87e7affc0db8aadb7021f3fe48101ab234" - -assert@^1.1.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" - dependencies: - util "0.10.3" - -async-each@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" - -async-foreach@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" - -async@^0.9.0: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - -async@^1.4.0, async@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" - -async@^2.1.2, async@^2.1.5, async@^2.4.1: - version "2.5.0" - resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d" - dependencies: - lodash "^4.14.0" - -async@~0.2.6: - version "0.2.10" - resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - -autoprefixer@^6.3.1, autoprefixer@^6.5.3: - version "6.7.7" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014" - dependencies: - browserslist "^1.7.6" - caniuse-db "^1.0.30000634" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^5.2.16" - postcss-value-parser "^3.2.3" - -aws-sign2@~0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" - -aws4@^1.2.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" - -babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" - dependencies: - chalk "^1.1.3" - esutils "^2.0.2" - js-tokens "^3.0.2" - -babel-generator@^6.18.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5" - dependencies: - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - detect-indent "^4.0.0" - jsesc "^1.3.0" - lodash "^4.17.4" - source-map "^0.5.6" - trim-right "^1.0.1" - -babel-messages@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" - dependencies: - babel-runtime "^6.22.0" - -babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - -babel-template@^6.16.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" - dependencies: - babel-runtime "^6.26.0" - babel-traverse "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - lodash "^4.17.4" - -babel-traverse@^6.18.0, babel-traverse@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" - dependencies: - babel-code-frame "^6.26.0" - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - debug "^2.6.8" - globals "^9.18.0" - invariant "^2.2.2" - lodash "^4.17.4" - -babel-types@^6.18.0, babel-types@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" - dependencies: - babel-runtime "^6.26.0" - esutils "^2.0.2" - lodash "^4.17.4" - to-fast-properties "^1.0.3" - -babylon@^6.17.4, babylon@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" - -balanced-match@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - -base64-js@^1.0.2: - version "1.2.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886" - -batch@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" - -bcrypt-pbkdf@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" - dependencies: - tweetnacl "^0.14.3" - -big.js@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.1.3.tgz#4cada2193652eb3ca9ec8e55c9015669c9806978" - -binary-extensions@^1.0.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0" - -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - dependencies: - inherits "~2.0.0" - -bluebird@^3.4.7: - version "3.5.0" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" - -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: - version "4.11.8" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" - -bonjour@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" - dependencies: - array-flatten "^2.1.0" - deep-equal "^1.0.1" - dns-equal "^1.0.0" - dns-txt "^2.0.2" - multicast-dns "^6.0.1" - multicast-dns-service-types "^1.1.0" - -boolbase@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - -boom@2.x.x: - version "2.10.1" - resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" - dependencies: - hoek "2.x.x" - -brace-expansion@^1.1.7: - version "1.1.8" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^1.8.2: - version "1.8.5" - resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" - dependencies: - expand-range "^1.8.1" - preserve "^0.2.0" - repeat-element "^1.1.2" - -brorand@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - -browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.6.tgz#5e7725dbdef1fd5930d4ebab48567ce451c48a0a" - dependencies: - buffer-xor "^1.0.2" - cipher-base "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.0" - inherits "^2.0.1" - -browserify-cipher@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.0.tgz#9988244874bf5ed4e28da95666dcd66ac8fc363a" - dependencies: - browserify-aes "^1.0.4" - browserify-des "^1.0.0" - evp_bytestokey "^1.0.0" - -browserify-des@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.0.tgz#daa277717470922ed2fe18594118a175439721dd" - dependencies: - cipher-base "^1.0.1" - des.js "^1.0.0" - inherits "^2.0.1" - -browserify-rsa@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - dependencies: - bn.js "^4.1.0" - randombytes "^2.0.1" - -browserify-sign@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" - dependencies: - bn.js "^4.1.1" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.2" - elliptic "^6.0.0" - inherits "^2.0.1" - parse-asn1 "^5.0.0" - -browserify-zlib@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" - dependencies: - pako "~0.2.0" - -browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6: - version "1.7.7" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9" - dependencies: - caniuse-db "^1.0.30000639" - electron-to-chromium "^1.2.7" - -buffer-indexof@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.0.tgz#f54f647c4f4e25228baa656a2e57e43d5f270982" - -buffer-xor@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" - -buffer@^4.3.0: - version "4.9.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" - dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" - isarray "^1.0.0" - -builtin-modules@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" - -builtin-status-codes@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" - -bytes@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.5.0.tgz#4c9423ea2d252c270c41b2bdefeff9bb6b62c06a" - -caller-path@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" - dependencies: - callsites "^0.2.0" - -callsites@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" - -camel-case@3.0.x: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" - dependencies: - no-case "^2.2.0" - upper-case "^1.1.1" - -camelcase-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" - dependencies: - camelcase "^2.0.0" - map-obj "^1.0.0" - -camelcase@^1.0.2: - version "1.2.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" - -camelcase@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" - -camelcase@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" - -camelcase@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" - -caniuse-api@^1.5.2: - version "1.6.1" - resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c" - dependencies: - browserslist "^1.3.6" - caniuse-db "^1.0.30000529" - lodash.memoize "^4.1.2" - lodash.uniq "^4.5.0" - -caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: - version "1.0.30000715" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000715.tgz#0b9b5c795950dfbaf301a8806bafe87f126da8ca" - -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - -center-align@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" - dependencies: - align-text "^0.1.3" - lazy-cache "^1.0.3" - -chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e" - dependencies: - ansi-styles "^3.1.0" - escape-string-regexp "^1.0.5" - supports-color "^4.0.0" - -chokidar@^1.6.0, chokidar@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" - dependencies: - anymatch "^1.3.0" - async-each "^1.0.0" - glob-parent "^2.0.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^2.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - optionalDependencies: - fsevents "^1.0.0" - -chownr@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" - -cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -circular-dependency-plugin@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-3.0.0.tgz#9b68692e35b0e3510998d0164b6ae5011bea5760" - -circular-json@^0.3.1: - version "0.3.3" - resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" - -clap@^1.0.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.0.tgz#59c90fe3e137104746ff19469a27a634ff68c857" - dependencies: - chalk "^1.1.3" - -clean-css@4.1.x: - version "4.1.7" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.1.7.tgz#b9aea4f85679889cf3eae8b40349ec4ebdfdd032" - dependencies: - source-map "0.5.x" - -cli-cursor@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" - dependencies: - restore-cursor "^1.0.1" - -cli-cursor@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" - dependencies: - restore-cursor "^2.0.0" - -cli-width@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" - -cliui@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" - dependencies: - center-align "^0.1.1" - right-align "^0.1.1" - wordwrap "0.0.2" - -cliui@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi "^2.0.0" - -clone-deep@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.3.0.tgz#348c61ae9cdbe0edfe053d91ff4cc521d790ede8" - dependencies: - for-own "^1.0.0" - is-plain-object "^2.0.1" - kind-of "^3.2.2" - shallow-clone "^0.1.2" - -clone@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" - -clone@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb" - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - -coa@~1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd" - dependencies: - q "^1.1.2" - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - -color-convert@^1.3.0, color-convert@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" - dependencies: - color-name "^1.1.1" - -color-name@^1.0.0, color-name@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - -color-string@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" - dependencies: - color-name "^1.0.0" - -color@^0.11.0: - version "0.11.4" - resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" - dependencies: - clone "^1.0.2" - color-convert "^1.3.0" - color-string "^0.3.0" - -colormin@^1.0.5: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" - dependencies: - color "^0.11.0" - css-color-names "0.0.4" - has "^1.0.1" - -colors@1.1.2, colors@^1.1.2, colors@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" - -combined-stream@^1.0.5, combined-stream@~1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" - dependencies: - delayed-stream "~1.0.0" - -commander@2.11.x, commander@^2.11.0, commander@^2.9.0, commander@~2.11.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" - -common-tags@^1.3.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.4.0.tgz#1187be4f3d4cf0c0427d43f74eef1f73501614c0" - dependencies: - babel-runtime "^6.18.0" - -compare-func@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-1.3.2.tgz#99dd0ba457e1f9bc722b12c08ec33eeab31fa648" - dependencies: - array-ify "^1.0.0" - dot-prop "^3.0.0" - -compressible@~2.0.10: - version "2.0.11" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.11.tgz#16718a75de283ed8e604041625a2064586797d8a" - dependencies: - mime-db ">= 1.29.0 < 2" - -compression@^1.5.2: - version "1.7.0" - resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.0.tgz#030c9f198f1643a057d776a738e922da4373012d" - dependencies: - accepts "~1.3.3" - bytes "2.5.0" - compressible "~2.0.10" - debug "2.6.8" - on-headers "~1.0.1" - safe-buffer "5.1.1" - vary "~1.1.1" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - -concat-stream@^1.5.2: - version "1.6.0" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" - dependencies: - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -connect-history-api-fallback@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.3.0.tgz#e51d17f8f0ef0db90a64fdb47de3051556e9f169" - -console-browserify@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" - dependencies: - date-now "^0.1.4" - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - -constants-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" - -content-disposition@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" - -content-type@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" - -conventional-changelog-angular@^1.3.4: - version "1.4.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-1.4.0.tgz#118b9f7d41a3d99500bfb6bea1f3525e055e8b9b" - dependencies: - compare-func "^1.3.1" - github-url-from-git "^1.4.0" - q "^1.4.1" - read-pkg-up "^2.0.0" - -conventional-changelog-atom@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/conventional-changelog-atom/-/conventional-changelog-atom-0.1.1.tgz#d40a9b297961b53c745e5d1718fd1a3379f6a92f" - dependencies: - q "^1.4.1" - -conventional-changelog-codemirror@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-codemirror/-/conventional-changelog-codemirror-0.1.0.tgz#7577a591dbf9b538e7a150a7ee62f65a2872b334" - dependencies: - q "^1.4.1" - -conventional-changelog-core@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-core/-/conventional-changelog-core-1.9.0.tgz#de5dfbc091847656508d4a389e35c9a1bc49e7f4" - dependencies: - conventional-changelog-writer "^1.1.0" - conventional-commits-parser "^1.0.0" - dateformat "^1.0.12" - get-pkg-repo "^1.0.0" - git-raw-commits "^1.2.0" - git-remote-origin-url "^2.0.0" - git-semver-tags "^1.2.0" - lodash "^4.0.0" - normalize-package-data "^2.3.5" - q "^1.4.1" - read-pkg "^1.1.0" - read-pkg-up "^1.0.1" - through2 "^2.0.0" - -conventional-changelog-ember@^0.2.6: - version "0.2.6" - resolved "https://registry.yarnpkg.com/conventional-changelog-ember/-/conventional-changelog-ember-0.2.6.tgz#8b7355419f5127493c4c562473ab2fc792f1c2b6" - dependencies: - q "^1.4.1" - -conventional-changelog-eslint@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-eslint/-/conventional-changelog-eslint-0.1.0.tgz#a52411e999e0501ce500b856b0a643d0330907e2" - dependencies: - q "^1.4.1" - -conventional-changelog-express@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-express/-/conventional-changelog-express-0.1.0.tgz#55c6c841c811962036c037bdbd964a54ae310fce" - dependencies: - q "^1.4.1" - -conventional-changelog-jquery@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jquery/-/conventional-changelog-jquery-0.1.0.tgz#0208397162e3846986e71273b6c79c5b5f80f510" - dependencies: - q "^1.4.1" - -conventional-changelog-jscs@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jscs/-/conventional-changelog-jscs-0.1.0.tgz#0479eb443cc7d72c58bf0bcf0ef1d444a92f0e5c" - dependencies: - q "^1.4.1" - -conventional-changelog-jshint@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jshint/-/conventional-changelog-jshint-0.1.0.tgz#00cab8e9a3317487abd94c4d84671342918d2a07" - dependencies: - compare-func "^1.3.1" - q "^1.4.1" - -conventional-changelog-writer@^1.1.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/conventional-changelog-writer/-/conventional-changelog-writer-1.4.1.tgz#3f4cb4d003ebb56989d30d345893b52a43639c8e" - dependencies: - compare-func "^1.3.1" - conventional-commits-filter "^1.0.0" - dateformat "^1.0.11" - handlebars "^4.0.2" - json-stringify-safe "^5.0.1" - lodash "^4.0.0" - meow "^3.3.0" - semver "^5.0.1" - split "^1.0.0" - through2 "^2.0.0" - -conventional-changelog@^1.1.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/conventional-changelog/-/conventional-changelog-1.1.4.tgz#108bc750c2a317e200e2f9b413caaa1f8c7efa3b" - dependencies: - conventional-changelog-angular "^1.3.4" - conventional-changelog-atom "^0.1.0" - conventional-changelog-codemirror "^0.1.0" - conventional-changelog-core "^1.9.0" - conventional-changelog-ember "^0.2.6" - conventional-changelog-eslint "^0.1.0" - conventional-changelog-express "^0.1.0" - conventional-changelog-jquery "^0.1.0" - conventional-changelog-jscs "^0.1.0" - conventional-changelog-jshint "^0.1.0" - -conventional-commits-filter@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/conventional-commits-filter/-/conventional-commits-filter-1.0.0.tgz#6fc2a659372bc3f2339cf9ffff7e1b0344b93039" - dependencies: - is-subset "^0.1.1" - modify-values "^1.0.0" - -conventional-commits-parser@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-1.3.0.tgz#e327b53194e1a7ad5dc63479ee9099a52b024865" - dependencies: - JSONStream "^1.0.4" - is-text-path "^1.0.0" - lodash "^4.2.1" - meow "^3.3.0" - split2 "^2.0.0" - through2 "^2.0.0" - trim-off-newlines "^1.0.0" - -convert-source-map@^1.3.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - -cookie@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - -core-js@^2.4.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.0.tgz#569c050918be6486b3837552028ae0466b717086" - -core-object@^3.1.0: - version "3.1.4" - resolved "https://registry.yarnpkg.com/core-object/-/core-object-3.1.4.tgz#6df401e858124be9f7572f4c4a34c6150952d4b6" - dependencies: - chalk "^2.0.0" - -core-util-is@1.0.2, core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - -cosmiconfig@^2.1.0, cosmiconfig@^2.1.1: - version "2.2.2" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-2.2.2.tgz#6173cebd56fac042c1f4390edf7af6c07c7cb892" - dependencies: - is-directory "^0.3.1" - js-yaml "^3.4.3" - minimist "^1.2.0" - object-assign "^4.1.0" - os-homedir "^1.0.1" - parse-json "^2.2.0" - require-from-string "^1.1.0" - -create-ecdh@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" - dependencies: - bn.js "^4.1.0" - elliptic "^6.0.0" - -create-hash@^1.1.0, create-hash@^1.1.1, create-hash@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd" - dependencies: - cipher-base "^1.0.1" - inherits "^2.0.1" - ripemd160 "^2.0.0" - sha.js "^2.4.0" - -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: - version "1.1.6" - resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06" - dependencies: - cipher-base "^1.0.3" - create-hash "^1.1.0" - inherits "^2.0.1" - ripemd160 "^2.0.0" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -cross-spawn@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - -cross-spawn@^5.0.1, cross-spawn@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - dependencies: - lru-cache "^4.0.1" - shebang-command "^1.2.0" - which "^1.2.9" - -cryptiles@2.x.x: - version "2.0.5" - resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" - dependencies: - boom "2.x.x" - -crypto-browserify@^3.11.0: - version "3.11.1" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f" - dependencies: - browserify-cipher "^1.0.0" - browserify-sign "^4.0.0" - create-ecdh "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.0" - diffie-hellman "^5.0.0" - inherits "^2.0.1" - pbkdf2 "^3.0.3" - public-encrypt "^4.0.0" - randombytes "^2.0.0" - -css-color-names@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" - -css-loader@^0.28.1: - version "0.28.5" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.5.tgz#dd02bb91b94545710212ef7f6aaa66663113d754" - dependencies: - babel-code-frame "^6.11.0" - css-selector-tokenizer "^0.7.0" - cssnano ">=2.6.1 <4" - icss-utils "^2.1.0" - loader-utils "^1.0.2" - lodash.camelcase "^4.3.0" - object-assign "^4.0.1" - postcss "^5.0.6" - postcss-modules-extract-imports "^1.0.0" - postcss-modules-local-by-default "^1.0.1" - postcss-modules-scope "^1.0.0" - postcss-modules-values "^1.1.0" - postcss-value-parser "^3.3.0" - source-list-map "^2.0.0" - -css-parse@1.7.x: - version "1.7.0" - resolved "https://registry.yarnpkg.com/css-parse/-/css-parse-1.7.0.tgz#321f6cf73782a6ff751111390fc05e2c657d8c9b" - -css-select@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" - dependencies: - boolbase "~1.0.0" - css-what "2.1" - domutils "1.5.1" - nth-check "~1.0.1" - -css-selector-tokenizer@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86" - dependencies: - cssesc "^0.1.0" - fastparse "^1.1.1" - regexpu-core "^1.0.0" - -css-what@2.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd" - -cssesc@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" - -"cssnano@>=2.6.1 <4", cssnano@^3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38" - dependencies: - autoprefixer "^6.3.1" - decamelize "^1.1.2" - defined "^1.0.0" - has "^1.0.1" - object-assign "^4.0.1" - postcss "^5.0.14" - postcss-calc "^5.2.0" - postcss-colormin "^2.1.8" - postcss-convert-values "^2.3.4" - postcss-discard-comments "^2.0.4" - postcss-discard-duplicates "^2.0.1" - postcss-discard-empty "^2.0.1" - postcss-discard-overridden "^0.1.1" - postcss-discard-unused "^2.2.1" - postcss-filter-plugins "^2.0.0" - postcss-merge-idents "^2.1.5" - postcss-merge-longhand "^2.0.1" - postcss-merge-rules "^2.0.3" - postcss-minify-font-values "^1.0.2" - postcss-minify-gradients "^1.0.1" - postcss-minify-params "^1.0.4" - postcss-minify-selectors "^2.0.4" - postcss-normalize-charset "^1.1.0" - postcss-normalize-url "^3.0.7" - postcss-ordered-values "^2.1.0" - postcss-reduce-idents "^2.2.2" - postcss-reduce-initial "^1.0.0" - postcss-reduce-transforms "^1.0.3" - postcss-svgo "^2.1.1" - postcss-unique-selectors "^2.0.2" - postcss-value-parser "^3.2.3" - postcss-zindex "^2.0.1" - -csso@~2.3.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/csso/-/csso-2.3.2.tgz#ddd52c587033f49e94b71fc55569f252e8ff5f85" - dependencies: - clap "^1.0.9" - source-map "^0.5.3" - -currently-unhandled@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" - dependencies: - array-find-index "^1.0.1" - -d@1: - version "1.0.0" - resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" - dependencies: - es5-ext "^0.10.9" - -dargs@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/dargs/-/dargs-4.1.0.tgz#03a9dbb4b5c2f139bf14ae53f0b8a2a6a86f4e17" - dependencies: - number-is-nan "^1.0.0" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - dependencies: - assert-plus "^1.0.0" - -date-now@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" - -dateformat@^1.0.11, dateformat@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9" - dependencies: - get-stdin "^4.0.1" - meow "^3.3.0" - -debug@*: - version "3.0.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.0.0.tgz#1d2feae53349047b08b264ec41906ba17a8516e4" - dependencies: - ms "2.0.0" - -debug@2.6.8, debug@^2.1.1, debug@^2.2.0, debug@^2.6.8: - version "2.6.8" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" - dependencies: - ms "2.0.0" - -decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - -deep-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - -deep-extend@~0.4.0: - version "0.4.2" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - -define-properties@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" - dependencies: - foreach "^2.0.5" - object-keys "^1.0.8" - -defined@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" - -del@^2.0.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" - dependencies: - globby "^5.0.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - rimraf "^2.2.8" - -del@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" - dependencies: - globby "^6.1.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - p-map "^1.1.1" - pify "^3.0.0" - rimraf "^2.2.8" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - -denodeify@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/denodeify/-/denodeify-1.2.1.tgz#3a36287f5034e699e7577901052c2e6c94251631" - -depd@1.1.1, depd@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" - -des.js@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - -detect-indent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" - dependencies: - repeating "^2.0.0" - -detect-node@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.3.tgz#a2033c09cc8e158d37748fbde7507832bd6ce127" - -diff@^3.1.0, diff@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.0.tgz#056695150d7aa93237ca7e378ac3b1682b7963b9" - -diffie-hellman@^5.0.0: - version "5.0.2" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - -directory-encoder@^0.7.2: - version "0.7.2" - resolved "https://registry.yarnpkg.com/directory-encoder/-/directory-encoder-0.7.2.tgz#59b4e2aa4f25422f6c63b527b462f5e2d0dd2c58" - dependencies: - fs-extra "^0.23.1" - handlebars "^1.3.0" - img-stats "^0.5.2" - -dns-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" - -dns-packet@^1.0.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.2.2.tgz#a8a26bec7646438963fc86e06f8f8b16d6c8bf7a" - dependencies: - ip "^1.1.0" - safe-buffer "^5.0.1" - -dns-txt@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" - dependencies: - buffer-indexof "^1.0.0" - -doctrine@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.0.0.tgz#c73d8d2909d22291e1a007a395804da8b665fe63" - dependencies: - esutils "^2.0.2" - isarray "^1.0.0" - -dom-converter@~0.1: - version "0.1.4" - resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b" - dependencies: - utila "~0.3" - -dom-serializer@0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" - dependencies: - domelementtype "~1.1.1" - entities "~1.1.1" - -domain-browser@^1.1.1: - version "1.1.7" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" - -domelementtype@1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2" - -domelementtype@~1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" - -domhandler@2.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.1.0.tgz#d2646f5e57f6c3bab11cf6cb05d3c0acf7412594" - dependencies: - domelementtype "1" - -domutils@1.1: - version "1.1.6" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.1.6.tgz#bddc3de099b9a2efacc51c623f28f416ecc57485" - dependencies: - domelementtype "1" - -domutils@1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" - dependencies: - dom-serializer "0" - domelementtype "1" - -dot-prop@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177" - dependencies: - is-obj "^1.0.0" - -dtsgenerator@^0.9.1: - version "0.9.5" - resolved "https://registry.yarnpkg.com/dtsgenerator/-/dtsgenerator-0.9.5.tgz#76a9eef1bce3dd5d24e7e51aa14ff748e7ec996e" - dependencies: - commander "^2.11.0" - debug "^2.6.8" - glob "^7.1.2" - js-yaml "^3.9.0" - mkdirp "^0.5.1" - request "^2.81.0" - -duplexer@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" - -ecc-jsbn@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" - dependencies: - jsbn "~0.1.0" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - -electron-to-chromium@^1.2.7: - version "1.3.18" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.18.tgz#3dcc99da3e6b665f6abbc71c28ad51a2cd731a9c" - -elliptic@^6.0.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" - dependencies: - bn.js "^4.4.0" - brorand "^1.0.1" - hash.js "^1.0.0" - hmac-drbg "^1.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.0" - -ember-cli-string-utils@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ember-cli-string-utils/-/ember-cli-string-utils-1.1.0.tgz#39b677fc2805f55173735376fcef278eaa4452a1" - -emojis-list@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" - -encodeurl@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" - -enhanced-resolve@^3.4.0, enhanced-resolve@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e" - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - object-assign "^4.0.1" - tapable "^0.2.7" - -ensure-posix-path@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/ensure-posix-path/-/ensure-posix-path-1.0.2.tgz#a65b3e42d0b71cfc585eb774f9943c8d9b91b0c2" - -entities@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" - -errno@^0.1.1, errno@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" - dependencies: - prr "~0.0.0" - -error-ex@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc" - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.4.3: - version "1.8.0" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.8.0.tgz#3b00385e85729932beffa9163bbea1234e932914" - dependencies: - es-to-primitive "^1.1.1" - function-bind "^1.1.0" - has "^1.0.1" - is-callable "^1.1.3" - is-regex "^1.0.4" - -es-to-primitive@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.1.1.tgz#45355248a88979034b6792e19bb81f2b7975dd0d" - dependencies: - is-callable "^1.1.1" - is-date-object "^1.0.1" - is-symbol "^1.0.1" - -es5-ext@^0.10.14, es5-ext@^0.10.9, es5-ext@~0.10.14: - version "0.10.27" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.27.tgz#bf926b058c62b1cb5de1a887930673b6aa6d9a66" - dependencies: - es6-iterator "2" - es6-symbol "~3.1" - -es6-iterator@2, es6-iterator@^2.0.1, es6-iterator@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" - dependencies: - d "1" - es5-ext "^0.10.14" - es6-symbol "^3.1" - -es6-map@^0.1.3: - version "0.1.5" - resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" - dependencies: - d "1" - es5-ext "~0.10.14" - es6-iterator "~2.0.1" - es6-set "~0.1.5" - es6-symbol "~3.1.1" - event-emitter "~0.3.5" - -es6-set@~0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" - dependencies: - d "1" - es5-ext "~0.10.14" - es6-iterator "~2.0.1" - es6-symbol "3.1.1" - event-emitter "~0.3.5" - -es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@^3.1.1, es6-symbol@~3.1, es6-symbol@~3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" - dependencies: - d "1" - es5-ext "~0.10.14" - -es6-weak-map@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f" - dependencies: - d "1" - es5-ext "^0.10.14" - es6-iterator "^2.0.1" - es6-symbol "^3.1.1" - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - -escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - -escope@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3" - dependencies: - es6-map "^0.1.3" - es6-weak-map "^2.0.1" - esrecurse "^4.1.0" - estraverse "^4.1.1" - -eslint@^3.11.0: - version "3.19.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.19.0.tgz#c8fc6201c7f40dd08941b87c085767386a679acc" - dependencies: - babel-code-frame "^6.16.0" - chalk "^1.1.3" - concat-stream "^1.5.2" - debug "^2.1.1" - doctrine "^2.0.0" - escope "^3.6.0" - espree "^3.4.0" - esquery "^1.0.0" - estraverse "^4.2.0" - esutils "^2.0.2" - file-entry-cache "^2.0.0" - glob "^7.0.3" - globals "^9.14.0" - ignore "^3.2.0" - imurmurhash "^0.1.4" - inquirer "^0.12.0" - is-my-json-valid "^2.10.0" - is-resolvable "^1.0.0" - js-yaml "^3.5.1" - json-stable-stringify "^1.0.0" - levn "^0.3.0" - lodash "^4.0.0" - mkdirp "^0.5.0" - natural-compare "^1.4.0" - optionator "^0.8.2" - path-is-inside "^1.0.1" - pluralize "^1.2.1" - progress "^1.1.8" - require-uncached "^1.0.2" - shelljs "^0.7.5" - strip-bom "^3.0.0" - strip-json-comments "~2.0.1" - table "^3.7.8" - text-table "~0.2.0" - user-home "^2.0.0" - -espree@^3.4.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.0.tgz#98358625bdd055861ea27e2867ea729faf463d8d" - dependencies: - acorn "^5.1.1" - acorn-jsx "^3.0.0" - -esprima@^2.6.0: - version "2.7.3" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" - -esprima@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.0.tgz#4499eddcd1110e0b218bacf2fa7f7f59f55ca804" - -esquery@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.0.tgz#cfba8b57d7fba93f17298a8a006a04cda13d80fa" - dependencies: - estraverse "^4.0.0" - -esrecurse@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.0.tgz#fa9568d98d3823f9a41d91e902dcab9ea6e5b163" - dependencies: - estraverse "^4.1.0" - object-assign "^4.0.1" - -estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - -etag@~1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051" - -event-emitter@~0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - dependencies: - d "1" - es5-ext "~0.10.14" - -event-stream@~3.3.0: - version "3.3.4" - resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" - dependencies: - duplexer "~0.1.1" - from "~0" - map-stream "~0.1.0" - pause-stream "0.0.11" - split "0.3" - stream-combiner "~0.0.4" - through "~2.3.1" - -eventemitter3@1.x.x: - version "1.2.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508" - -events@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" - -eventsource@0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232" - dependencies: - original ">=0.0.5" - -evp_bytestokey@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.0.tgz#497b66ad9fef65cd7c08a6180824ba1476b66e53" - dependencies: - create-hash "^1.1.1" - -execa@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -exit-hook@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - -expand-brackets@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" - dependencies: - is-posix-bracket "^0.1.0" - -expand-range@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" - dependencies: - fill-range "^2.1.0" - -exports-loader@^0.6.3: - version "0.6.4" - resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886" - dependencies: - loader-utils "^1.0.2" - source-map "0.5.x" - -express@^4.13.3, express@^4.14.0: - version "4.15.4" - resolved "https://registry.yarnpkg.com/express/-/express-4.15.4.tgz#032e2253489cf8fce02666beca3d11ed7a2daed1" - dependencies: - accepts "~1.3.3" - array-flatten "1.1.1" - content-disposition "0.5.2" - content-type "~1.0.2" - cookie "0.3.1" - cookie-signature "1.0.6" - debug "2.6.8" - depd "~1.1.1" - encodeurl "~1.0.1" - escape-html "~1.0.3" - etag "~1.8.0" - finalhandler "~1.0.4" - fresh "0.5.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.1" - path-to-regexp "0.1.7" - proxy-addr "~1.1.5" - qs "6.5.0" - range-parser "~1.2.0" - send "0.15.4" - serve-static "1.12.4" - setprototypeof "1.0.3" - statuses "~1.3.1" - type-is "~1.6.15" - utils-merge "1.0.0" - vary "~1.1.1" - -extend@~3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" - -external-editor@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.4.tgz#1ed9199da9cbfe2ef2f7a31b2fde8b0d12368972" - dependencies: - iconv-lite "^0.4.17" - jschardet "^1.4.2" - tmp "^0.0.31" - -extglob@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" - dependencies: - is-extglob "^1.0.0" - -extract-text-webpack-plugin@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.0.tgz#90caa7907bc449f335005e3ac7532b41b00de612" - dependencies: - async "^2.4.1" - loader-utils "^1.1.0" - schema-utils "^0.3.0" - webpack-sources "^1.0.1" - -extsprintf@1.3.0, extsprintf@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - -fast-deep-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" - -fast-levenshtein@~2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - -fastparse@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" - -faye-websocket@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" - dependencies: - websocket-driver ">=0.5.1" - -faye-websocket@~0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.1.tgz#f0efe18c4f56e4f40afc7e06c719fd5ee6188f38" - dependencies: - websocket-driver ">=0.5.1" - -figures@^1.3.5: - version "1.7.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" - dependencies: - escape-string-regexp "^1.0.5" - object-assign "^4.1.0" - -figures@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" - dependencies: - escape-string-regexp "^1.0.5" - -file-entry-cache@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" - dependencies: - flat-cache "^1.2.1" - object-assign "^4.0.1" - -file-loader@^0.10.0: - version "0.10.1" - resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-0.10.1.tgz#815034119891fc6441fb5a64c11bc93c22ddd842" - dependencies: - loader-utils "^1.0.2" - -filename-regex@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" - -fill-range@^2.1.0: - version "2.2.3" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723" - dependencies: - is-number "^2.1.0" - isobject "^2.0.0" - randomatic "^1.1.3" - repeat-element "^1.1.2" - repeat-string "^1.5.2" - -finalhandler@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.4.tgz#18574f2e7c4b98b8ae3b230c21f201f31bdb3fb7" - dependencies: - debug "2.6.8" - encodeurl "~1.0.1" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.1" - statuses "~1.3.1" - unpipe "~1.0.0" - -find-parent-dir@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" - -find-up@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" - dependencies: - path-exists "^2.0.0" - pinkie-promise "^2.0.0" - -find-up@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - dependencies: - locate-path "^2.0.0" - -flat-cache@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.2.2.tgz#fa86714e72c21db88601761ecf2f555d1abc6b96" - dependencies: - circular-json "^0.3.1" - del "^2.0.2" - graceful-fs "^4.1.2" - write "^0.2.1" - -flatten@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" - -for-in@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - -for-in@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - -for-own@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" - dependencies: - for-in "^1.0.1" - -for-own@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" - dependencies: - for-in "^1.0.1" - -foreach@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - -form-data@~2.1.1: - version "2.1.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.4.tgz#33c183acf193276ecaa98143a69e94bfee1750d1" - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.5" - mime-types "^2.1.12" - -forwarded@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" - -fresh@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e" - -from@~0: - version "0.1.7" - resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" - -fs-extra@^0.23.1: - version "0.23.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.23.1.tgz#6611dba6adf2ab8dc9c69fab37cddf8818157e3d" - dependencies: - graceful-fs "^4.1.2" - jsonfile "^2.1.0" - path-is-absolute "^1.0.0" - rimraf "^2.2.8" - -fs-extra@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.1.tgz#7fc0c6c8957f983f57f306a24e5b9ddd8d0dd880" - dependencies: - graceful-fs "^4.1.2" - jsonfile "^3.0.0" - universalify "^0.1.0" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - -fsevents@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4" - dependencies: - nan "^2.3.0" - node-pre-gyp "^0.6.36" - -fstream-ignore@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" - dependencies: - fstream "^1.0.0" - inherits "2" - minimatch "^3.0.0" - -fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2: - version "1.0.11" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171" - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - -function-bind@^1.0.2, function-bind@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771" - -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -gaze@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.2.tgz#847224677adb8870d679257ed3388fdb61e40105" - dependencies: - globule "^1.0.0" - -generate-function@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" - -generate-object-property@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" - dependencies: - is-property "^1.0.0" - -get-caller-file@^1.0.0, get-caller-file@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" - -get-pkg-repo@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz#c73b489c06d80cc5536c2c853f9e05232056972d" - dependencies: - hosted-git-info "^2.1.4" - meow "^3.3.0" - normalize-package-data "^2.3.0" - parse-github-repo-url "^1.3.0" - through2 "^2.0.0" - -get-stdin@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" - -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - dependencies: - assert-plus "^1.0.0" - -git-raw-commits@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-1.2.0.tgz#0f3a8bfd99ae0f2d8b9224d58892975e9a52d03c" - dependencies: - dargs "^4.0.1" - lodash.template "^4.0.2" - meow "^3.3.0" - split2 "^2.0.0" - through2 "^2.0.0" - -git-remote-origin-url@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz#5282659dae2107145a11126112ad3216ec5fa65f" - dependencies: - gitconfiglocal "^1.0.0" - pify "^2.3.0" - -git-semver-tags@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/git-semver-tags/-/git-semver-tags-1.2.1.tgz#6ccd2a52e735b736748dc762444fcd9588e27490" - dependencies: - meow "^3.3.0" - semver "^5.0.1" - -gitconfiglocal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b" - dependencies: - ini "^1.3.2" - -github-url-from-git@^1.4.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/github-url-from-git/-/github-url-from-git-1.5.0.tgz#f985fedcc0a9aa579dc88d7aff068d55cc6251a0" - -glob-base@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" - dependencies: - glob-parent "^2.0.0" - is-glob "^2.0.0" - -glob-parent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" - dependencies: - is-glob "^2.0.0" - -glob@7.0.x: - version "7.0.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.2" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1: - version "7.1.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^9.14.0, globals@^9.18.0: - version "9.18.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" - -globby@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" - dependencies: - array-union "^1.0.1" - arrify "^1.0.0" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -globby@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" - dependencies: - array-union "^1.0.1" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -globule@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.0.tgz#1dc49c6822dd9e8a2fa00ba2a295006e8664bd09" - dependencies: - glob "~7.1.1" - lodash "~4.17.4" - minimatch "~3.0.2" - -graceful-fs@^4.1.2, graceful-fs@^4.1.6: - version "4.1.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" - -handle-thing@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4" - -handlebars@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-1.3.0.tgz#9e9b130a93e389491322d975cf3ec1818c37ce34" - dependencies: - optimist "~0.3" - optionalDependencies: - uglify-js "~2.3" - -handlebars@^4.0.2: - version "4.0.10" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.10.tgz#3d30c718b09a3d96f23ea4cc1f403c4d3ba9ff4f" - dependencies: - async "^1.4.0" - optimist "^0.6.1" - source-map "^0.4.4" - optionalDependencies: - uglify-js "^2.6" - -har-schema@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e" - -har-validator@~4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a" - dependencies: - ajv "^4.9.1" - har-schema "^1.0.5" - -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - dependencies: - ansi-regex "^2.0.0" - -has-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" - -has-flag@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - -has@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" - dependencies: - function-bind "^1.0.2" - -hash-base@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1" - dependencies: - inherits "^2.0.1" - -hash.js@^1.0.0, hash.js@^1.0.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846" - dependencies: - inherits "^2.0.3" - minimalistic-assert "^1.0.0" - -hawk@~3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" - dependencies: - boom "2.x.x" - cryptiles "2.x.x" - hoek "2.x.x" - sntp "1.x.x" - -he@1.1.x: - version "1.1.1" - resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" - -heimdalljs-logger@^0.1.9: - version "0.1.9" - resolved "https://registry.yarnpkg.com/heimdalljs-logger/-/heimdalljs-logger-0.1.9.tgz#d76ada4e45b7bb6f786fc9c010a68eb2e2faf176" - dependencies: - debug "^2.2.0" - heimdalljs "^0.2.0" - -heimdalljs@^0.2.0, heimdalljs@^0.2.4: - version "0.2.5" - resolved "https://registry.yarnpkg.com/heimdalljs/-/heimdalljs-0.2.5.tgz#6aa54308eee793b642cff9cf94781445f37730ac" - dependencies: - rsvp "~3.2.1" - -hmac-drbg@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - dependencies: - hash.js "^1.0.3" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.1" - -hoek@2.x.x: - version "2.16.3" - resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" - -hosted-git-info@^2.1.4: - version "2.5.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c" - -hpack.js@^2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" - dependencies: - inherits "^2.0.1" - obuf "^1.0.0" - readable-stream "^2.0.1" - wbuf "^1.1.0" - -html-comment-regex@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e" - -html-entities@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" - -html-minifier@^3.2.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.3.tgz#4a275e3b1a16639abb79b4c11191ff0d0fcf1ab9" - dependencies: - camel-case "3.0.x" - clean-css "4.1.x" - commander "2.11.x" - he "1.1.x" - ncname "1.0.x" - param-case "2.1.x" - relateurl "0.2.x" - uglify-js "3.0.x" - -html-webpack-plugin@^2.29.0: - version "2.30.1" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-2.30.1.tgz#7f9c421b7ea91ec460f56527d78df484ee7537d5" - dependencies: - bluebird "^3.4.7" - html-minifier "^3.2.3" - loader-utils "^0.2.16" - lodash "^4.17.3" - pretty-error "^2.0.2" - toposort "^1.0.0" - -htmlparser2@~3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.3.0.tgz#cc70d05a59f6542e43f0e685c982e14c924a9efe" - dependencies: - domelementtype "1" - domhandler "2.1" - domutils "1.1" - readable-stream "1.0" - -http-deceiver@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" - -http-errors@~1.6.1, http-errors@~1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" - dependencies: - depd "1.1.1" - inherits "2.0.3" - setprototypeof "1.0.3" - statuses ">= 1.3.1 < 2" - -http-proxy-middleware@~0.17.4: - version "0.17.4" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833" - dependencies: - http-proxy "^1.16.2" - is-glob "^3.1.0" - lodash "^4.17.2" - micromatch "^2.3.11" - -http-proxy@^1.16.2: - version "1.16.2" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.16.2.tgz#06dff292952bf64dbe8471fa9df73066d4f37742" - dependencies: - eventemitter3 "1.x.x" - requires-port "1.x.x" - -http-signature@~1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" - dependencies: - assert-plus "^0.2.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -https-browserify@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" - -iconv-lite@^0.4.17: - version "0.4.18" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2" - -icss-replace-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" - -icss-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962" - dependencies: - postcss "^6.0.1" - -ieee754@^1.1.4: - version "1.1.8" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" - -ignore@^3.2.0: - version "3.3.3" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.3.tgz#432352e57accd87ab3110e82d3fea0e47812156d" - -image-size@~0.5.0: - version "0.5.5" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" - -img-stats@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/img-stats/-/img-stats-0.5.2.tgz#c203496c42f2d9eb2e5ab8232fa756bab32c9e2b" - dependencies: - xmldom "^0.1.19" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - -in-publish@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" - -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - dependencies: - repeating "^2.0.0" - -indexes-of@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" - -indexof@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - -ini@^1.3.2, ini@~1.3.0: - version "1.3.4" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" - -inquirer@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e" - dependencies: - ansi-escapes "^1.1.0" - ansi-regex "^2.0.0" - chalk "^1.0.0" - cli-cursor "^1.0.1" - cli-width "^2.0.0" - figures "^1.3.5" - lodash "^4.3.0" - readline2 "^1.0.1" - run-async "^0.1.0" - rx-lite "^3.1.2" - string-width "^1.0.1" - strip-ansi "^3.0.0" - through "^2.3.6" - -inquirer@^3.0.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.2.2.tgz#c2aaede1507cc54d826818737742d621bef2e823" - dependencies: - ansi-escapes "^2.0.0" - chalk "^2.0.0" - cli-cursor "^2.1.0" - cli-width "^2.0.0" - external-editor "^2.0.4" - figures "^2.0.0" - lodash "^4.3.0" - mute-stream "0.0.7" - run-async "^2.2.0" - rx-lite "^4.0.8" - rx-lite-aggregates "^4.0.8" - string-width "^2.1.0" - strip-ansi "^4.0.0" - through "^2.3.6" - -internal-ip@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c" - dependencies: - meow "^3.3.0" - -interpret@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.3.tgz#cbc35c62eeee73f19ab7b10a801511401afc0f90" - -invariant@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" - dependencies: - loose-envify "^1.0.0" - -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - -ip@^1.1.0: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - -ipaddr.js@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.4.0.tgz#296aca878a821816e5b85d0a285a99bcff4582f0" - -is-absolute-url@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.0.2, is-buffer@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" - -is-builtin-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" - dependencies: - builtin-modules "^1.0.0" - -is-callable@^1.1.1, is-callable@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2" - -is-date-object@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" - -is-directory@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" - -is-dotfile@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" - -is-equal-shallow@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" - dependencies: - is-primitive "^2.0.0" - -is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - -is-extglob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" - -is-extglob@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - -is-finite@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - -is-glob@^2.0.0, is-glob@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" - dependencies: - is-extglob "^1.0.0" - -is-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - dependencies: - is-extglob "^2.1.0" - -is-my-json-valid@^2.10.0: - version "2.16.0" - resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.16.0.tgz#f079dd9bfdae65ee2038aae8acbc86ab109e3693" - dependencies: - generate-function "^2.0.0" - generate-object-property "^1.1.0" - jsonpointer "^4.0.0" - xtend "^4.0.0" - -is-number@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" - dependencies: - kind-of "^3.0.2" - -is-number@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" - dependencies: - kind-of "^3.0.2" - -is-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - -is-path-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" - -is-path-in-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz#6477582b8214d602346094567003be8a9eac04dc" - dependencies: - is-path-inside "^1.0.0" - -is-path-inside@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.0.tgz#fc06e5a1683fbda13de667aff717bbc10a48f37f" - dependencies: - path-is-inside "^1.0.1" - -is-plain-obj@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - -is-plain-object@^2.0.1: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - dependencies: - isobject "^3.0.1" - -is-posix-bracket@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" - -is-primitive@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" - -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - -is-property@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" - -is-regex@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" - dependencies: - has "^1.0.1" - -is-resolvable@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.0.tgz#8df57c61ea2e3c501408d100fb013cf8d6e0cc62" - dependencies: - tryit "^1.0.1" - -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - -is-subset@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" - -is-svg@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9" - dependencies: - html-comment-regex "^1.1.0" - -is-symbol@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572" - -is-text-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-text-path/-/is-text-path-1.0.1.tgz#4e1aa0fb51bfbcb3e92688001397202c1775b66e" - dependencies: - text-extensions "^1.0.0" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - -is-utf8@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" - -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - dependencies: - isarray "1.0.0" - -isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - -istanbul-instrumenter-loader@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/istanbul-instrumenter-loader/-/istanbul-instrumenter-loader-2.0.0.tgz#e5492900ab0bba835efa8024cb00be9b3eea2700" - dependencies: - convert-source-map "^1.3.0" - istanbul-lib-instrument "^1.1.3" - loader-utils "^0.2.16" - object-assign "^4.1.0" - -istanbul-lib-coverage@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da" - -istanbul-lib-instrument@^1.1.3: - version "1.7.4" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.7.4.tgz#e9fd920e4767f3d19edc765e2d6b3f5ccbd0eea8" - dependencies: - babel-generator "^6.18.0" - babel-template "^6.16.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - babylon "^6.17.4" - istanbul-lib-coverage "^1.1.1" - semver "^5.3.0" - -jasmine-core@~2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.7.0.tgz#50ff8c4f92d8ef5c0b2c1b846dd263ed85152091" - -jasmine-spec-reporter@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/jasmine-spec-reporter/-/jasmine-spec-reporter-4.2.1.tgz#1d632aec0341670ad324f92ba84b4b32b35e9e22" - dependencies: - colors "1.1.2" - -jasmine@^2.6.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.7.0.tgz#5cf0bb4e594b4600bb4235560366212ac5aea1b2" - dependencies: - exit "^0.1.2" - glob "^7.0.6" - jasmine-core "~2.7.0" - -js-base64@^2.1.5, js-base64@^2.1.8, js-base64@^2.1.9: - version "2.1.9" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce" - -js-tokens@^3.0.0, js-tokens@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" - -js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.9.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.1.tgz#08775cebdfdd359209f0d2acd383c8f86a6904a0" - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -js-yaml@~3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" - dependencies: - argparse "^1.0.7" - esprima "^2.6.0" - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - -jschardet@^1.4.2: - version "1.5.1" - resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-1.5.1.tgz#c519f629f86b3a5bedba58a88d311309eec097f9" - -jsesc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - -json-loader@^0.5.4: - version "0.5.7" - resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d" - -json-schema-traverse@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - -json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" - dependencies: - jsonify "~0.0.0" - -json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - -json3@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" - -json5@^0.5.0, json5@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - -jsonfile@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonfile@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-3.0.1.tgz#a5ecc6f65f53f662c4415c7675a0331d0992ec66" - optionalDependencies: - graceful-fs "^4.1.6" - -jsonify@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" - -jsonparse@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - -jsonpointer@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" - -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.2.3" - verror "1.10.0" - -karma-source-map-support@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.2.0.tgz#1bf81e7bb4b089627ab352ec4179e117c406a540" - dependencies: - source-map-support "^0.4.1" - -kind-of@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" - dependencies: - is-buffer "^1.0.2" - -kind-of@^3.0.2, kind-of@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - dependencies: - is-buffer "^1.1.5" - -kind-of@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" - dependencies: - is-buffer "^1.1.5" - -lazy-cache@^0.2.3: - version "0.2.7" - resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65" - -lazy-cache@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" - -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - dependencies: - invert-kv "^1.0.0" - -less-loader@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-4.0.5.tgz#ae155a7406cac6acd293d785587fcff0f478c4dd" - dependencies: - clone "^2.1.1" - loader-utils "^1.1.0" - pify "^2.3.0" - -less@^2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/less/-/less-2.7.2.tgz#368d6cc73e1fb03981183280918743c5dcf9b3df" - optionalDependencies: - errno "^0.1.1" - graceful-fs "^4.1.2" - image-size "~0.5.0" - mime "^1.2.11" - mkdirp "^0.5.0" - promise "^7.1.1" - request "^2.72.0" - source-map "^0.5.3" - -levn@^0.3.0, levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -license-webpack-plugin@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-0.5.1.tgz#68d8af103486a9c4ebceddb7ed5d3bd61f383be4" - dependencies: - object-assign "^4.1.0" - -load-json-file@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - pinkie-promise "^2.0.0" - strip-bom "^2.0.0" - -load-json-file@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - strip-bom "^3.0.0" - -loader-runner@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" - -loader-utils@^0.2.16, loader-utils@~0.2.2: - version "0.2.17" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" - dependencies: - big.js "^3.1.3" - emojis-list "^2.0.0" - json5 "^0.5.0" - object-assign "^4.0.1" - -loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" - dependencies: - big.js "^3.1.3" - emojis-list "^2.0.0" - json5 "^0.5.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -lodash._reinterpolate@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" - -lodash.assign@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" - -lodash.camelcase@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" - -lodash.clonedeep@^4.3.2, lodash.clonedeep@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - -lodash.mergewith@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55" - -lodash.tail@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" - -lodash.template@^4.0.2: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" - dependencies: - lodash._reinterpolate "~3.0.0" - lodash.templatesettings "^4.0.0" - -lodash.templatesettings@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316" - dependencies: - lodash._reinterpolate "~3.0.0" - -lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - -lodash@^4.0.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.4: - version "4.17.4" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" - -longest@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" - -loose-envify@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" - dependencies: - js-tokens "^3.0.0" - -loud-rejection@^1.0.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" - dependencies: - currently-unhandled "^0.4.1" - signal-exit "^3.0.0" - -lower-case@^1.1.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" - -lru-cache@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55" - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - -macaddress@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" - -magic-string@^0.22.3: - version "0.22.4" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.4.tgz#31039b4e40366395618c1d6cf8193c53917475ff" - dependencies: - vlq "^0.2.1" - -make-error@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.0.tgz#52ad3a339ccf10ce62b4040b708fe707244b8b96" - -map-obj@^1.0.0, map-obj@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - -map-stream@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" - -matcher-collection@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/matcher-collection/-/matcher-collection-1.0.4.tgz#2f66ae0869996f29e43d0b62c83dd1d43e581755" - dependencies: - minimatch "^3.0.2" - -math-expression-evaluator@^1.2.14: - version "1.2.17" - resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - -mem@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" - dependencies: - mimic-fn "^1.0.0" - -memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" - -meow@^3.3.0, meow@^3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" - dependencies: - camelcase-keys "^2.0.0" - decamelize "^1.1.2" - loud-rejection "^1.0.0" - map-obj "^1.0.1" - minimist "^1.1.3" - normalize-package-data "^2.3.4" - object-assign "^4.0.1" - read-pkg-up "^1.0.1" - redent "^1.0.0" - trim-newlines "^1.0.0" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - -micromatch@^2.1.5, micromatch@^2.3.11: - version "2.3.11" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" - dependencies: - arr-diff "^2.0.0" - array-unique "^0.2.1" - braces "^1.8.2" - expand-brackets "^0.1.4" - extglob "^0.3.1" - filename-regex "^2.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.1" - kind-of "^3.0.2" - normalize-path "^2.0.1" - object.omit "^2.0.0" - parse-glob "^3.0.4" - regex-cache "^0.4.2" - -miller-rabin@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.0.tgz#4a62fb1d42933c05583982f4c716f6fb9e6c6d3d" - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - -"mime-db@>= 1.29.0 < 2", mime-db@~1.29.0: - version "1.29.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.29.0.tgz#48d26d235589651704ac5916ca06001914266878" - -mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7: - version "2.1.16" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.16.tgz#2b858a52e5ecd516db897ac2be87487830698e23" - dependencies: - mime-db "~1.29.0" - -mime@1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53" - -mime@1.3.x, mime@^1.2.11, mime@^1.3.4: - version "1.3.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.6.tgz#591d84d3653a6b0b4a3b9df8de5aa8108e72e5e0" - -mimic-fn@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" - -minimalistic-assert@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3" - -minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" - -minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - dependencies: - brace-expansion "^1.1.7" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - -minimist@^1.1.3, minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - -minimist@~0.0.1: - version "0.0.10" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" - -minipass@^2.0.0, minipass@^2.0.2: - version "2.2.1" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.2.1.tgz#5ada97538b1027b4cf7213432428578cb564011f" - dependencies: - yallist "^3.0.0" - -minizlib@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.0.3.tgz#d5c1abf77be154619952e253336eccab9b2a32f5" - dependencies: - minipass "^2.0.0" - -mixin-object@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - dependencies: - for-in "^0.1.3" - is-extendable "^0.1.1" - -mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - dependencies: - minimist "0.0.8" - -mock-fs@^4.0.0: - version "4.4.1" - resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-4.4.1.tgz#f285fa025b42a4031faf75b66f632b21e7056683" - -modify-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.0.tgz#e2b6cdeb9ce19f99317a53722f3dbf5df5eaaab2" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - -multicast-dns-service-types@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" - -multicast-dns@^6.0.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.1.1.tgz#6e7de86a570872ab17058adea7160bbeca814dde" - dependencies: - dns-packet "^1.0.1" - thunky "^0.1.0" - -mute-stream@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" - -mute-stream@0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" - -nan@^2.3.0, nan@^2.3.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45" - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - -ncname@1.0.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ncname/-/ncname-1.0.0.tgz#5b57ad18b1ca092864ef62b0b1ed8194f383b71c" - dependencies: - xml-char-classes "^1.0.0" - -negotiator@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" - -no-case@^2.2.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.1.tgz#7aeba1c73a52184265554b7dc03baf720df80081" - dependencies: - lower-case "^1.1.1" - -node-forge@0.6.33: - version "0.6.33" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.33.tgz#463811879f573d45155ad6a9f43dc296e8e85ebc" - -node-gyp@^3.3.1: - version "3.6.2" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.6.2.tgz#9bfbe54562286284838e750eac05295853fa1c60" - dependencies: - fstream "^1.0.0" - glob "^7.0.3" - graceful-fs "^4.1.2" - minimatch "^3.0.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "2" - rimraf "2" - semver "~5.3.0" - tar "^2.0.0" - which "1" - -node-libs-browser@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.0.0.tgz#a3a59ec97024985b46e958379646f96c4b616646" - dependencies: - assert "^1.1.1" - browserify-zlib "^0.1.4" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^1.0.0" - https-browserify "0.0.1" - os-browserify "^0.2.0" - path-browserify "0.0.0" - process "^0.11.0" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.0.5" - stream-browserify "^2.0.1" - stream-http "^2.3.1" - string_decoder "^0.10.25" - timers-browserify "^2.0.2" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.10.3" - vm-browserify "0.0.4" - -node-modules-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/node-modules-path/-/node-modules-path-1.0.1.tgz#40096b08ce7ad0ea14680863af449c7c75a5d1c8" - -node-pre-gyp@^0.6.36: - version "0.6.36" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.36.tgz#db604112cb74e0d477554e9b505b17abddfab786" - dependencies: - mkdirp "^0.5.1" - nopt "^4.0.1" - npmlog "^4.0.2" - rc "^1.1.7" - request "^2.81.0" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^2.2.1" - tar-pack "^3.4.0" - -node-sass@^4.3.0: - version "4.5.3" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.3.tgz#d09c9d1179641239d1b97ffc6231fdcec53e1568" - dependencies: - async-foreach "^0.1.3" - chalk "^1.1.1" - cross-spawn "^3.0.0" - gaze "^1.0.0" - get-stdin "^4.0.1" - glob "^7.0.3" - in-publish "^2.0.0" - lodash.assign "^4.2.0" - lodash.clonedeep "^4.3.2" - lodash.mergewith "^4.6.0" - meow "^3.7.0" - mkdirp "^0.5.1" - nan "^2.3.2" - node-gyp "^3.3.1" - npmlog "^4.0.0" - request "^2.79.0" - sass-graph "^2.1.1" - stdout-stream "^1.4.0" - -"nopt@2 || 3": - version "3.0.6" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" - dependencies: - abbrev "1" - -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - dependencies: - abbrev "1" - osenv "^0.1.4" - -normalize-package-data@^2.3.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.3.5: - version "2.4.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" - dependencies: - hosted-git-info "^2.1.4" - is-builtin-module "^1.0.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-path@^2.0.0, normalize-path@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - dependencies: - remove-trailing-separator "^1.0.1" - -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - -normalize-url@^1.4.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" - dependencies: - object-assign "^4.0.1" - prepend-http "^1.0.0" - query-string "^4.1.0" - sort-keys "^1.0.0" - -npm-path@^2.0.2, npm-path@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.3.tgz#15cff4e1c89a38da77f56f6055b24f975dfb2bbe" - dependencies: - which "^1.2.10" - -npm-run-all@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.0.2.tgz#a84669348e6db6ccbe052200b4cdb6bfe034a4fe" - dependencies: - chalk "^1.1.3" - cross-spawn "^5.0.1" - minimatch "^3.0.2" - ps-tree "^1.0.1" - read-pkg "^2.0.0" - shell-quote "^1.6.1" - string.prototype.padend "^3.0.0" - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - dependencies: - path-key "^2.0.0" - -npm-run@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npm-run/-/npm-run-4.1.2.tgz#1030e1ec56908c89fcc3fa366d03a2c2ba98eb99" - dependencies: - cross-spawn "^5.1.0" - minimist "^1.2.0" - npm-path "^2.0.3" - npm-which "^3.0.1" - serializerr "^1.0.3" - sync-exec "^0.6.2" - -npm-which@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-3.0.1.tgz#9225f26ec3a285c209cae67c3b11a6b4ab7140aa" - dependencies: - commander "^2.9.0" - npm-path "^2.0.2" - which "^1.2.10" - -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - -nth-check@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.1.tgz#9929acdf628fc2c41098deab82ac580cf149aae4" - dependencies: - boolbase "~1.0.0" - -num2fraction@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - -oauth-sign@~0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" - -object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - -object-keys@^1.0.8: - version "1.0.11" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" - -object.omit@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" - dependencies: - for-own "^0.1.4" - is-extendable "^0.1.1" - -obuf@^1.0.0, obuf@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e" - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" - -once@^1.3.0, once@^1.3.3: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - dependencies: - wrappy "1" - -onetime@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" - -onetime@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" - dependencies: - mimic-fn "^1.0.0" - -opn@4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/opn/-/opn-4.0.2.tgz#7abc22e644dff63b0a96d5ab7f2790c0f01abc95" - dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" - -opn@~5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/opn/-/opn-5.1.0.tgz#72ce2306a17dbea58ff1041853352b4a8fc77519" - dependencies: - is-wsl "^1.1.0" - -optimist@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" - dependencies: - minimist "~0.0.1" - wordwrap "~0.0.2" - -optimist@~0.3, optimist@~0.3.5: - version "0.3.7" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.3.7.tgz#c90941ad59e4273328923074d2cf2e7cbc6ec0d9" - dependencies: - wordwrap "~0.0.2" - -optionator@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.4" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - wordwrap "~1.0.0" - -original@>=0.0.5: - version "1.0.0" - resolved "https://registry.yarnpkg.com/original/-/original-1.0.0.tgz#9147f93fa1696d04be61e01bd50baeaca656bd3b" - dependencies: - url-parse "1.0.x" - -os-browserify@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" - -os-homedir@^1.0.0, os-homedir@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - -os-locale@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" - dependencies: - lcid "^1.0.0" - -os-locale@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" - dependencies: - execa "^0.7.0" - lcid "^1.0.0" - mem "^1.1.0" - -os-tmpdir@^1.0.0, os-tmpdir@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - -osenv@0, osenv@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - -p-limit@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.1.0.tgz#b07ff2d9a5d88bec806035895a2bab66a27988bc" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - dependencies: - p-limit "^1.1.0" - -p-map@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.1.1.tgz#05f5e4ae97a068371bc2a5cc86bfbdbc19c4ae7a" - -pako@~0.2.0: - version "0.2.9" - resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" - -param-case@2.1.x: - version "2.1.1" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" - dependencies: - no-case "^2.2.0" - -parse-asn1@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.0.tgz#37c4f9b7ed3ab65c74817b5f2480937fbf97c712" - dependencies: - asn1.js "^4.0.0" - browserify-aes "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.0" - pbkdf2 "^3.0.3" - -parse-github-repo-url@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/parse-github-repo-url/-/parse-github-repo-url-1.4.0.tgz#286c53e2c9962e0641649ee3ac9508fca4dd959c" - -parse-glob@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" - dependencies: - glob-base "^0.3.0" - is-dotfile "^1.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.0" - -parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - dependencies: - error-ex "^1.2.0" - -parseurl@~1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.1.tgz#c8ab8c9223ba34888aa64a297b28853bec18da56" - -path-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" - -path-exists@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" - dependencies: - pinkie-promise "^2.0.0" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - -path-is-inside@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - -path-key@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - -path-parse@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - -path-type@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" - dependencies: - graceful-fs "^4.1.2" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -path-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" - dependencies: - pify "^2.0.0" - -pause-stream@0.0.11: - version "0.0.11" - resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" - dependencies: - through "~2.3" - -pbkdf2@^3.0.3: - version "3.0.13" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.13.tgz#c37d295531e786b1da3e3eadc840426accb0ae25" - dependencies: - create-hash "^1.1.2" - create-hmac "^1.1.4" - ripemd160 "^2.0.1" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -performance-now@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" - -pify@^2.0.0, pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - -pluralize@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" - -portfinder@^1.0.9, portfinder@~1.0.12: - version "1.0.13" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.13.tgz#bb32ecd87c27104ae6ee44b5a3ccbf0ebb1aede9" - dependencies: - async "^1.5.2" - debug "^2.2.0" - mkdirp "0.5.x" - -postcss-calc@^5.2.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e" - dependencies: - postcss "^5.0.2" - postcss-message-helpers "^2.0.0" - reduce-css-calc "^1.2.6" - -postcss-colormin@^2.1.8: - version "2.2.2" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.2.tgz#6631417d5f0e909a3d7ec26b24c8a8d1e4f96e4b" - dependencies: - colormin "^1.0.5" - postcss "^5.0.13" - postcss-value-parser "^3.2.3" - -postcss-convert-values@^2.3.4: - version "2.6.1" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz#bbd8593c5c1fd2e3d1c322bb925dcae8dae4d62d" - dependencies: - postcss "^5.0.11" - postcss-value-parser "^3.1.2" - -postcss-discard-comments@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz#befe89fafd5b3dace5ccce51b76b81514be00e3d" - dependencies: - postcss "^5.0.14" - -postcss-discard-duplicates@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz#b9abf27b88ac188158a5eb12abcae20263b91932" - dependencies: - postcss "^5.0.4" - -postcss-discard-empty@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz#d2b4bd9d5ced5ebd8dcade7640c7d7cd7f4f92b5" - dependencies: - postcss "^5.0.14" - -postcss-discard-overridden@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz#8b1eaf554f686fb288cd874c55667b0aa3668d58" - dependencies: - postcss "^5.0.16" - -postcss-discard-unused@^2.2.1: - version "2.2.3" - resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz#bce30b2cc591ffc634322b5fb3464b6d934f4433" - dependencies: - postcss "^5.0.14" - uniqs "^2.0.0" - -postcss-filter-plugins@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/postcss-filter-plugins/-/postcss-filter-plugins-2.0.2.tgz#6d85862534d735ac420e4a85806e1f5d4286d84c" - dependencies: - postcss "^5.0.4" - uniqid "^4.0.0" - -postcss-load-config@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-1.2.0.tgz#539e9afc9ddc8620121ebf9d8c3673e0ce50d28a" - dependencies: - cosmiconfig "^2.1.0" - object-assign "^4.1.0" - postcss-load-options "^1.2.0" - postcss-load-plugins "^2.3.0" - -postcss-load-options@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postcss-load-options/-/postcss-load-options-1.2.0.tgz#b098b1559ddac2df04bc0bb375f99a5cfe2b6d8c" - dependencies: - cosmiconfig "^2.1.0" - object-assign "^4.1.0" - -postcss-load-plugins@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/postcss-load-plugins/-/postcss-load-plugins-2.3.0.tgz#745768116599aca2f009fad426b00175049d8d92" - dependencies: - cosmiconfig "^2.1.1" - object-assign "^4.1.0" - -postcss-loader@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-1.3.3.tgz#a621ea1fa29062a83972a46f54486771301916eb" - dependencies: - loader-utils "^1.0.2" - object-assign "^4.1.1" - postcss "^5.2.15" - postcss-load-config "^1.2.0" - -postcss-merge-idents@^2.1.5: - version "2.1.7" - resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz#4c5530313c08e1d5b3bbf3d2bbc747e278eea270" - dependencies: - has "^1.0.1" - postcss "^5.0.10" - postcss-value-parser "^3.1.1" - -postcss-merge-longhand@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz#23d90cd127b0a77994915332739034a1a4f3d658" - dependencies: - postcss "^5.0.4" - -postcss-merge-rules@^2.0.3: - version "2.1.2" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz#d1df5dfaa7b1acc3be553f0e9e10e87c61b5f721" - dependencies: - browserslist "^1.5.2" - caniuse-api "^1.5.2" - postcss "^5.0.4" - postcss-selector-parser "^2.2.2" - vendors "^1.0.0" - -postcss-message-helpers@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e" - -postcss-minify-font-values@^1.0.2: - version "1.0.5" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz#4b58edb56641eba7c8474ab3526cafd7bbdecb69" - dependencies: - object-assign "^4.0.1" - postcss "^5.0.4" - postcss-value-parser "^3.0.2" - -postcss-minify-gradients@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz#5dbda11373703f83cfb4a3ea3881d8d75ff5e6e1" - dependencies: - postcss "^5.0.12" - postcss-value-parser "^3.3.0" - -postcss-minify-params@^1.0.4: - version "1.2.2" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz#ad2ce071373b943b3d930a3fa59a358c28d6f1f3" - dependencies: - alphanum-sort "^1.0.1" - postcss "^5.0.2" - postcss-value-parser "^3.0.2" - uniqs "^2.0.0" - -postcss-minify-selectors@^2.0.4: - version "2.1.1" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz#b2c6a98c0072cf91b932d1a496508114311735bf" - dependencies: - alphanum-sort "^1.0.2" - has "^1.0.1" - postcss "^5.0.14" - postcss-selector-parser "^2.0.0" - -postcss-modules-extract-imports@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85" - dependencies: - postcss "^6.0.1" - -postcss-modules-local-by-default@^1.0.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" - dependencies: - css-selector-tokenizer "^0.7.0" - postcss "^6.0.1" - -postcss-modules-scope@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" - dependencies: - css-selector-tokenizer "^0.7.0" - postcss "^6.0.1" - -postcss-modules-values@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" - dependencies: - icss-replace-symbols "^1.1.0" - postcss "^6.0.1" - -postcss-normalize-charset@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz#ef9ee71212d7fe759c78ed162f61ed62b5cb93f1" - dependencies: - postcss "^5.0.5" - -postcss-normalize-url@^3.0.7: - version "3.0.8" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz#108f74b3f2fcdaf891a2ffa3ea4592279fc78222" - dependencies: - is-absolute-url "^2.0.0" - normalize-url "^1.4.0" - postcss "^5.0.14" - postcss-value-parser "^3.2.3" - -postcss-ordered-values@^2.1.0: - version "2.2.3" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d" - dependencies: - postcss "^5.0.4" - postcss-value-parser "^3.0.1" - -postcss-reduce-idents@^2.2.2: - version "2.4.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz#c2c6d20cc958284f6abfbe63f7609bf409059ad3" - dependencies: - postcss "^5.0.4" - postcss-value-parser "^3.0.2" - -postcss-reduce-initial@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz#68f80695f045d08263a879ad240df8dd64f644ea" - dependencies: - postcss "^5.0.4" - -postcss-reduce-transforms@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz#ff76f4d8212437b31c298a42d2e1444025771ae1" - dependencies: - has "^1.0.1" - postcss "^5.0.8" - postcss-value-parser "^3.0.1" - -postcss-selector-parser@^2.0.0, postcss-selector-parser@^2.2.2: - version "2.2.3" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz#f9437788606c3c9acee16ffe8d8b16297f27bb90" - dependencies: - flatten "^1.0.2" - indexes-of "^1.0.1" - uniq "^1.0.1" - -postcss-svgo@^2.1.1: - version "2.1.6" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-2.1.6.tgz#b6df18aa613b666e133f08adb5219c2684ac108d" - dependencies: - is-svg "^2.0.0" - postcss "^5.0.14" - postcss-value-parser "^3.2.3" - svgo "^0.7.0" - -postcss-unique-selectors@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz#981d57d29ddcb33e7b1dfe1fd43b8649f933ca1d" - dependencies: - alphanum-sort "^1.0.1" - postcss "^5.0.4" - uniqs "^2.0.0" - -postcss-url@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-url/-/postcss-url-5.1.2.tgz#98b3165be8d592471cb0caadde2c0d1f832f133e" - dependencies: - directory-encoder "^0.7.2" - js-base64 "^2.1.5" - mime "^1.2.11" - minimatch "^3.0.0" - mkdirp "^0.5.0" - path-is-absolute "^1.0.0" - postcss "^5.0.0" - -postcss-value-parser@^3.0.1, postcss-value-parser@^3.0.2, postcss-value-parser@^3.1.1, postcss-value-parser@^3.1.2, postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" - -postcss-zindex@^2.0.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-2.2.0.tgz#d2109ddc055b91af67fc4cb3b025946639d2af22" - dependencies: - has "^1.0.1" - postcss "^5.0.4" - uniqs "^2.0.0" - -postcss@^5.0.0, postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8, postcss@^5.2.15, postcss@^5.2.16: - version "5.2.17" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.17.tgz#cf4f597b864d65c8a492b2eabe9d706c879c388b" - dependencies: - chalk "^1.1.3" - js-base64 "^2.1.9" - source-map "^0.5.6" - supports-color "^3.2.3" - -postcss@^6.0.1: - version "6.0.9" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.9.tgz#54819766784a51c65b1ec4d54c2f93765438c35a" - dependencies: - chalk "^2.1.0" - source-map "^0.5.6" - supports-color "^4.2.1" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - -prepend-http@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" - -preserve@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" - -pretty-error@^2.0.2: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" - dependencies: - renderkid "^2.0.1" - utila "~0.4" - -process-nextick-args@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" - -process@^0.11.0: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - -progress@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" - -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - dependencies: - asap "~2.0.3" - -protochain@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/protochain/-/protochain-1.0.5.tgz#991c407e99de264aadf8f81504b5e7faf7bfa260" - -proxy-addr@~1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918" - dependencies: - forwarded "~0.1.0" - ipaddr.js "1.4.0" - -prr@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" - -ps-tree@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.1.0.tgz#b421b24140d6203f1ed3c76996b4427b08e8c014" - dependencies: - event-stream "~3.3.0" - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - -public-encrypt@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" - dependencies: - bn.js "^4.1.0" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - parse-asn1 "^5.0.0" - randombytes "^2.0.1" - -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - -punycode@^1.2.4, punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - -q@^1.1.2, q@^1.4.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" - -qs@6.5.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49" - -qs@~6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" - -query-string@^4.1.0: - version "4.3.4" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" - dependencies: - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - -querystring-es3@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" - -querystring@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" - -querystringify@0.0.x: - version "0.0.4" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" - -querystringify@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb" - -randomatic@^1.1.3: - version "1.1.7" - resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c" - dependencies: - is-number "^3.0.0" - kind-of "^4.0.0" - -randombytes@^2.0.0, randombytes@^2.0.1: - version "2.0.5" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.5.tgz#dc009a246b8d09a177b4b7a0ae77bc570f4b1b79" - dependencies: - safe-buffer "^5.1.0" - -range-parser@^1.0.3, range-parser@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - -raw-loader@^0.5.1, raw-loader@~0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa" - -rc@^1.1.7: - version "1.2.1" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95" - dependencies: - deep-extend "~0.4.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -read-pkg-up@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" - dependencies: - find-up "^1.0.0" - read-pkg "^1.0.0" - -read-pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" - dependencies: - find-up "^2.0.0" - read-pkg "^2.0.0" - -read-pkg@^1.0.0, read-pkg@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" - dependencies: - load-json-file "^1.0.0" - normalize-package-data "^2.3.2" - path-type "^1.0.0" - -read-pkg@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" - dependencies: - load-json-file "^2.0.0" - normalize-package-data "^2.3.2" - path-type "^2.0.0" - -readable-stream@1.0: - version "1.0.34" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.2.9: - version "2.3.3" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - safe-buffer "~5.1.1" - string_decoder "~1.0.3" - util-deprecate "~1.0.1" - -readdirp@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" - dependencies: - graceful-fs "^4.1.2" - minimatch "^3.0.2" - readable-stream "^2.0.2" - set-immediate-shim "^1.0.1" - -readline2@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - mute-stream "0.0.5" - -rechoir@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" - dependencies: - resolve "^1.1.6" - -redent@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" - dependencies: - indent-string "^2.1.0" - strip-indent "^1.0.1" - -reduce-css-calc@^1.2.6: - version "1.3.0" - resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" - dependencies: - balanced-match "^0.4.2" - math-expression-evaluator "^1.2.14" - reduce-function-call "^1.0.1" - -reduce-function-call@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99" - dependencies: - balanced-match "^0.4.2" - -reflect-metadata@^0.1.2: - version "0.1.10" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.10.tgz#b4f83704416acad89988c9b15635d47e03b9344a" - -regenerate@^1.2.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.2.tgz#d1941c67bad437e1be76433add5b385f95b19260" - -regenerator-runtime@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz#7e54fe5b5ccd5d6624ea6255c3473be090b802e1" - -regex-cache@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.3.tgz#9b1a6c35d4d0dfcef5711ae651e8e9d3d7114145" - dependencies: - is-equal-shallow "^0.1.3" - is-primitive "^2.0.0" - -regexpu-core@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" - dependencies: - regenerate "^1.2.1" - regjsgen "^0.2.0" - regjsparser "^0.1.4" - -regjsgen@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" - -regjsparser@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" - dependencies: - jsesc "~0.5.0" - -relateurl@0.2.x: - version "0.2.7" - resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - -renderkid@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.1.tgz#898cabfc8bede4b7b91135a3ffd323e58c0db319" - dependencies: - css-select "^1.1.0" - dom-converter "~0.1" - htmlparser2 "~3.3.0" - strip-ansi "^3.0.0" - utila "~0.3" - -repeat-element@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a" - -repeat-string@^1.5.2: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - dependencies: - is-finite "^1.0.0" - -request@2, request@^2.72.0, request@^2.74.0, request@^2.79.0, request@^2.81.0: - version "2.81.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" - dependencies: - aws-sign2 "~0.6.0" - aws4 "^1.2.1" - caseless "~0.12.0" - combined-stream "~1.0.5" - extend "~3.0.0" - forever-agent "~0.6.1" - form-data "~2.1.1" - har-validator "~4.2.1" - hawk "~3.1.3" - http-signature "~1.1.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.7" - oauth-sign "~0.8.1" - performance-now "^0.2.0" - qs "~6.4.0" - safe-buffer "^5.0.1" - stringstream "~0.0.4" - tough-cookie "~2.3.0" - tunnel-agent "^0.6.0" - uuid "^3.0.0" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - -require-from-string@^1.1.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-1.2.1.tgz#529c9ccef27380adfec9a2f965b649bbee636418" - -require-main-filename@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" - -require-uncached@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" - dependencies: - caller-path "^0.1.0" - resolve-from "^1.0.0" - -requires-port@1.0.x, requires-port@1.x.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" - -resolve-bin@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/resolve-bin/-/resolve-bin-0.4.0.tgz#47132249891101afb19991fe937cb0a5f072e5d9" - dependencies: - find-parent-dir "~0.3.0" - -resolve-from@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" - -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2: - version "1.4.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86" - dependencies: - path-parse "^1.0.5" - -restore-cursor@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" - dependencies: - exit-hook "^1.0.0" - onetime "^1.0.0" - -restore-cursor@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" - dependencies: - onetime "^2.0.0" - signal-exit "^3.0.2" - -rewire@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/rewire/-/rewire-2.5.2.tgz#6427de7b7feefa7d36401507eb64a5385bc58dc7" - -right-align@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" - dependencies: - align-text "^0.1.1" - -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.1.tgz#c2338ec643df7a1b7fe5c54fa86f57428a55f33d" - dependencies: - glob "^7.0.5" - -rimraf@~2.2.6: - version "2.2.8" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582" - -ripemd160@^2.0.0, ripemd160@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" - dependencies: - hash-base "^2.0.0" - inherits "^2.0.1" - -rsvp@^3.0.17: - version "3.6.2" - resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" - -rsvp@~3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.2.1.tgz#07cb4a5df25add9e826ebc67dcc9fd89db27d84a" - -run-async@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" - dependencies: - once "^1.3.0" - -run-async@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" - dependencies: - is-promise "^2.1.0" - -rx-lite-aggregates@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be" - dependencies: - rx-lite "*" - -rx-lite@*, rx-lite@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" - -rx-lite@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" - -rxjs@^5.4.2: - version "5.4.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.4.3.tgz#0758cddee6033d68e0fd53676f0f3596ce3d483f" - dependencies: - symbol-observable "^1.0.1" - -safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - -sass-graph@^2.1.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" - dependencies: - glob "^7.0.0" - lodash "^4.0.0" - scss-tokenizer "^0.2.3" - yargs "^7.0.0" - -sass-loader@^6.0.3: - version "6.0.6" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-6.0.6.tgz#e9d5e6c1f155faa32a4b26d7a9b7107c225e40f9" - dependencies: - async "^2.1.5" - clone-deep "^0.3.0" - loader-utils "^1.0.1" - lodash.tail "^4.1.1" - pify "^3.0.0" - -sax@0.5.x: - version "0.5.8" - resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" - -sax@~1.2.1: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - -schema-utils@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf" - dependencies: - ajv "^5.0.0" - -script-loader@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/script-loader/-/script-loader-0.7.0.tgz#685dc7e7069e0dee7a92674f0ebc5b0f55baa5ec" - dependencies: - raw-loader "~0.5.1" - -scss-tokenizer@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" - dependencies: - js-base64 "^2.1.8" - source-map "^0.4.2" - -select-hose@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" - -selfsigned@^1.9.1: - version "1.10.1" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.1.tgz#bf8cb7b83256c4551e31347c6311778db99eec52" - dependencies: - node-forge "0.6.33" - -"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.3.0: - version "5.4.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" - -semver@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - -send@0.15.4: - version "0.15.4" - resolved "https://registry.yarnpkg.com/send/-/send-0.15.4.tgz#985faa3e284b0273c793364a35c6737bd93905b9" - dependencies: - debug "2.6.8" - depd "~1.1.1" - destroy "~1.0.4" - encodeurl "~1.0.1" - escape-html "~1.0.3" - etag "~1.8.0" - fresh "0.5.0" - http-errors "~1.6.2" - mime "1.3.4" - ms "2.0.0" - on-finished "~2.3.0" - range-parser "~1.2.0" - statuses "~1.3.1" - -serializerr@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/serializerr/-/serializerr-1.0.3.tgz#12d4c5aa1c3ffb8f6d1dc5f395aa9455569c3f91" - dependencies: - protochain "^1.0.5" - -serve-index@^1.7.2: - version "1.9.0" - resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.0.tgz#d2b280fc560d616ee81b48bf0fa82abed2485ce7" - dependencies: - accepts "~1.3.3" - batch "0.6.1" - debug "2.6.8" - escape-html "~1.0.3" - http-errors "~1.6.1" - mime-types "~2.1.15" - parseurl "~1.3.1" - -serve-static@1.12.4: - version "1.12.4" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.4.tgz#9b6aa98eeb7253c4eedc4c1f6fdbca609901a961" - dependencies: - encodeurl "~1.0.1" - escape-html "~1.0.3" - parseurl "~1.3.1" - send "0.15.4" - -set-blocking@^2.0.0, set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - -set-immediate-shim@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" - -setimmediate@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - -setprototypeof@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" - -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.8" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.8.tgz#37068c2c476b6baf402d14a49c67f597921f634f" - dependencies: - inherits "^2.0.1" - -shallow-clone@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-0.1.2.tgz#5909e874ba77106d73ac414cfec1ffca87d97060" - dependencies: - is-extendable "^0.1.1" - kind-of "^2.0.1" - lazy-cache "^0.2.3" - mixin-object "^2.0.1" - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - -shell-quote@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767" - dependencies: - array-filter "~0.0.0" - array-map "~0.0.0" - array-reduce "~0.0.0" - jsonify "~0.0.0" - -shelljs@^0.7.5: - version "0.7.8" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.8.tgz#decbcf874b0d1e5fb72e14b164a9683048e9acb3" - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - -signal-exit@^3.0.0, signal-exit@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - -silent-error@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/silent-error/-/silent-error-1.1.0.tgz#2209706f1c850a9f1d10d0d840918b46f26e1bc9" - dependencies: - debug "^2.2.0" - -slice-ansi@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" - -sntp@1.x.x: - version "1.0.9" - resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" - dependencies: - hoek "2.x.x" - -sockjs-client@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.2.tgz#f0212a8550e4c9468c8cceaeefd2e3493c033ad5" - dependencies: - debug "^2.2.0" - eventsource "0.1.6" - faye-websocket "~0.11.0" - inherits "^2.0.1" - json3 "^3.3.2" - url-parse "^1.1.1" - -sockjs@0.3.18: - version "0.3.18" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.18.tgz#d9b289316ca7df77595ef299e075f0f937eb4207" - dependencies: - faye-websocket "^0.10.0" - uuid "^2.0.2" - -sort-keys@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" - dependencies: - is-plain-obj "^1.0.0" - -source-list-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" - -source-map-loader@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-0.2.1.tgz#48126be9230bd47fad05e46a8c3c2e3d2dabe507" - dependencies: - async "^0.9.0" - loader-utils "~0.2.2" - source-map "~0.1.33" - -source-map-support@^0.4.0, source-map-support@^0.4.1, source-map-support@^0.4.2: - version "0.4.16" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.16.tgz#16fecf98212467d017d586a2af68d628b9421cd8" - dependencies: - source-map "^0.5.6" - -source-map@0.1.x, source-map@~0.1.33, source-map@~0.1.7: - version "0.1.43" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" - dependencies: - amdefine ">=0.0.4" - -source-map@0.5.x, source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1, source-map@~0.5.3: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - -source-map@^0.4.2, source-map@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" - dependencies: - amdefine ">=0.0.4" - -spdx-compare@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/spdx-compare/-/spdx-compare-0.1.2.tgz#b06af3ea34af7437d91a9f449eaf2d2e93c3c8fb" - dependencies: - spdx-expression-parse "^1.0.0" - spdx-ranges "^1.0.0" - -spdx-correct@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" - dependencies: - spdx-license-ids "^1.0.2" - -spdx-expression-parse@^1.0.0, spdx-expression-parse@~1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz#9bdf2f20e1f40ed447fbe273266191fced51626c" - -spdx-license-ids@^1.0.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" - -spdx-ranges@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/spdx-ranges/-/spdx-ranges-1.0.1.tgz#0f4eec7b8ea48ed202e374bb8942e8d18dc0113e" - -spdx-satisfies@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/spdx-satisfies/-/spdx-satisfies-0.1.3.tgz#67a1f274e6115d4aae28afe474db76164be10bdc" - dependencies: - spdx-compare "^0.1.2" - spdx-expression-parse "^1.0.0" - -spdy-transport@^2.0.18: - version "2.0.20" - resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.0.20.tgz#735e72054c486b2354fe89e702256004a39ace4d" - dependencies: - debug "^2.6.8" - detect-node "^2.0.3" - hpack.js "^2.1.6" - obuf "^1.1.1" - readable-stream "^2.2.9" - safe-buffer "^5.0.1" - wbuf "^1.7.2" - -spdy@^3.4.1: - version "3.4.7" - resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc" - dependencies: - debug "^2.6.8" - handle-thing "^1.2.5" - http-deceiver "^1.2.7" - safe-buffer "^5.0.1" - select-hose "^2.0.0" - spdy-transport "^2.0.18" - -split2@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/split2/-/split2-2.1.1.tgz#7a1f551e176a90ecd3345f7246a0cfe175ef4fd0" - dependencies: - through2 "^2.0.2" - -split@0.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" - dependencies: - through "2" - -split@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" - dependencies: - through "2" - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - -sshpk@^1.7.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3" - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - dashdash "^1.12.0" - getpass "^0.1.1" - optionalDependencies: - bcrypt-pbkdf "^1.0.0" - ecc-jsbn "~0.1.1" - jsbn "~0.1.0" - tweetnacl "~0.14.0" - -"statuses@>= 1.3.1 < 2", statuses@~1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" - -stdout-stream@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.0.tgz#a2c7c8587e54d9427ea9edb3ac3f2cd522df378b" - dependencies: - readable-stream "^2.0.1" - -stream-browserify@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" - dependencies: - inherits "~2.0.1" - readable-stream "^2.0.2" - -stream-combiner@~0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" - dependencies: - duplexer "~0.1.1" - -stream-http@^2.3.1: - version "2.7.2" - resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.7.2.tgz#40a050ec8dc3b53b33d9909415c02c0bf1abfbad" - dependencies: - builtin-status-codes "^3.0.0" - inherits "^2.0.1" - readable-stream "^2.2.6" - to-arraybuffer "^1.0.0" - xtend "^4.0.0" - -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - -string-width@^1.0.1, string-width@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -string-width@^2.0.0, string-width@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -string.prototype.padend@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz#f3aaef7c1719f170c5eab1c32bf780d96e21f2f0" - dependencies: - define-properties "^1.1.2" - es-abstract "^1.4.3" - function-bind "^1.0.2" - -string_decoder@^0.10.25, string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - -string_decoder@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" - dependencies: - safe-buffer "~5.1.0" - -stringstream@~0.0.4: - version "0.0.5" - resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - dependencies: - ansi-regex "^3.0.0" - -strip-bom@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" - dependencies: - is-utf8 "^0.2.0" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - -strip-indent@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" - dependencies: - get-stdin "^4.0.1" - -strip-json-comments@^2.0.0, strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - -style-loader@^0.13.1: - version "0.13.2" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.13.2.tgz#74533384cf698c7104c7951150b49717adc2f3bb" - dependencies: - loader-utils "^1.0.2" - -stylus-loader@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/stylus-loader/-/stylus-loader-3.0.1.tgz#77f4b34fd030d25b2617bcf5513db5b0730c4089" - dependencies: - loader-utils "^1.0.2" - lodash.clonedeep "^4.5.0" - when "~3.6.x" - -stylus@^0.54.5: - version "0.54.5" - resolved "https://registry.yarnpkg.com/stylus/-/stylus-0.54.5.tgz#42b9560931ca7090ce8515a798ba9e6aa3d6dc79" - dependencies: - css-parse "1.7.x" - debug "*" - glob "7.0.x" - mkdirp "0.5.x" - sax "0.5.x" - source-map "0.1.x" - -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - -supports-color@^3.1.1, supports-color@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" - dependencies: - has-flag "^1.0.0" - -supports-color@^4.0.0, supports-color@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.2.1.tgz#65a4bb2631e90e02420dba5554c375a4754bb836" - dependencies: - has-flag "^2.0.0" - -svgo@^0.7.0: - version "0.7.2" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" - dependencies: - coa "~1.0.1" - colors "~1.1.2" - csso "~2.3.1" - js-yaml "~3.7.0" - mkdirp "~0.5.1" - sax "~1.2.1" - whet.extend "~0.9.9" - -symbol-observable@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d" - -sync-exec@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105" - -table@^3.7.8: - version "3.8.3" - resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f" - dependencies: - ajv "^4.7.0" - ajv-keywords "^1.0.0" - chalk "^1.1.1" - lodash "^4.0.0" - slice-ansi "0.0.4" - string-width "^2.0.0" - -tapable@^0.2.7: - version "0.2.8" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22" - -tar-pack@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984" - dependencies: - debug "^2.2.0" - fstream "^1.0.10" - fstream-ignore "^1.0.5" - once "^1.3.3" - readable-stream "^2.1.4" - rimraf "^2.5.1" - tar "^2.2.1" - uid-number "^0.0.6" - -tar@^2.0.0, tar@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" - dependencies: - block-stream "*" - fstream "^1.0.2" - inherits "2" - -tar@^3.1.5: - version "3.2.0" - resolved "https://registry.yarnpkg.com/tar/-/tar-3.2.0.tgz#534255ba01befa11f91257cc7fa9cea773596852" - dependencies: - chownr "^1.0.1" - minipass "^2.0.2" - minizlib "^1.0.3" - mkdirp "^0.5.0" - yallist "^3.0.2" - -temp@0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.3.tgz#e0c6bc4d26b903124410e4fed81103014dfc1f59" - dependencies: - os-tmpdir "^1.0.0" - rimraf "~2.2.6" - -text-extensions@^1.0.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.5.0.tgz#d1cb2d14b5d0bc45bfdca8a08a473f68c7eb0cbc" - -text-table@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - -through2@^2.0.0, through2@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" - dependencies: - readable-stream "^2.1.5" - xtend "~4.0.1" - -through@2, "through@>=2.2.7 <3", through@^2.3.6, through@~2.3, through@~2.3.1: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - -thunky@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/thunky/-/thunky-0.1.0.tgz#bf30146824e2b6e67b0f2d7a4ac8beb26908684e" - -time-stamp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-2.0.0.tgz#95c6a44530e15ba8d6f4a3ecb8c3a3fac46da357" - -timers-browserify@^2.0.2: - version "2.0.4" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.4.tgz#96ca53f4b794a5e7c0e1bd7cc88a372298fa01e6" - dependencies: - setimmediate "^1.0.4" - -tmp@^0.0.31: - version "0.0.31" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" - dependencies: - os-tmpdir "~1.0.1" - -to-arraybuffer@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" - -to-fast-properties@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" - -toposort@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.3.tgz#f02cd8a74bd8be2fc0e98611c3bacb95a171869c" - -tough-cookie@~2.3.0: - version "2.3.2" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" - dependencies: - punycode "^1.4.1" - -tree-kill@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.1.0.tgz#c963dcf03722892ec59cba569e940b71954d1729" - -trim-newlines@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" - -trim-off-newlines@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" - -trim-right@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" - -tryit@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" - -ts-node@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-3.3.0.tgz#c13c6a3024e30be1180dd53038fc209289d4bf69" - dependencies: - arrify "^1.0.0" - chalk "^2.0.0" - diff "^3.1.0" - make-error "^1.1.1" - minimist "^1.2.0" - mkdirp "^0.5.1" - source-map-support "^0.4.0" - tsconfig "^6.0.0" - v8flags "^3.0.0" - yn "^2.0.0" - -tsconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-6.0.0.tgz#6b0e8376003d7af1864f8df8f89dd0059ffcd032" - dependencies: - strip-bom "^3.0.0" - strip-json-comments "^2.0.0" - -tsickle@^0.21.0: - version "0.21.6" - resolved "https://registry.yarnpkg.com/tsickle/-/tsickle-0.21.6.tgz#53b01b979c5c13fdb13afb3fb958177e5991588d" - dependencies: - minimist "^1.2.0" - mkdirp "^0.5.1" - source-map "^0.5.6" - source-map-support "^0.4.2" - -tslib@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.7.1.tgz#bc8004164691923a79fe8378bbeb3da2017538ec" - -tslint@^5.1.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.6.0.tgz#088aa6c6026623338650b2900828ab3edf59f6cf" - dependencies: - babel-code-frame "^6.22.0" - colors "^1.1.2" - commander "^2.9.0" - diff "^3.2.0" - glob "^7.1.1" - minimatch "^3.0.4" - resolve "^1.3.2" - semver "^5.3.0" - tslib "^1.7.1" - tsutils "^2.7.1" - -tsutils@^2.7.1: - version "2.8.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.8.0.tgz#0160173729b3bf138628dd14a1537e00851d814a" - dependencies: - tslib "^1.7.1" - -tty-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - dependencies: - prelude-ls "~1.1.2" - -type-is@~1.6.15: - version "1.6.15" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" - dependencies: - media-typer "0.3.0" - mime-types "~2.1.15" - -typedarray@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - -typescript@^2.3.3, typescript@~2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.4.2.tgz#f8395f85d459276067c988aa41837a8f82870844" - -uglify-js@3.0.x: - version "3.0.27" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.0.27.tgz#a97db8c8ba6b9dba4e2f88d86aa9548fa6320034" - dependencies: - commander "~2.11.0" - source-map "~0.5.1" - -uglify-js@^2.6, uglify-js@^2.8.29: - version "2.8.29" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" - dependencies: - source-map "~0.5.1" - yargs "~3.10.0" - optionalDependencies: - uglify-to-browserify "~1.0.0" - -uglify-js@~2.3: - version "2.3.6" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.3.6.tgz#fa0984770b428b7a9b2a8058f46355d14fef211a" - dependencies: - async "~0.2.6" - optimist "~0.3.5" - source-map "~0.1.7" - -uglify-to-browserify@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" - -uglifyjs-webpack-plugin@^0.4.6: - version "0.4.6" - resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309" - dependencies: - source-map "^0.5.6" - uglify-js "^2.8.29" - webpack-sources "^1.0.1" - -uid-number@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" - -uniq@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" - -uniqid@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/uniqid/-/uniqid-4.1.1.tgz#89220ddf6b751ae52b5f72484863528596bb84c1" - dependencies: - macaddress "^0.2.8" - -uniqs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" - -universalify@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.1.tgz#fa71badd4437af4c148841e3b3b165f9e9e590b7" - -unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - -upper-case@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" - -url-loader@^0.5.7: - version "0.5.9" - resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.5.9.tgz#cc8fea82c7b906e7777019250869e569e995c295" - dependencies: - loader-utils "^1.0.2" - mime "1.3.x" - -url-parse@1.0.x: - version "1.0.5" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.0.5.tgz#0854860422afdcfefeb6c965c662d4800169927b" - dependencies: - querystringify "0.0.x" - requires-port "1.0.x" - -url-parse@^1.1.1: - version "1.1.9" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.1.9.tgz#c67f1d775d51f0a18911dd7b3ffad27bb9e5bd19" - dependencies: - querystringify "~1.0.0" - requires-port "1.0.x" - -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -user-home@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" - -user-home@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f" - dependencies: - os-homedir "^1.0.0" - -util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - -util@0.10.3, util@^0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - dependencies: - inherits "2.0.1" - -utila@~0.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.3.3.tgz#d7e8e7d7e309107092b05f8d9688824d633a4226" - -utila@~0.4: - version "0.4.0" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" - -utils-merge@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" - -uuid@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" - -uuid@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" - -v8flags@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.0.0.tgz#4be9604488e0c4123645def705b1848d16b8e01f" - dependencies: - user-home "^1.1.1" - -validate-npm-package-license@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc" - dependencies: - spdx-correct "~1.0.0" - spdx-expression-parse "~1.0.0" - -vary@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37" - -vendors@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22" - -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -vlq@^0.2.1: - version "0.2.2" - resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.2.tgz#e316d5257b40b86bb43cb8d5fea5d7f54d6b0ca1" - -vm-browserify@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" - dependencies: - indexof "0.0.1" - -walk-sync@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/walk-sync/-/walk-sync-0.3.2.tgz#4827280afc42d0e035367c4a4e31eeac0d136f75" - dependencies: - ensure-posix-path "^1.0.0" - matcher-collection "^1.0.0" - -watchpack@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac" - dependencies: - async "^2.1.2" - chokidar "^1.7.0" - graceful-fs "^4.1.2" - -wbuf@^1.1.0, wbuf@^1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.2.tgz#d697b99f1f59512df2751be42769c1580b5801fe" - dependencies: - minimalistic-assert "^1.0.0" - -webpack-dev-middleware@^1.11.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.12.0.tgz#d34efefb2edda7e1d3b5dbe07289513219651709" - dependencies: - memory-fs "~0.4.1" - mime "^1.3.4" - path-is-absolute "^1.0.0" - range-parser "^1.0.3" - time-stamp "^2.0.0" - -webpack-dev-server@~2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.5.1.tgz#a02e726a87bb603db5d71abb7d6d2649bf10c769" - dependencies: - ansi-html "0.0.7" - bonjour "^3.5.0" - chokidar "^1.6.0" - compression "^1.5.2" - connect-history-api-fallback "^1.3.0" - del "^3.0.0" - express "^4.13.3" - html-entities "^1.2.0" - http-proxy-middleware "~0.17.4" - internal-ip "^1.2.0" - opn "4.0.2" - portfinder "^1.0.9" - selfsigned "^1.9.1" - serve-index "^1.7.2" - sockjs "0.3.18" - sockjs-client "1.1.2" - spdy "^3.4.1" - strip-ansi "^3.0.0" - supports-color "^3.1.1" - webpack-dev-middleware "^1.11.0" - yargs "^6.0.0" - -webpack-merge@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.1.0.tgz#6ad72223b3e0b837e531e4597c199f909361511e" - dependencies: - lodash "^4.17.4" - -webpack-sources@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf" - dependencies: - source-list-map "^2.0.0" - source-map "~0.5.3" - -webpack@~3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.4.1.tgz#4c3f4f3fb318155a4db0cb6a36ff05c5697418f4" - dependencies: - acorn "^5.0.0" - acorn-dynamic-import "^2.0.0" - ajv "^5.1.5" - ajv-keywords "^2.0.0" - async "^2.1.2" - enhanced-resolve "^3.4.0" - escope "^3.6.0" - interpret "^1.0.0" - json-loader "^0.5.4" - json5 "^0.5.1" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - mkdirp "~0.5.0" - node-libs-browser "^2.0.0" - source-map "^0.5.3" - supports-color "^4.2.1" - tapable "^0.2.7" - uglifyjs-webpack-plugin "^0.4.6" - watchpack "^1.4.0" - webpack-sources "^1.0.1" - yargs "^8.0.2" - -websocket-driver@>=0.5.1: - version "0.6.5" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" - dependencies: - websocket-extensions ">=0.1.1" - -websocket-extensions@>=0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.1.tgz#76899499c184b6ef754377c2dbb0cd6cb55d29e7" - -when@~3.6.x: - version "3.6.4" - resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e" - -whet.extend@~0.9.9: - version "0.9.9" - resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" - -which-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - -which@1, which@^1.2.10, which@^1.2.9: - version "1.3.0" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a" - dependencies: - isexe "^2.0.0" - -wide-align@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" - dependencies: - string-width "^1.0.2" - -window-size@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" - -wordwrap@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" - -wordwrap@~0.0.2: - version "0.0.3" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" - -wordwrap@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - -write@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" - dependencies: - mkdirp "^0.5.1" - -xml-char-classes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/xml-char-classes/-/xml-char-classes-1.0.0.tgz#64657848a20ffc5df583a42ad8a277b4512bbc4d" - -xmldom@^0.1.19: - version "0.1.27" - resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9" - -xtend@^4.0.0, xtend@~4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" - -y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - -yallist@^3.0.0, yallist@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9" - -yargs-parser@^4.2.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-4.2.1.tgz#29cceac0dc4f03c6c87b4a9f217dd18c9f74871c" - dependencies: - camelcase "^3.0.0" - -yargs-parser@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" - dependencies: - camelcase "^3.0.0" - -yargs-parser@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" - dependencies: - camelcase "^4.1.0" - -yargs@^6.0.0: - version "6.6.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-6.6.0.tgz#782ec21ef403345f830a808ca3d513af56065208" - dependencies: - camelcase "^3.0.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^1.4.0" - read-pkg-up "^1.0.1" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^1.0.2" - which-module "^1.0.0" - y18n "^3.2.1" - yargs-parser "^4.2.0" - -yargs@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" - dependencies: - camelcase "^3.0.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^1.4.0" - read-pkg-up "^1.0.1" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^1.0.2" - which-module "^1.0.0" - y18n "^3.2.1" - yargs-parser "^5.0.0" - -yargs@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360" - dependencies: - camelcase "^4.1.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^2.0.0" - read-pkg-up "^2.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1" - yargs-parser "^7.0.0" - -yargs@~3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" - dependencies: - camelcase "^1.0.2" - cliui "^2.1.0" - decamelize "^1.0.0" - window-size "0.1.0" - -yn@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" - -zone.js@^0.8.14: - version "0.8.16" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.8.16.tgz#ac31b6c418f88c0f918ad6acd8a402aca9313abb"