diff --git a/.bazelignore b/.bazelignore index ee1e0a664e87..52ebdac6af4a 100644 --- a/.bazelignore +++ b/.bazelignore @@ -4,6 +4,8 @@ node_modules packages/angular/cli/node_modules packages/angular/create/node_modules packages/angular/pwa/node_modules +packages/angular/build/node_modules +packages/angular/ssr/node_modules packages/angular_devkit/architect/node_modules packages/angular_devkit/architect_cli/node_modules packages/angular_devkit/build_angular/node_modules @@ -12,4 +14,7 @@ packages/angular_devkit/core/node_modules packages/angular_devkit/schematics/node_modules packages/angular_devkit/schematics_cli/node_modules packages/ngtools/webpack/node_modules -packages/schematics/angular/node_modules \ No newline at end of file +packages/schematics/angular/node_modules +modules/testing/builder/node_modules +tests/node_modules +tools/baseline_browserslist/node_modules diff --git a/.bazelrc b/.bazelrc index d742b8bbb40c..4f79c86cf3b4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,6 +1,10 @@ # 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 @@ -29,10 +33,6 @@ test:no-sharding --flaky_test_attempts=1 --test_sharding_strategy=disabled # See https://github.com/bazelbuild/bazel/issues/4603 build --symlink_prefix=dist/ -# Disable watchfs as it causes tests to be flaky on Windows -# https://github.com/angular/angular/issues/29541 -build --nowatchfs - # Turn off legacy external runfiles build --nolegacy_external_runfiles @@ -40,7 +40,7 @@ build --nolegacy_external_runfiles # 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 `yarn bazel`. +# when running bazel via `pnpm bazel`. # See https://github.com/angular/angular/issues/27514. build --incompatible_strict_action_env run --incompatible_strict_action_env @@ -52,9 +52,6 @@ build --experimental_remote_merkle_tree_cache # Ensure that tags applied in BUILDs propagate to actions common --experimental_allow_tags_propagation -# Don't check if output files have been modified -build --noexperimental_check_output_files - # Ensure sandboxing is enabled even for exclusive tests test --incompatible_exclusive_test_sandboxed @@ -83,16 +80,19 @@ test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED # 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="yarn -s ng-dev release build-env-stamp --mode=release" +build:release --workspace_status_command="pnpm -s ng-dev release build-env-stamp --mode=release" build:release --stamp -build:snapshot --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --workspace_status_command="pnpm -s ng-dev release build-env-stamp --mode=snapshot" build:snapshot --stamp build:snapshot --//:enable_snapshot_repo_deps -build:e2e --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=release" +build:e2e --workspace_status_command="pnpm -s ng-dev release build-env-stamp --mode=release" build:e2e --stamp -test:e2e --test_timeout=3600 +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 @@ -106,13 +106,6 @@ query --output=label_kind # By default, failing tests don't print any output, it goes to the log file test --test_output=errors - -################################ -# Settings for CircleCI # -################################ - -# Bazel flags for CircleCI are in /.circleci/bazel.rc - ################################ # Remote Execution Setup # ################################ @@ -133,14 +126,13 @@ 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 --crosstool_top=@npm//@angular/build-tooling/bazel/remote-execution/cpp:cc_toolchain_suite -build:remote --extra_toolchains=@npm//@angular/build-tooling/bazel/remote-execution/cpp:cc_toolchain -build:remote --extra_execution_platforms=@npm//@angular/build-tooling/bazel/remote-execution:platform_with_network -build:remote --host_platform=@npm//@angular/build-tooling/bazel/remote-execution:platform_with_network -build:remote --platforms=@npm//@angular/build-tooling/bazel/remote-execution:platform_with_network +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. @@ -150,14 +142,32 @@ build:remote --host_cpu=k8 # Set up authentication mechanism for RBE build:remote --google_default_credentials -############################### -# NodeJS rules settings -# These settings are required for rules_nodejs -############################### +# 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 use of npm paths with spaces such as some within the puppeteer module +# 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. @@ -166,7 +176,3 @@ build --experimental_inprocess_symlink_creation # 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 - -# Enable runfiles even on Windows. -# Architect resolves output files from data files, and this isn't possible without runfile support. -build --enable_runfiles diff --git a/.bazelversion b/.bazelversion index 03f488b076ae..e8be68404bcb 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -5.3.0 +7.6.1 diff --git a/.circleci/bazel.common.rc b/.circleci/bazel.common.rc deleted file mode 100644 index 1e8cad37a5ec..000000000000 --- a/.circleci/bazel.common.rc +++ /dev/null @@ -1,35 +0,0 @@ -# These options are enabled when running on CI -# We do this by copying this file to /etc/bazel.bazelrc at the start of the build. - -# Echo all the configuration settings and their source -build --announce_rc - -# Print extra information for build failures to help with debugging. -build --verbose_failures - -# Show progress so CI doesn't appear to be stuck, but rate limit to avoid -# spamming the log. -build --show_progress_rate_limit 5 - -# Improve the UI for rendering to a CI log. -build --curses yes --color yes --terminal_columns 140 --show_timestamps - -# Workaround https://github.com/bazelbuild/bazel/issues/3645 -# Bazel doesn't calculate the memory ceiling correctly when running under Docker. -# Limit Bazel to consuming resources that fit in CircleCI "xlarge" class -# https://circleci.com/docs/2.0/configuration-reference/#resource_class -build --local_cpu_resources=8 -build --local_ram_resources=14336 - -# More details on failures -build --verbose_failures=true - -# Retry in the event of flakes -test --flaky_test_attempts=2 - -# Run as many tests as possible so we capture all the failures. -test --keep_going - -# Don't build targets not needed for tests. `build_test()` should be used if a -# target should be verified as buildable on CI. -test --build_tests_only diff --git a/.circleci/bazel.linux.rc b/.circleci/bazel.linux.rc deleted file mode 100644 index 6a4d30ed44f8..000000000000 --- a/.circleci/bazel.linux.rc +++ /dev/null @@ -1,5 +0,0 @@ -# Import config items common to both Linux and Windows setups. -# https://docs.bazel.build/versions/master/guide.html#bazelrc-syntax-and-semantics -import %workspace%/.circleci/bazel.common.rc - -build --config=remote diff --git a/.circleci/bazel.windows.rc b/.circleci/bazel.windows.rc deleted file mode 100644 index c9cba94c10cc..000000000000 --- a/.circleci/bazel.windows.rc +++ /dev/null @@ -1,8 +0,0 @@ -# Import config items common to both Linux and Windows setups. -# https://docs.bazel.build/versions/master/guide.html#bazelrc-syntax-and-semantics -import %workspace%/.circleci/bazel.common.rc - -build --remote_cache=https://storage.googleapis.com/angular-cli-windows-bazel-cache -build --remote_accept_cached=true -build --remote_upload_local_results=true -build --google_default_credentials diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 5f1aebbeb5c0..000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2.1 -orbs: - path-filtering: circleci/path-filtering@0.1.3 - -# This allows you to use CircleCI's dynamic configuration feature -setup: true - -workflows: - run-filter: - jobs: - - path-filtering/filter: - # Compare files on main - base-revision: main - # 3-column space-separated table for mapping; `path-to-test parameter-to-set value-for-parameter` for each row - mapping: | - tests/legacy-cli/e2e/ng-snapshot/package.json snapshot_changed true - config-path: '.circleci/dynamic_config.yml' diff --git a/.circleci/dynamic_config.yml b/.circleci/dynamic_config.yml deleted file mode 100644 index 092691c9cf57..000000000000 --- a/.circleci/dynamic_config.yml +++ /dev/null @@ -1,383 +0,0 @@ -# Configuration file for https://circleci.com/gh/angular/angular-cli - -# Note: YAML anchors allow an object to be re-used, reducing duplication. -# The ampersand declares an alias for an object, then later the `<<: *name` -# syntax dereferences it. -# See http://blog.daemonl.com/2016/02/yaml.html -# To validate changes, use an online parser, eg. -# http://yaml-online-parser.appspot.com/ - -version: 2.1 - -orbs: - browser-tools: circleci/browser-tools@1.4.0 - devinfra: angular/dev-infra@1.0.8 - -parameters: - snapshot_changed: - type: boolean - default: false - -# Variables - -## IMPORTANT -# Windows needs its own cache key because binaries in node_modules are different. -# See https://circleci.com/docs/2.0/caching/#restoring-cache for how prefixes work in CircleCI. -var_1: &cache_key v1-angular_devkit-16.14-{{ checksum "yarn.lock" }} -var_1_win: &cache_key_win v1-angular_devkit-win-16.14-{{ checksum "yarn.lock" }} -var_3: &default_nodeversion '16.14' -var_3_major: &default_nodeversion_major '16' -# The major version of node toolchains. See tools/toolchain_info.bzl -# NOTE: entries in this array may be repeated elsewhere in the file, find them before adding more -var_3_all_major: &all_nodeversion_major ['16', '18'] -# Workspace initially persisted by the `setup` job, and then enhanced by `setup-and-build-win`. -# https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs -# https://circleci.com/blog/deep-diving-into-circleci-workspaces/ -var_4: &workspace_location . -# Filter to only release branches on a given job. -var_5_only_releases: &only_release_branches - filters: - branches: - only: - - main - - /\d+\.\d+\.x/ -var_5_only_snapshots: &only_snapshot_branches - filters: - branches: - only: - - main - # This is needed to run this steps on Renovate PRs that amend the snapshots package.json - - /^pull\/.*/ - -var_6: &only_pull_requests - filters: - branches: - only: - - /pull\/\d+/ - -var_7: &only_builds_branches - filters: - branches: - only: - - main - - /\d+\.\d+\.x/ - - ^feature\-.* - -# All e2e test suites -var_8: &all_e2e_subsets ['npm', 'esbuild', 'yarn'] - -# Executor Definitions -# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-executors -executors: - action-executor: - parameters: - nodeversion: - type: string - default: *default_nodeversion - docker: - - image: cimg/node:<< parameters.nodeversion >> - working_directory: ~/ng - resource_class: small - - bazel-executor: - parameters: - nodeversion: - type: string - default: *default_nodeversion - docker: - - image: cimg/node:<< parameters.nodeversion >>-browsers - working_directory: ~/ng - resource_class: xlarge - - windows-executor: - # Same as https://circleci.com/orbs/registry/orb/circleci/windows, but named. - working_directory: ~/ng - resource_class: windows.large - shell: powershell.exe -ExecutionPolicy Bypass - machine: - # Contents of this image: - # https://circleci.com/developer/machine/image/windows-server-2022-gui - image: 'windows-server-2022-gui:current' - -# Command Definitions -# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-commands -commands: - fail_fast: - steps: - - run: - name: 'Cancel workflow on fail' - shell: bash - when: on_fail - command: | - curl -X POST --header "Content-Type: application/json" "https://circleci.com/api/v2/workflow/${CIRCLE_WORKFLOW_ID}/cancel?circle-token=${CIRCLE_TOKEN}" - - initialize_env: - steps: - - run: - name: Initialize Environment - command: ./.circleci/env.sh - - rebase_pr: - steps: - - devinfra/rebase-pr-on-target-branch: - base_revision: << pipeline.git.base_revision >> - head_revision: << pipeline.git.revision >> - - rebase_pr_win: - steps: - - devinfra/rebase-pr-on-target-branch: - base_revision: << pipeline.git.base_revision >> - head_revision: << pipeline.git.revision >> - # Use `bash.exe` as Shell because the CircleCI-orb command is an - # included Bash script and expects Bash as shell. - shell: bash.exe - - custom_attach_workspace: - description: Attach workspace at a predefined location - steps: - - attach_workspace: - at: *workspace_location - - setup_windows: - steps: - - initialize_env - - run: nvm install 16.14.2 - - run: nvm use 16.14.2 - - run: npm install -g yarn@1.22.10 @bazel/bazelisk@${BAZELISK_VERSION} - - run: node --version - - run: yarn --version - - setup_bazel_rbe: - parameters: - key: - type: env_var_name - default: CIRCLE_PROJECT_REPONAME - steps: - - run: - name: 'Copy Bazel RC' - shell: bash - command: | - # Conditionally, copy bazel configuration based on the current VM - # operating system running. We detect Windows by checking for `%AppData%`. - if [[ -n "${APPDATA}" ]]; then - cp "./.circleci/bazel.windows.rc" ".bazelrc.user"; - else - cp "./.circleci/bazel.linux.rc" ".bazelrc.user"; - fi - - devinfra/setup-bazel-remote-exec: - shell: bash - -# Job definitions -jobs: - setup: - executor: action-executor - resource_class: medium - steps: - - checkout - - rebase_pr - - initialize_env - - restore_cache: - keys: - - *cache_key - - run: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - persist_to_workspace: - root: *workspace_location - paths: - - ./* - - save_cache: - key: *cache_key - paths: - - ~/.cache/yarn - - # TODO: Remove once no other jobs rely on it anymore. - build: - executor: bazel-executor - steps: - - custom_attach_workspace - - setup_bazel_rbe - - run: - name: Bazel Build Packages - command: yarn bazel build //... - - fail_fast - - e2e-tests: - executor: bazel-executor - parallelism: 8 - parameters: - nodeversion: - type: string - default: *default_nodeversion - snapshots: - type: boolean - default: false - subset: - type: enum - enum: *all_e2e_subsets - default: 'npm' - steps: - - custom_attach_workspace - - initialize_env - - setup_bazel_rbe - - run: mkdir /mnt/ramdisk/e2e - - run: - name: Execute CLI E2E Tests with << parameters.subset >> - command: yarn bazel test --define=E2E_TEMP=/mnt/ramdisk/e2e --define=E2E_SHARD_TOTAL=${CIRCLE_NODE_TOTAL} --define=E2E_SHARD_INDEX=${CIRCLE_NODE_INDEX} --config=e2e //tests/legacy-cli:e2e<<# parameters.snapshots >>.snapshots<>.<< parameters.subset >>_node<< parameters.nodeversion >> - no_output_timeout: 40m - - store_artifacts: - path: dist/testlogs/tests/legacy-cli/e2e<<# parameters.snapshots >>.snapshots<>.<< parameters.subset >>_node<< parameters.nodeversion >> - - store_test_results: - path: dist/testlogs/tests/legacy-cli/e2e<<# parameters.snapshots >>.snapshots<>.<< parameters.subset >>_node<< parameters.nodeversion >> - - fail_fast - - test-browsers: - executor: bazel-executor - steps: - - custom_attach_workspace - - initialize_env - - setup_bazel_rbe - - run: - name: Initialize Saucelabs - command: setSecretVar SAUCE_ACCESS_KEY $(echo $SAUCE_ACCESS_KEY | rev) - - run: - name: Start Saucelabs Tunnel - command: ./scripts/saucelabs/start-tunnel.sh - background: true - # Waits for the Saucelabs tunnel to be ready. This ensures that we don't run tests - # too early without Saucelabs not being ready. - - run: ./scripts/saucelabs/wait-for-tunnel.sh - - run: - name: E2E Saucelabs Tests - command: yarn bazel test --config=saucelabs //tests/legacy-cli:e2e.saucelabs - - run: ./scripts/saucelabs/stop-tunnel.sh - - store_artifacts: - path: dist/testlogs/tests/legacy-cli/e2e.saucelabs - - store_test_results: - path: dist/testlogs/tests/legacy-cli/e2e.saucelabs - - fail_fast - - snapshot_publish: - executor: action-executor - resource_class: medium - steps: - - custom_attach_workspace - - run: - name: Deployment to Snapshot - command: yarn admin snapshots --verbose - - fail_fast - - # Windows jobs - e2e-cli-win: - executor: windows-executor - parallelism: 12 - steps: - - checkout - - setup_windows - - rebase_pr_win - - setup_bazel_rbe - - restore_cache: - keys: - - *cache_key_win - - run: - # We use Arsenal Image Mounter (AIM) instead of ImDisk because of: https://github.com/nodejs/node/issues/6861 - # Useful resources for AIM: http://reboot.pro/index.php?showtopic=22068 - name: 'Arsenal Image Mounter (RAM Disk)' - command: | - pwsh ./.circleci/win-ram-disk.ps1 - - run: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - save_cache: - key: *cache_key_win - paths: - - ~/.cache/yarn - # Path where Arsenal Image Mounter files are downloaded. - # Must match path in .circleci/win-ram-disk.ps1 - - ./aim - - run: - name: Execute E2E Tests - environment: - # Required by `yarn ng-dev` - # See https://github.com/angular/angular/issues/46858 - PWD: . - command: | - mkdir X:/ramdisk/e2e - bazel test --define=E2E_TEMP=X:/ramdisk/e2e --define=E2E_SHARD_TOTAL=$env:CIRCLE_NODE_TOTAL --define=E2E_SHARD_INDEX=$env:CIRCLE_NODE_INDEX --config=e2e //tests/legacy-cli:e2e.npm_node16 - # This timeout provides time for the actual tests to timeout and report status - # instead of CircleCI stopping the job without test failure information. - no_output_timeout: 40m - - fail_fast - - store_artifacts: - path: dist/testlogs/tests/legacy-cli/e2e.npm_node16 - - store_test_results: - path: dist/testlogs/tests/legacy-cli/e2e.npm_node16 - -workflows: - version: 2 - default_workflow: - jobs: - # Linux jobs - - setup - - # Bazel jobs - - build: - requires: - - setup - - - e2e-tests: - name: e2e-cli-<< matrix.subset >> - nodeversion: *default_nodeversion_major - matrix: - parameters: - subset: *all_e2e_subsets - filters: - branches: - ignore: - - main - - /\d+\.\d+\.x/ - requires: - - build - - - e2e-tests: - name: e2e-cli-node-<>-<< matrix.subset >> - matrix: - alias: e2e-cli - parameters: - nodeversion: *all_nodeversion_major - subset: *all_e2e_subsets - requires: - - build - <<: *only_release_branches - - - e2e-tests: - name: e2e-snapshots-<< matrix.subset >> - nodeversion: *default_nodeversion_major - matrix: - parameters: - subset: *all_e2e_subsets - snapshots: true - pre-steps: - - when: - # Don't run snapshot E2E's unless it's on the main branch or the snapshots file has been updated. - condition: - and: - - not: - equal: [main, << pipeline.git.branch >>] - - not: << pipeline.parameters.snapshot_changed >> - steps: - - run: circleci-agent step halt - requires: - - build - <<: *only_snapshot_branches - - - test-browsers: - requires: - - build - - # Windows jobs - - e2e-cli-win: - <<: *only_release_branches - - # Publish jobs - - snapshot_publish: - <<: *only_builds_branches - requires: - - setup - - e2e-cli diff --git a/.circleci/env-helpers.inc.sh b/.circleci/env-helpers.inc.sh deleted file mode 100644 index 5fa1263e112f..000000000000 --- a/.circleci/env-helpers.inc.sh +++ /dev/null @@ -1,73 +0,0 @@ -#################################################################################################### -# Helpers for defining environment variables for CircleCI. -# -# In CircleCI, each step runs in a new shell. The way to share ENV variables across steps is to -# export them from `$BASH_ENV`, which is automatically sourced at the beginning of every step (for -# the default `bash` shell). -# -# See also https://circleci.com/docs/2.0/env-vars/#using-bash_env-to-set-environment-variables. -#################################################################################################### - -# Set and print an environment variable. -# -# Use this function for setting environment variables that are public, i.e. it is OK for them to be -# visible to anyone through the CI logs. -# -# Usage: `setPublicVar ` -function setPublicVar() { - setSecretVar $1 "$2"; - echo "$1=$2"; -} - -# Set (without printing) an environment variable. -# -# Use this function for setting environment variables that are secret, i.e. should not be visible to -# everyone through the CI logs. -# -# Usage: `setSecretVar ` -function setSecretVar() { - # WARNING: Secrets (e.g. passwords, access tokens) should NOT be printed. - # (Keep original shell options to restore at the end.) - local -r originalShellOptions=$(set +o); - set +x -eu -o pipefail; - - echo "export $1=\"${2:-}\";" >> $BASH_ENV; - - # Restore original shell options. - eval "$originalShellOptions"; -} - - -# Create a function to set an environment variable, when called. -# -# Use this function for creating setter for public environment variables that require expensive or -# time-consuming computaions and may not be needed. When needed, you can call this function to set -# the environment variable (which will be available through `$BASH_ENV` from that point onwards). -# -# Arguments: -# - ``: The name of the environment variable. The generated setter function will be -# `setPublicVar_`. -# - ``: The code to run to compute the value for the variable. Since this code should be -# executed lazily, it must be properly escaped. For example: -# ```sh -# # DO NOT do this: -# createPublicVarSetter MY_VAR "$(whoami)"; # `whoami` will be evaluated eagerly -# -# # DO this isntead: -# createPublicVarSetter MY_VAR "\$(whoami)"; # `whoami` will NOT be evaluated eagerly -# ``` -# -# Usage: `createPublicVarSetter ` -# -# Example: -# ```sh -# createPublicVarSetter MY_VAR 'echo "FOO"'; -# echo $MY_VAR; # Not defined -# -# setPublicVar_MY_VAR; -# source $BASH_ENV; -# echo $MY_VAR; # FOO -# ``` -function createPublicVarSetter() { - echo "setPublicVar_$1() { setPublicVar $1 \"$2\"; }" >> $BASH_ENV; -} diff --git a/.circleci/env.sh b/.circleci/env.sh deleted file mode 100755 index e6ae354a6a7c..000000000000 --- a/.circleci/env.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash - -# Variables -readonly projectDir=$(realpath "$(dirname ${BASH_SOURCE[0]})/..") -readonly envHelpersPath="$projectDir/.circleci/env-helpers.inc.sh"; - -# Load helpers and make them available everywhere (through `$BASH_ENV`). -source $envHelpersPath; -echo "source $envHelpersPath;" >> $BASH_ENV; - - -#################################################################################################### -# Define PUBLIC environment variables for CircleCI. -#################################################################################################### -# See https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables for more info. -#################################################################################################### -setPublicVar PROJECT_ROOT "$projectDir"; -setPublicVar NPM_CONFIG_PREFIX "${HOME}/.npm-global"; -setPublicVar PATH "${HOME}/.npm-global/bin:${PATH}"; - -#################################################################################################### -# Define SauceLabs environment variables for CircleCI. -#################################################################################################### -setPublicVar SAUCE_USERNAME "angular-tooling"; -setSecretVar SAUCE_ACCESS_KEY "e05dabf6fe0e-2c18-abf4-496d-1d010490"; -setPublicVar SAUCE_LOG_FILE /tmp/angular/sauce-connect.log -setPublicVar SAUCE_READY_FILE /tmp/angular/sauce-connect-ready-file.lock -setPublicVar SAUCE_PID_FILE /tmp/angular/sauce-connect-pid-file.lock -setPublicVar SAUCE_TUNNEL_IDENTIFIER "angular-${CIRCLE_BUILD_NUM}-${CIRCLE_NODE_INDEX}" -# Amount of seconds we wait for sauceconnect to establish a tunnel instance. In order to not -# acquire CircleCI instances for too long if sauceconnect failed, we need a connect timeout. -setPublicVar SAUCE_READY_FILE_TIMEOUT 120 - -# Source `$BASH_ENV` to make the variables available immediately. -source $BASH_ENV; - -# Disable husky. -setPublicVar HUSKY 0 - -# Expose the Bazelisk version. We need to run Bazelisk globally since Windows has problems launching -# Bazel from a node modules directoy that might be modified by the Bazel Yarn install then. -setPublicVar BAZELISK_VERSION \ - "$(cd ${PROJECT_ROOT}; node -p 'require("./package.json").devDependencies["@bazel/bazelisk"]')" \ No newline at end of file diff --git a/.circleci/win-ram-disk.ps1 b/.circleci/win-ram-disk.ps1 deleted file mode 100644 index a73bdcdb06b7..000000000000 --- a/.circleci/win-ram-disk.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -$aimContents = "./aim"; - -if (-not (Test-Path -Path $aimContents)) { - echo "Arsenal Image Mounter files not found in cache. Downloading..." - - # Download AIM Drivers and validate hash - Invoke-WebRequest "https://github.com/ArsenalRecon/Arsenal-Image-Mounter/raw/988930e4b3180ec34661504e6f9906f98943a022/DriverSetup/DriverFiles.zip" -OutFile "aim_drivers.zip" -UseBasicParsing - $aimDriversDownloadHash = (Get-FileHash aim_drivers.zip -a sha256).Hash - If ($aimDriversDownloadHash -ne "1F5AA5DD892C2D5E8A0083752B67C6E5A2163CD83B6436EA545508D84D616E02") { - throw "aim_drivers.zip hash is ${aimDriversDownloadHash} which didn't match the known version." - } - Expand-Archive -Path "aim_drivers.zip" -DestinationPath $aimContents/drivers - - # Download AIM CLI and validate hash - Invoke-WebRequest "https://github.com/ArsenalRecon/Arsenal-Image-Mounter/raw/988930e4b3180ec34661504e6f9906f98943a022/Command%20line%20applications/aim_ll.zip" -OutFile "aim_ll.zip" -UseBasicParsing - $aimCliDownloadHash = (Get-FileHash aim_ll.zip -a sha256).Hash - If ($aimCliDownloadHash -ne "9AD3058F14595AC4A5E5765A9746737D31C219383766B624FCBA4C5ED96B20F3") { - throw "aim_ll.zip hash is ${aimCliDownloadHash} which didn't match the known version." - } - Expand-Archive -Path "aim_ll.zip" -DestinationPath $aimContents/cli -} else { - echo "Arsenal Image Mounter files found in cache. Skipping download." -} - -# Install AIM drivers -./aim/cli/x64/aim_ll.exe --install ./aim/drivers - -# Setup RAM disk mount. Same parameters as ImDisk -# Ensure size is large enough to support the bazel 'shard_count's such as for e2e tests. -# See: https://support.circleci.com/hc/en-us/articles/4411520952091-Create-a-windows-RAM-disk -./aim/cli/x64/aim_ll.exe -a -s 12G -m X: -p "/fs:ntfs /q /y" diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 3be6763ed0db..000000000000 --- a/.eslintignore +++ /dev/null @@ -1,14 +0,0 @@ -/bazel-out/ -/dist-schema/ -/goldens/public-api -/packages/angular_devkit/build_angular/src/babel-bazel.d.ts -/packages/angular_devkit/build_angular/test/ -/packages/angular_devkit/build_webpack/test/ -/packages/angular_devkit/schematics_cli/blank/project-files/ -/packages/angular_devkit/schematics_cli/blank/schematic-files/ -/packages/angular_devkit/schematics_cli/schematic/files/ -/tests/ -.yarn/ -dist/ -node_modules/ -third_party/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 954eb0855a7b..000000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "root": true, - "env": { - "es6": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:import/typescript", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "prettier" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "tsconfig.json", - "sourceType": "module" - }, - "plugins": ["eslint-plugin-import", "header", "@typescript-eslint"], - "rules": { - "@typescript-eslint/consistent-type-assertions": "error", - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-non-null-assertion": "error", - "@typescript-eslint/no-unnecessary-qualifier": "error", - "@typescript-eslint/no-unused-expressions": "error", - "curly": "error", - "header/header": [ - "error", - "block", - [ - "*", - " * @license", - " * Copyright Google LLC All Rights 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", - " " - ], - 2 - ], - "import/first": "error", - "import/newline-after-import": "error", - "import/no-absolute-path": "error", - "import/no-duplicates": "error", - "import/no-extraneous-dependencies": ["off", { "devDependencies": false }], - "import/no-unassigned-import": ["error", { "allow": ["symbol-observable"] }], - "import/order": [ - "error", - { - "alphabetize": { "order": "asc" }, - "groups": [["builtin", "external"], "parent", "sibling", "index"] - } - ], - "max-len": [ - "error", - { - "code": 140, - "ignoreUrls": true - } - ], - "max-lines-per-function": ["error", { "max": 200 }], - "no-caller": "error", - "no-console": "error", - "no-empty": ["error", { "allowEmptyCatch": true }], - "no-eval": "error", - "no-multiple-empty-lines": ["error"], - "no-throw-literal": "error", - "padding-line-between-statements": [ - "error", - { - "blankLine": "always", - "prev": "*", - "next": "return" - } - ], - "sort-imports": ["error", { "ignoreDeclarationSort": true }], - "spaced-comment": [ - "error", - "always", - { - "markers": ["/"] - } - ], - - /* TODO: evaluate usage of these rules and fix issues as needed */ - "no-case-declarations": "off", - "no-fallthrough": "off", - "no-underscore-dangle": "off", - "@typescript-eslint/await-thenable": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-implied-eval": "off", - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/prefer-regexp-exec": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/restrict-plus-operands": "off", - "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/unbound-method": "off" - }, - "overrides": [ - { - "files": ["!packages/**", "**/*_spec.ts"], - "rules": { - "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], - "max-lines-per-function": "off", - "no-console": "off" - } - } - ] -} diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5764ed46e6a7..898698af3906 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,10 +1,10 @@ blank_issues_enabled: false contact_links: - - name: Docs or angular.io issue report + - 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.io application + about: Report an issue in Angular's documentation or angular.dev application - name: Security issue disclosure - url: https://angular.io/guide/security#report-issues + 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 diff --git a/.github/SAVED_REPLIES.md b/.github/SAVED_REPLIES.md index 06fb24cd1cd6..1237bc279e11 100644 --- a/.github/SAVED_REPLIES.md +++ b/.github/SAVED_REPLIES.md @@ -71,7 +71,7 @@ If the problem persists after upgrading, please open a new issue, provide a simp ## 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`. +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). ``` @@ -84,7 +84,7 @@ Hello, errors like `Error encountered resolving symbol values statically` mean t 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 diff --git a/.github/codeql/config.yml b/.github/codeql/config.yml new file mode 100644 index 000000000000..ad81a268eda4 --- /dev/null +++ b/.github/codeql/config.yml @@ -0,0 +1,8 @@ +name: 'Angular CLI CodeQL config' + +query-filters: + # TODO(josephperrott): reevaluate if these can be reenabled. + - exclude: + id: js/bad-code-sanitization + - exclude: + id: js/regex-injection 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..7c922f53b781 --- /dev/null +++ b/.github/shared-actions/windows-bazel-test/action.yml @@ -0,0 +1,78 @@ +name: 'Native Windows Bazel e2e test' +description: 'Runs an Angular CLI e2e Bazel test on native Windows (dispatched from inside WSL)' +author: 'Angular' + +inputs: + test_target_name: + description: E2E test target name + required: true + test_args: + description: | + Text representing the command line arguments that + should be passed to the e2e test runner. + required: false + default: '' + +runs: + using: composite + steps: + - name: Initialize WSL + id: init_wsl + uses: angular/dev-infra/github-actions/setup-wsl@dfe138678e4edb4789fbe40ae7792c046de3b4bd + with: + wsl_firewall_interface: 'vEthernet (WSL (Hyper-V firewall))' + + - name: Installing pnpm (in WSL) + run: npm install -g pnpm@9 + shell: wsl-bash {0} + + - name: Install node modules in WSL (re-using from previous install/cache restore) + run: | + cd ${{steps.init_wsl.outputs.repo_path}} + pnpm install --frozen-lockfile + shell: wsl-bash {0} + + - name: Build test binary for Windows (inside WSL) + shell: wsl-bash {0} + run: | + cd ${{steps.init_wsl.outputs.repo_path}} + pnpm bazel \ + build --config=e2e //tests/legacy-cli:${{inputs.test_target_name}} --platforms=tools:windows_x64 + env: + # See: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows + WSLENV: 'GOOGLE_APPLICATION_CREDENTIALS/p' + + - name: Copying binary artifact to host + shell: wsl-bash {0} + run: | + cd ${{steps.init_wsl.outputs.repo_path}} + tar -cf /tmp/test.tar.gz dist/bin/tests/legacy-cli/${{inputs.test_target_name}}_ + mkdir /mnt/c/test + mv /tmp/test.tar.gz /mnt/c/test + (cd /mnt/c/test && tar -xf /mnt/c/test/test.tar.gz) + + - name: Convert symlinks for Windows host + shell: wsl-bash {0} + run: | + cd ${{steps.init_wsl.outputs.repo_path}} + + runfiles_dir="/mnt/c/test/dist/bin/tests/legacy-cli/${{inputs.test_target_name}}_/${{inputs.test_target_name}}.bat.runfiles" + + # Make WSL symlinks compatible on Windows native file system. + node scripts/windows-testing/convert-symlinks.mjs $runfiles_dir "${{steps.init_wsl.outputs.cmd_path}}" + + # Needed for resolution because Aspect/Bazel looks for repositories at `/external`. + # TODO(devversion): consult with Aspect on why this is needed. + (cd $runfiles_dir/_main && ${{steps.init_wsl.outputs.cmd_path}} /C "mklink /D external ..") + + - name: Run tests + # Note: This is Git Bash. + shell: bash + env: + BAZEL_BINDIR: '.' + working-directory: "C:\\test" + run: | + node "${{github.workspace}}\\scripts\\windows-testing\\parallel-executor.mjs" \ + $PWD/dist/bin/tests/legacy-cli/${{inputs.test_target_name}}_/${{inputs.test_target_name}}.bat.runfiles \ + ${{inputs.test_target_name}} \ + "${{inputs.test_args}}" \ diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index 5a743ba6bc95..a7ee974b39b6 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -13,9 +13,9 @@ jobs: assistant_to_the_branch_manager: runs-on: ubuntu-latest steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@25a781ff4fff8c13348ddf335e8067f103cb9035 + - uses: angular/dev-infra/github-actions/branch-manager@dfe138678e4edb4789fbe40ae7792c046de3b4bd with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e29db40e4d87..90c4e78beb41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - main - '[0-9]+.[0-9]+.x' - pull_request: - types: [opened, synchronize, reopened] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -23,80 +21,209 @@ jobs: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@ef022a04090aca0945862f9f69f8ff3ea5865f26 - - name: Setup ESLint Caching - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 - with: - path: .eslintcache - key: ${{ runner.os }}-${{ hashFiles('.eslintrc.json') }} + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd - name: Install node modules - run: yarn install --frozen-lockfile --ignore-scripts + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm admin build-schema - name: Run ESLint - run: yarn lint --cache-strategy content + run: pnpm lint --cache-strategy content - name: Validate NgBot Configuration - run: yarn ng-dev ngbot verify + run: pnpm ng-dev ngbot verify - name: Validate Circular Dependencies - run: yarn ts-circular-deps:check + run: pnpm ts-circular-deps check - name: Run Validation - run: yarn -s admin validate + run: pnpm admin validate - name: Check tooling setup - run: yarn -s 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. - if: github.event_name == 'pull_request' - run: yarn 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. - if: github.event_name == 'pull_request' - run: yarn ng-dev format changed --check ${{ github.event.pull_request.base.sha }} + 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@0109d498b0f6aae418ed4924a5e5c65695f0ac61 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@0109d498b0f6aae418ed4924a5e5c65695f0ac61 + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@0109d498b0f6aae418ed4924a5e5c65695f0ac61 + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} - name: Install node modules - run: yarn install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Build release targets - run: yarn ng-dev release build - - name: Store PR release packages - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 - with: - name: packages - path: dist/releases/*.tgz - retention-days: 14 + run: pnpm ng-dev release build test: - runs-on: ubuntu-latest - env: - defaultVersion: 16 + needs: build + runs-on: ubuntu-latest-4core + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + 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 //modules/... //packages/... + env: + ASPECT_RULES_JS_FROZEN_PNPM_LOCK: '1' + + e2e: + needs: test strategy: + fail-fast: false matrix: - version: [16, 18] + os: [ubuntu-latest] + node: [20, 22, 24] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + 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/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e_windows: + strategy: + fail-fast: false + matrix: + os: [windows-2025] + node: [22] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd with: - persist-credentials: false - - uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3.7.0 + allow_windows_rbe: true + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test with: - node-version: ${{ matrix.version }} - cache: 'yarn' + 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: + os: [ubuntu-latest] + node: [22] + subset: [yarn, pnpm] + shard: [0, 1, 2] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + 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/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: test + if: github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + 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/legacy-cli: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@dfe138678e4edb4789fbe40ae7792c046de3b4bd - name: Install node modules - run: yarn install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@0109d498b0f6aae418ed4924a5e5c65695f0ac61 + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@0109d498b0f6aae418ed4924a5e5c65695f0ac61 - - if: matrix.version == env.defaultVersion - name: Run tests for default node version - run: yarn bazel test --test_tag_filters=-node18,-node16-broken //packages/... - - if: matrix.version != env.defaultVersion - name: Run tests for non-default node version - run: yarn bazel test --test_tag_filters=node18,-node18-broken //packages/... + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run E2E Browser tests + env: + SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + SAUCE_LOG_FILE: /tmp/angular/sauce-connect.log + SAUCE_READY_FILE: /tmp/angular/sauce-connect-ready-file.lock + SAUCE_PID_FILE: /tmp/angular/sauce-connect-pid-file.lock + SAUCE_TUNNEL_IDENTIFIER: 'angular-${{ github.run_number }}' + SAUCE_READY_FILE_TIMEOUT: 120 + run: | + ./scripts/saucelabs/start-tunnel.sh & + ./scripts/saucelabs/wait-for-tunnel.sh + pnpm bazel test --config=saucelabs //tests/legacy-cli:e2e.saucelabs + ./scripts/saucelabs/stop-tunnel.sh + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: ${{ failure() }} + with: + name: sauce-connect-log + path: ${{ env.SAUCE_CONNECT_DIR_IN_HOST }}/sauce-connect.log + + 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@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - run: pnpm admin snapshots --verbose + env: + SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000000..1adf56462d15 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,34 @@ +name: 'CodeQL' + +on: + push: + branches: ['main', '*.*.x'] + schedule: + - cron: '39 9 * * 1' + +permissions: {} + +jobs: + analyze: + name: Analyze + runs-on: 'ubuntu-latest' + permissions: + security-events: write + packages: read + strategy: + fail-fast: false + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Initialize CodeQL + uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 + with: + languages: javascript-typescript + build-mode: none + config-file: .github/codeql/config.yml + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 + with: + category: '/language:javascript-typescript' diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index 1b7336f04ee0..8b20acca92a1 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -12,14 +12,14 @@ jobs: labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - uses: angular/dev-infra/github-actions/commit-message-based-labels@25a781ff4fff8c13348ddf335e8067f103cb9035 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/pull-request-labeling@dfe138678e4edb4789fbe40ae7792c046de3b4bd with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} post_approval_changes: runs-on: ubuntu-latest steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - uses: angular/dev-infra/github-actions/post-approval-changes@25a781ff4fff8c13348ddf335e8067f103cb9035 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/post-approval-changes@dfe138678e4edb4789fbe40ae7792c046de3b4bd with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/feature-requests.yml b/.github/workflows/feature-requests.yml index 0c3347b0ed50..ac3167fda43b 100644 --- a/.github/workflows/feature-requests.yml +++ b/.github/workflows/feature-requests.yml @@ -16,6 +16,6 @@ jobs: if: github.repository == 'angular/angular-cli' runs-on: ubuntu-latest steps: - - uses: angular/dev-infra/github-actions/feature-request@25a781ff4fff8c13348ddf335e8067f103cb9035 + - uses: angular/dev-infra/github-actions/feature-request@dfe138678e4edb4789fbe40ae7792c046de3b4bd with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000000..d57199a3df96 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,55 @@ +name: Performance Tracking + +on: + push: + branches: + - main + # Run workflows for all releasable branches + - '[0-9]+.[0-9]+.x' + +permissions: + contents: 'read' + id-token: 'write' + +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@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - id: workflows + run: echo "workflows=$(pnpm -s ng-dev perf workflows --list)" >> "$GITHUB_OUTPUT" + + workflow: + timeout-minutes: 30 + runs-on: ubuntu-latest + needs: list + strategy: + matrix: + workflow: ${{ fromJSON(needs.list.outputs.workflows) }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - 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@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10 + 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 ${{ matrix.workflow }} --commit-sha ${{github.sha}} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000000..76c3c8c3238c --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,191 @@ +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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + snapshots: + - 'tests/legacy-cli/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@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup ESLint Caching + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + 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 admin 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 Package Licenses + uses: angular/dev-infra/github-actions/linting/licenses@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - 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 }} + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: packages + path: dist/releases/*.tgz + retention-days: 14 + + test: + needs: build + runs-on: ubuntu-latest-16core + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test //modules/... //packages/... + env: + ASPECT_RULES_JS_FROZEN_PNPM_LOCK: '1' + + e2e: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - 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/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-windows-subset: + needs: build + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + with: + allow_windows_rbe: true + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e_node22 + test_args: --esbuild --glob "tests/basic/{build,rebuild}.ts" + + e2e-package-managers: + needs: build + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [yarn, pnpm] + shard: [0, 1, 2] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - 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/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: [analyze, build] + if: needs.analyze.outputs.snapshots == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@dfe138678e4edb4789fbe40ae7792c046de3b4bd + - 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/legacy-cli:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4fb8bc80fe86..41b44acee359 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,12 +25,12 @@ jobs: steps: - name: 'Checkout code' - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: 'Run analysis' - uses: ossf/scorecard-action@08b4669551908b1024bb425080c797723083c031 # v2.2.0 + uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: results.sarif results_format: sarif @@ -38,7 +38,7 @@ jobs: # Upload the results as artifacts. - name: 'Upload artifact' - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # tag=v3.1.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: SARIF file path: results.sarif @@ -46,6 +46,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@0ba4244466797eb048eb91a6cd43d5c03ca8bd05 # v2.21.2 + uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 91652321da0e..de3ad9a9154d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,14 @@ test-project-host-* dist/ dist-schema/ +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + # IDEs jsconfig.json diff --git a/.husky/commit-msg b/.husky/commit-msg index 1b07f649c828..0c6213fc6bb7 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname $0)/_/husky.sh" - -yarn -s ng-dev commit-message pre-commit-validate --file $1; +pnpm -s ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit index 84611a58eec9..bbcdc40e0112 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname $0)/_/husky.sh" - -yarn -s ng-dev format staged; \ No newline at end of file +pnpm -s ng-dev format staged; diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg index 3a3afe6f32f5..2333b7b798c0 100755 --- a/.husky/prepare-commit-msg +++ b/.husky/prepare-commit-msg @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname $0)/_/husky.sh" - -yarn -s ng-dev commit-message restore-commit-message-draft $1 $2; +pnpm -s ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.idea/angular-cli.iml b/.idea/angular-cli.iml deleted file mode 100644 index cff4053c5974..000000000000 --- a/.idea/angular-cli.iml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 28a804d8932a..000000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 6d8c965387b0..000000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.idea/runConfigurations/Large_Tests.xml b/.idea/runConfigurations/Large_Tests.xml deleted file mode 100644 index 3d4f25fb3a76..000000000000 --- a/.idea/runConfigurations/Large_Tests.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - ', + ); + indexFileContent.toContain(' { + 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/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..26ae35a8221f --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_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'; + +/** + * 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'); + }, + ]); + }); + } + }); +}); 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..0dde3b4be58f --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -0,0 +1,318 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { 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 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(); + 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 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', '@one'); + }, + async ({ logs }) => { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@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 }) => { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@one'), + }), + ); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@two'); + }, + async ({ logs }) => { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@one'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@two'), + }), + ); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@three'); + }, + async ({ logs }) => { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@one'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@two'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@three'), + }), + ); + + // Revert the changes that caused the error + // Should remove the error + await harness.writeFile('src/app/app.component.html', '

GOOD

'); + }, + ({ logs }) => { + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@one'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@two'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@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 }) => { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('invalid-css-content'), + }), + ); + + await harness.writeFile('src/app/app.component.css', 'p { color: green }'); + }, + ({ logs }) => { + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('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! { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unexpected character "EOF"'), + }), + ); + + await harness.appendToFile('src/app/app.component.html', '>'); + }, + async ({ logs }) => { + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('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 }) => { + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('invalid-css-content'), + }), + ); + + 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..22c4c32202bd --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 }, + ); + }); + }); +}); 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..4e167f2994c6 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { 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: "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(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(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 }) => { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(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(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(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(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(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/typescript-incremental_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts new file mode 100644 index 000000000000..2c73e66d9f8b --- /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.io/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..06e66cbd6da9 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_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.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'; + + 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_devkit/build_angular/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts similarity index 95% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts index e1d8dafbf955..41539df239f2 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -103,7 +103,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/main.js').content.toContain(`console.log("A")`); + 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..1f1efafaf3c5 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_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 type { logging } from '@angular-devkit/core'; +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } 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(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + `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_devkit/build_angular/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts index 198e9db71b84..e7d060de1262 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; 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..135d5ff68165 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_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 { 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); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/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 similarity index 88% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts rename to packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts index 1c7d1d82faf9..ad29d985f712 100644 --- a/packages/angular_devkit/build_angular/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 @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; @@ -77,6 +77,31 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { ); }); + 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); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + it('should not show warning when depending on zone.js', async () => { // Add a Common JS dependency await harness.appendToFile( diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts similarity index 89% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts rename to packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts index 2ec3996dfa67..9c8384b29efc 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -16,6 +16,7 @@ const appShellRouteFiles: Record = { @Component({ selector: 'app-app-shell', + standalone: false, styles: ['div { color: #fff; }'], template: '

app-shell works!

', }) @@ -68,10 +69,10 @@ const appShellRouteFiles: Record = { export class AppServerModule {} `, 'src/main.ts': ` - import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + import { platformBrowser } from '@angular/platform-browser'; import { AppModule } from './app/app.module'; - platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.log(err)); + platformBrowser().bootstrapModule(AppModule).catch(err => console.log(err)); `, 'src/app/app-routing.module.ts': ` import { NgModule } from '@angular/core'; @@ -113,10 +114,9 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - harness.expectFile('dist/main.js').toExist(); - const indexFileContent = harness.expectFile('dist/index.html').content; + harness.expectFile('dist/browser/main.js').toExist(); + const indexFileContent = harness.expectFile('dist/browser/index.html').content; indexFileContent.toContain('app-shell works!'); - indexFileContent.toContain('ng-server-context="app-shell"'); }); it('critical CSS is inlined', async () => { @@ -137,7 +137,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - const indexFileContent = harness.expectFile('dist/index.html').content; + const indexFileContent = harness.expectFile('dist/browser/index.html').content; indexFileContent.toContain('app-shell works!'); indexFileContent.toContain('p{color:#000}'); indexFileContent.toContain( @@ -166,7 +166,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - const indexFileContent = harness.expectFile('dist/index.html').content; + const indexFileContent = harness.expectFile('dist/browser/index.html').content; indexFileContent.toContain('app-shell works!'); indexFileContent.toContain('p{color:#000}'); indexFileContent.toContain( diff --git a/packages/angular/build/src/builders/application/tests/options/assets_spec.ts b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts new file mode 100644 index 000000000000..96ae3c0d943e --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "assets"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + 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/browser/extra.file').content.toBe('extra file'); + harness.expectFile('dist/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/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/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/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/browser/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/browser/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 { error } = await harness.executeOnce({ outputLogsOnException: false }); + + expect(error?.message).toMatch('path must start with the project source root'); + + harness.expectFile('dist/browser/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/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/another.file').content.toBe('asset file'); + harness.expectFile('dist/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/another.file').toNotExist(); + harness.expectFile('dist/browser/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/browser/test.svg').content.toBe(''); + harness.expectFile('dist/browser/another.file').toNotExist(); + harness.expectFile('dist/browser/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/browser/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/browser/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/browser/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/browser/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/browser/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/browser/subdirectory/test.svg').content.toBe(''); + }); + + 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 { error } = await harness.executeOnce({ outputLogsOnException: false }); + + expect(error?.message).toMatch( + 'An asset cannot be written to a location outside of the output path', + ); + + harness.expectFile('dist/browser/test.svg').toNotExist(); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/base-href_spec.ts b/packages/angular/build/src/builders/application/tests/options/base-href_spec.ts similarity index 82% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/base-href_spec.ts rename to packages/angular/build/src/builders/application/tests/options/base-href_spec.ts index 0147a5f0c6a2..b47e90782d07 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/base-href_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/base-href_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -24,7 +24,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain(''); }); it('should update the base element with no href attribute when option is set', async () => { @@ -45,7 +45,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain(''); }); it('should add the base element href attribute when option is set', async () => { @@ -66,7 +66,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain(''); }); it('should update the base element href attribute when option is set to an empty string', async () => { @@ -77,7 +77,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain(''); }); it('should not update the base element href attribute when option is not present', async () => { @@ -87,7 +87,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain(''); }); it('should not change the base element href attribute when option is not present', async () => { @@ -107,7 +107,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toContain(''); + harness.expectFile('dist/browser/index.html').content.toContain(''); }); }); }); diff --git a/packages/angular/build/src/builders/application/tests/options/browser_spec.ts b/packages/angular/build/src/builders/application/tests/options/browser_spec.ts new file mode 100644 index 000000000000..9fe3cd00536a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/browser_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "browser"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + browser: 'src/main.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/index.html').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/main.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + browser: 'src/main.js', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/index.html').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('console.log("main")'); + }); + + it('defaults to use `src/main.ts` if not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + browser: undefined, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/index.html').toExist(); + }); + + it('uses project source root in default if `browser` not present', async () => { + harness.useProject('test', { + root: '.', + sourceRoot: 'source', + cli: { + cache: { + enabled: false, + }, + }, + }); + harness.useTarget('build', { + ...BASE_OPTIONS, + browser: undefined, + index: false, + }); + + // Update app for a `source/main.ts` file based on the above changed `sourceRoot` + await harness.writeFile('source/main.ts', `console.log('main');`); + await harness.modifyFile('src/tsconfig.app.json', (content) => + content.replace('main.ts', '../source/main.ts'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('console.log("main")'); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + browser: '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/browser/main.js').toNotExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + it('throws an error when given an empty string', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + browser: '', + }); + + 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, + browser: '/file.mjs', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Always uses the name `main.js` for the `browser` option. + harness.expectFile('dist/browser/main.js').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/bundle-budgets_spec.ts b/packages/angular/build/src/builders/application/tests/options/bundle-budgets_spec.ts new file mode 100644 index 000000000000..4614d5a5788e --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/bundle-budgets_spec.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 { logging } from '@angular-devkit/core'; +import { buildApplication } from '../../index'; +import { Type } from '../../schema'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + lazyModuleFiles, + lazyModuleFnImport, +} from '../setup'; + +describeBuilder(buildApplication, APPLICATION_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).toBeFalse(); + 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-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-module exceeded maximum budget'), + }), + ); + }); + + it(`should not warn when non-injected style is not within the baseline threshold`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: false, + styles: [ + { + input: 'src/lazy-styles.css', + inject: false, + bundleName: 'lazy-styles', + }, + ], + budgets: [ + { type: Type.Bundle, name: 'lazy-styles', warning: '1kb', error: '1kb', baseline: '2kb' }, + ], + }); + + await harness.writeFile( + 'src/lazy-styles.css', + ` + .foo { color: green; padding: 1px; } + `.repeat(24), + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('lazy-styles failed to meet minimum 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(`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/build/src/builders/application/tests/options/conditions_spec.ts b/packages/angular/build/src/builders/application/tests/options/conditions_spec.ts new file mode 100644 index 000000000000..11e2cdb62ab0 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/conditions_spec.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 { + setupConditionImport, + setTargetMapping, +} from '../../../../../../../../modules/testing/builder/src/dev_prod_mode'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "conditions"', () => { + beforeEach(async () => { + await setupConditionImport(harness); + }); + + interface ImportsTestCase { + name: string; + mapping: unknown; + output?: string; + customConditions?: string[]; + } + + const GOOD_TARGET = './src/good.js'; + const BAD_TARGET = './src/bad.js'; + + const emptyArrayCases: ImportsTestCase[] = [ + { + name: 'default fallback without matching condition', + mapping: { + 'never': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'development condition', + mapping: { + 'development': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'production condition', + mapping: { + 'production': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'browser condition (in browser)', + mapping: { + 'browser': GOOD_TARGET, + 'default': BAD_TARGET, + }, + }, + { + name: 'browser condition (in server)', + output: 'server/main.server.mjs', + mapping: { + 'browser': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + ]; + + for (const testCase of emptyArrayCases) { + describe('with empty array ' + testCase.name, () => { + beforeEach(async () => { + await setTargetMapping(harness, testCase.mapping); + }); + + it('resolves to expected target', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + ssr: true, + server: 'src/main.ts', + conditions: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + const outputFile = `dist/${testCase.output ?? 'browser/main.js'}`; + harness.expectFile(outputFile).content.toContain('"good-value"'); + harness.expectFile(outputFile).content.not.toContain('"bad-value"'); + }); + }); + } + + const customCases: ImportsTestCase[] = [ + { + name: 'default fallback without matching condition', + mapping: { + 'never': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'development condition', + mapping: { + 'development': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + { + name: 'staging condition', + mapping: { + 'staging': GOOD_TARGET, + 'production': BAD_TARGET, + 'default': BAD_TARGET, + }, + }, + { + name: 'browser condition (in browser)', + mapping: { + 'browser': GOOD_TARGET, + 'staging': BAD_TARGET, + 'default': BAD_TARGET, + }, + }, + { + name: 'browser condition (in server)', + output: 'server/main.server.mjs', + mapping: { + 'browser': BAD_TARGET, + 'default': GOOD_TARGET, + }, + }, + ]; + + for (const testCase of customCases) { + describe('with custom condition ' + testCase.name, () => { + beforeEach(async () => { + await setTargetMapping(harness, testCase.mapping); + }); + + it('resolves to expected target', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + ssr: true, + server: 'src/main.ts', + conditions: ['staging'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + const outputFile = `dist/${testCase.output ?? 'browser/main.js'}`; + harness.expectFile(outputFile).content.toContain('"good-value"'); + harness.expectFile(outputFile).content.not.toContain('"bad-value"'); + }); + }); + } + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/cross-origin_spec.ts b/packages/angular/build/src/builders/application/tests/options/cross-origin_spec.ts similarity index 93% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/cross-origin_spec.ts rename to packages/angular/build/src/builders/application/tests/options/cross-origin_spec.ts index c201b9296910..82bb04016418 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/cross-origin_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/cross-origin_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -36,7 +36,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); harness - .expectFile('dist/index.html') + .expectFile('dist/browser/index.html') .content.toEqual( `` + `` + @@ -54,7 +54,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); harness - .expectFile('dist/index.html') + .expectFile('dist/browser/index.html') .content.toEqual( `` + `` + @@ -73,7 +73,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); harness - .expectFile('dist/index.html') + .expectFile('dist/browser/index.html') .content.toEqual( `` + `` + @@ -91,7 +91,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); harness - .expectFile('dist/index.html') + .expectFile('dist/browser/index.html') .content.toEqual( `` + `` + diff --git a/packages/angular/build/src/builders/application/tests/options/define_spec.ts b/packages/angular/build/src/builders/application/tests/options/define_spec.ts new file mode 100644 index 000000000000..ec8ccf2e6f24 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/define_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "define"', () => { + it('should replace a value in application code when specified as a number', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'AN_INTEGER': '42', + }, + }); + + await harness.writeFile('./src/types.d.ts', 'declare const AN_INTEGER: number;'); + await harness.writeFile('src/main.ts', 'console.log(AN_INTEGER);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('AN_INTEGER'); + harness.expectFile('dist/browser/main.js').content.toContain('(42)'); + }); + + it('should replace a value in application code when specified as a string', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'A_STRING': '"42"', + }, + }); + + await harness.writeFile('./src/types.d.ts', 'declare const A_STRING: string;'); + await harness.writeFile('src/main.ts', 'console.log(A_STRING);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('A_STRING'); + harness.expectFile('dist/browser/main.js').content.toContain('("42")'); + }); + + it('should replace a value in application code when specified as a boolean', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'A_BOOLEAN': 'true', + }, + }); + + await harness.writeFile('./src/types.d.ts', 'declare const A_BOOLEAN: boolean;'); + await harness.writeFile('src/main.ts', 'console.log(A_BOOLEAN);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('A_BOOLEAN'); + harness.expectFile('dist/browser/main.js').content.toContain('(true)'); + }); + + it('should replace a value in script code', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'A_BOOLEAN': 'true', + }, + scripts: ['./src/script.js'], + }); + + await harness.writeFile('src/script.js', 'console.log(A_BOOLEAN);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/scripts.js').content.not.toContain('A_BOOLEAN'); + harness.expectFile('dist/browser/scripts.js').content.toContain('(true)'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts new file mode 100644 index 000000000000..7c0ceaab7145 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/delete-output-path_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "deleteOutputPath"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + + // Add files in output + await harness.writeFile('dist/a.txt', 'A'); + await harness.writeFile('dist/browser/b.txt', 'B'); + }); + + 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.expectDirectory('dist').toExist(); + harness.expectFile('dist/a.txt').toNotExist(); + harness.expectDirectory('dist/browser').toExist(); + harness.expectFile('dist/browser/b.txt').toNotExist(); + }); + + it(`should delete the output files when 'deleteOutputPath' is not set`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist').toExist(); + harness.expectFile('dist/a.txt').toNotExist(); + harness.expectDirectory('dist/browser').toExist(); + harness.expectFile('dist/browser/b.txt').toNotExist(); + }); + + it(`should not delete the 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/a.txt').toExist(); + harness.expectFile('dist/browser/b.txt').toExist(); + }); + + it(`should not delete empty only directories when 'deleteOutputPath' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: true, + }); + + // Add an error to prevent the build from writing files + await harness.writeFile('src/main.ts', 'INVALID_CODE'); + + const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + harness.expectDirectory('dist').toExist(); + harness.expectDirectory('dist/browser').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts new file mode 100644 index 000000000000..a03ca2b026e7 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "deployUrl"', () => { + beforeEach(async () => { + // Add a global stylesheet to test link elements + await harness.writeFile('src/styles.css', '/* Global styles */'); + + // Reduce the input index HTML to a single line to simplify comparing + await harness.writeFile( + 'src/index.html', + '', + ); + }); + + it('should update script src and link href attributes when option is set to relative URL', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + deployUrl: 'deployUrl/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toEqual( + `` + + `` + + ``, + ); + }); + + 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/browser/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/browser/main.js') + .content.toContain('background-image: url("https://example.com/some/path/media/test.svg")'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts new file mode 100644 index 000000000000..feb9b6447c3b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/external-dependencies_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "externalDependencies"', () => { + it('should not externalize any dependency when option is not set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/main.js').content.not.toMatch(/from ['"]@angular\/core['"]/); + harness + .expectFile('dist/browser/main.js') + .content.not.toMatch(/from ['"]@angular\/common['"]/); + }); + + it('should only externalize the listed depedencies when option is set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + externalDependencies: ['@angular/core'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toMatch(/from ['"]@angular\/core['"]/); + harness + .expectFile('dist/browser/main.js') + .content.not.toMatch(/from ['"]@angular\/common['"]/); + }); + + it('should externalize the listed depedencies in Web Workers when option is set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + externalDependencies: ['path'], + }); + + // The `path` Node.js builtin is used to cause a failure if not externalized + const workerCodeFile = ` + import path from "path"; + console.log(path); + `; + + // 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' }); + } + `, + ); + + const { result } = await harness.executeOnce(); + // If not externalized, build will fail with a Node.js platform builtin error + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts new file mode 100644 index 000000000000..402200a27f9d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/extract-licenses_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_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).toBeTrue(); + 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).toBeTrue(); + 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).toBeTrue(); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + }); + + it(`should generate '3rdpartylicenses.txt' when 'extractLicenses' and 'localize' are true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + extractLicenses: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + harness.expectFile('dist/browser/en-US/main.js').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts b/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts new file mode 100644 index 000000000000..a937f0bc430a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "fileReplacements"', () => { + it('should replace JSON files', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + fileReplacements: [{ replace: './src/one.json', with: './src/two.json' }], + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.resolveJsonModule = true; + + return JSON.stringify(tsconfig); + }); + + await harness.writeFile('./src/one.json', '{ "x": 12345 }'); + await harness.writeFile('./src/two.json', '{ "x": 67890 }'); + await harness.writeFile('src/main.ts', 'import { x } from "./one.json";\n console.log(x);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('12345'); + harness.expectFile('dist/browser/main.js').content.toContain('67890'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts b/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts new file mode 100644 index 000000000000..d29c0a84adbc --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "i18nMissingTranslation"', () => { + beforeEach(() => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + locales: { + 'fr': 'src/locales/messages.fr.xlf', + }, + }, + }); + }); + + it('should warn when i18nMissingTranslation is undefined (default)', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + i18nMissingTranslation: undefined, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should warn when i18nMissingTranslation is set to warning', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'warning' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should error when i18nMissingTranslation is set to error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'error' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to ignore', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'ignore' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to error and all found', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'error' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', GOOD_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to warning and all found', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'warning' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', GOOD_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + }); +}); + +const GOOD_TRANSLATION_FILE_CONTENT = ` + + + + + + Bonjour ! + + src/app/app.component.html + 2,3 + + An introduction header for this sample + + + + +`; + +const MISSING_TRANSLATION_FILE_CONTENT = ` + + + + + + + + +`; diff --git a/packages/angular/build/src/builders/application/tests/options/index_spec.ts b/packages/angular/build/src/builders/application/tests/options/index_spec.ts new file mode 100644 index 000000000000..11228658bbce --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/index_spec.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "index"', () => { + beforeEach(async () => { + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + describe('short form syntax', () => { + it('should not generate an output file when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + // TODO: This fails option validation when used in the CLI but not when used directly + xit('should fail build when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: true, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + harness.expectFile('dist/browser/index.html').toNotExist(); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Schema validation failed') }), + ); + }); + + it('should use the provided file path to generate the output file when a string path', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: 'src/index.html', + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('TEST_123'); + }); + + it('should use the the index.html file within the project source root when not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: undefined, + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('TEST_123'); + }); + + // TODO: Build needs to be fixed to not throw an unhandled exception for this case + xit('should fail build when a string path to non-existent file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: 'src/not-here.html', + }); + + const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + it('should generate initial preload link elements', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: true, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.toContain('chunk-'); + }); + }); + + describe('long form syntax', () => { + it('should use the provided input path to generate the output file when present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + }, + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('TEST_123'); + }); + + it('should use the provided output path to generate the output file when present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + output: 'output.html', + }, + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/output.html').content.toContain('TEST_123'); + }); + }); + + describe('preload', () => { + it('should generate initial preload link elements when preloadInitial is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: true, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.toContain('chunk-'); + }); + + it('should generate initial preload link elements when preloadInitial is undefined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: undefined, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.toContain('chunk-'); + }); + + it('should not generate initial preload link elements when preloadInitial is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: false, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.not.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.not.toContain('chunk-'); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts b/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts new file mode 100644 index 000000000000..21a905c792d6 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/inline-style-language_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 { buildApplication } from '../../index'; +import { InlineStyleLanguage } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_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/browser/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/browser/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/browser/main.js').content.toContain('color: indianred'); + }); + + it('updates produced stylesheet in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + 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/browser/main.js').content.toContain('color: indianred'); + harness.expectFile('dist/browser/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/browser/main.js').content.not.toContain('color: indianred'); + harness.expectFile('dist/browser/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/browser/main.js').content.not.toContain('color: indianred'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + }, + ]); + }); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/loader_spec.ts b/packages/angular/build/src/builders/application/tests/options/loader_spec.ts new file mode 100644 index 000000000000..2945df7bb4eb --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/loader_spec.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "loader"', () => { + it('should error for an unknown file extension', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\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( + 'No loader is configured for ".unknown" files: src/a.unknown', + ), + }), + ); + }); + + it('should not include content for file extension set to "empty"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'empty', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline text content for file extension set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'text', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\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 file extension set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'binary', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: Uint8Array; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\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, + loader: { + '.unknown': 'base64', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\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, + loader: { + '.svg': 'dataurl', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.svg" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.svg', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.svg";\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 file extension set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\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 file extension set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + loader: { + '.unknown': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\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 inline text content for `.txt` by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: undefined, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\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 text content for `.txt` by default when other extensions are defined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'binary', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\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 allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.txt': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\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(); + }); + + // Schema validation will prevent this from happening for supported use-cases. + // This will only happen if used programmatically and the option value is set incorrectly. + it('should ignore entry if an invalid loader name is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'invalid', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + + // Schema validation will prevent this from happening for supported use-cases. + // This will only happen if used programmatically and the option value is set incorrectly. + it('should ignore entry if an extension does not start with a period', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + 'unknown': 'text', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts b/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts new file mode 100644 index 000000000000..06f72f27c902 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/named-chunks_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const MAIN_OUTPUT = 'dist/browser/main.js'; +const NAMED_LAZY_OUTPUT = 'dist/browser/lazy-module-7QZXF7K7.js'; +const UNNAMED_LAZY_OUTPUT = 'dist/browser/chunk-OW5RYMPM.js'; + +describeBuilder(buildApplication, APPLICATION_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 generates 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/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts new file mode 100644 index 000000000000..a84aeeccfdf9 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "fonts.inline"', () => { + beforeEach(async () => { + await harness.modifyFile('/src/index.html', (content) => + content.replace( + '', + ``, + ), + ); + + await harness.writeFile( + 'src/styles.css', + '@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);', + ); + + await harness.writeFile( + 'src/app/app.component.css', + '@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);', + ); + }); + + it(`should not inline fonts when fonts optimization is set to false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: false, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + } + }); + + it(`should inline fonts when fonts optimization is unset`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: undefined, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + harness + .expectFile(`dist/browser/${file}`) + .content.toMatch(/@font-face{font-family:'?Roboto/); + } + }); + + it(`should inline fonts when fonts optimization is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: true, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + harness + .expectFile(`dist/browser/${file}`) + .content.toMatch(/@font-face{font-family:'?Roboto/); + } + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts new file mode 100644 index 000000000000..ab56a9bc84dd --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_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/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/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/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/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/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/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/browser/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/browser/index.html').content.not.toContain(` { + 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/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/index.html').content.toContain(`body{color:#000}`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts new file mode 100644 index 000000000000..0ce1c6dc92b5 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "removeSpecialComments"', () => { + beforeEach(async () => { + await harness.writeFile( + 'src/styles.css', + ` + /* normal-comment */ + /*! important-comment */ + div { flex: 1 } + `, + ); + }); + + it(`should retain special comments when 'removeSpecialComments' is set to 'false'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + styles: ['src/styles.css'], + optimization: { + styles: { + removeSpecialComments: false, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\/\*! important-comment \*\/[\s\S]*div{flex:1}/); + }); + + it(`should not retain special comments when 'removeSpecialComments' is set to 'true'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + styles: ['src/styles.css'], + optimization: { + styles: { + removeSpecialComments: true, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.not.toContain('important-comment'); + }); + + it(`should not retain special comments when 'removeSpecialComments' is not set`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + styles: ['src/styles.css'], + optimization: { + styles: {}, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.not.toContain('important-comment'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/optimization-scripts_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-scripts_spec.ts new file mode 100644 index 000000000000..013451467bb0 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-scripts_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('Option: "optimization.scripts"', () => { + it(`should include 'setClassMetadata' calls when false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: false, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('setClassMetadata('); + }); + + it(`should not include 'setClassMetadata' calls when true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.not.toContain('setClassMetadata('); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts new file mode 100644 index 000000000000..a4988148c879 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "outputHashing"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + await harness.writeFile('src/polyfills.ts', 'console.log("TEST-POLYFILLS");'); + }); + + 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/browser', /main-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.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/browser', /main-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.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/browser', /main-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.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/browser', /main-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.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/browser', /main-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.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/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css.map$/)).toBeFalse(); + harness.expectFile('dist/browser/styles.css').toExist(); + harness.expectFile('dist/browser/styles.css.map').toExist(); + }); + + // TODO: Re-enable once implemented in the esbuild builder + xit('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/browser/media/test.svg').toExist(); + harness.expectFile('dist/browser/media/small-test.svg').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/output-mode.ts b/packages/angular/build/src/builders/application/tests/options/output-mode.ts new file mode 100644 index 000000000000..4e6a6eb49a7d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/output-mode.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 { buildApplication } from '../../index'; +import { OutputMode } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } 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 ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + }); + + describe('Option: "outputMode"', () => { + it(`should not emit 'server' directory when OutputMode is Static`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputMode: OutputMode.Static, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectDirectory('dist/server').toNotExist(); + }); + + it(`should emit 'server' directory when OutputMode is Server`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputMode: OutputMode.Server, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts new file mode 100644 index 000000000000..b6c72b9bee58 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts @@ -0,0 +1,265 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "outputPath"', () => { + beforeEach(async () => { + // Add a global stylesheet media file + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + // Add a component stylesheet media file + await harness.writeFile('src/app/abc.svg', ''); + await harness.writeFile('src/app/app.component.css', `h2 { background: url('./abc.svg')}`); + + // Enable SSR + 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); + }); + + // Application server code is not needed in this test + await harness.writeFile('src/main.server.ts', `console.log('Hello!');`); + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + }); + + describe('when option value is a string', () => { + it('should emit browser, media and server files in their respective directories', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + outputPath: 'dist', + styles: ['src/styles.css'], + server: 'src/main.server.ts', + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + harness.expectFile('dist/browser/media/abc.svg').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe('when option value is an object', () => { + describe(`'media' is set to 'resources'`, () => { + it('should emit browser, media and server files in their respective directories', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + media: 'resource', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/resource/spectrum.png').toExist(); + harness.expectFile('dist/browser/resource/abc.svg').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'media' is set to ''`, () => { + it('should emit browser, media and server files in their respective directories', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + media: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/spectrum.png').toExist(); + harness.expectFile('dist/browser/abc.svg').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + harness.expectFile('dist/browser/app.component.css').toNotExist(); + }); + }); + + describe(`'server' is set to 'node-server'`, () => { + it('should emit browser, media and server files in their respective directories', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + server: 'node-server', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + harness.expectFile('dist/browser/media/abc.svg').toExist(); + harness.expectFile('dist/node-server/server.mjs').toExist(); + }); + }); + + describe(`'browser' is set to 'public'`, () => { + it('should emit browser, media and server files in their respective directories', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: 'public', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/public/main.js').toExist(); + harness.expectFile('dist/public/media/spectrum.png').toExist(); + harness.expectFile('dist/public/media/abc.svg').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'browser' is set to ''`, () => { + it('should emit browser and media files in the root output directory when ssr is disabled', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/media/spectrum.png').toExist(); + harness.expectFile('dist/media/abc.svg').toExist(); + }); + + it('should error when ssr is enabled', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + `'outputPath.browser' cannot be configured to an empty string when SSR is enabled`, + ), + }), + ); + }); + }); + + describe(`'server' is set to ''`, () => { + it('should emit browser, media and server files in their respective directories', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + server: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + harness.expectFile('dist/browser/media/abc.svg').toExist(); + harness.expectFile('dist/server.mjs').toExist(); + }); + }); + + it(`should error when ssr is enabled and 'browser' and 'server' are identical`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: 'public', + server: 'public', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + `'outputPath.browser' and 'outputPath.server' cannot be configured to the same value`, + ), + }), + ); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts b/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts new file mode 100644 index 000000000000..8b5cc3a09ab3 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/polyfills_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const testsVariants: [suitName: string, baseUrl: string | undefined][] = [ + ['When "baseUrl" is set to "./"', './'], + [`When "baseUrl" is not set`, undefined], + [`When "baseUrl" is set to non root path`, './project/foo'], +]; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: polyfills', () => { + for (const [suitName, baseUrl] of testsVariants) { + describe(suitName, () => { + beforeEach(async () => { + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.baseUrl = baseUrl; + + return JSON.stringify(tsconfig); + }); + }); + + 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/browser/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/browser/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('Could not resolve') }), + ); + + harness.expectFile('dist/browser/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/browser/polyfills.js').toExist(); + }); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts b/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts new file mode 100644 index 000000000000..757ff81acbac --- /dev/null +++ b/packages/angular/build/src/builders/application/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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "scripts"', () => { + beforeEach(async () => { + // Application code is not needed for scripts tests + await harness.writeFile('src/main.ts', 'console.log("TESTING");'); + }); + + 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/browser/scripts.js').toExist(); + harness + .expectFile('dist/browser/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/browser/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/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/browser/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/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/browser/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({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(`Could not resolve "src/test-script-a.js"`), + }), + ); + + harness.expectFile('dist/browser/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/browser/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/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/browser/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/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/browser/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/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/browser/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/other.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + harness + .expectFile('dist/browser/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/browser/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/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/browser/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/extra.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/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/browser/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/browser/other.js') + .content.toMatch(/console\.log\("c"\)[;\s]+console\.log\("a"\)/); + harness + .expectFile('dist/browser/extra.js') + .content.toMatch(/console\.log\("d"\)[;\s]+console\.log\("b"\)/); + harness + .expectFile('dist/browser/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/browser/test-script-a.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/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/browser/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/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/application/tests/options/server_spec.ts b/packages/angular/build/src/builders/application/tests/options/server_spec.ts similarity index 79% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts rename to packages/angular/build/src/builders/application/tests/options/server_spec.ts index 8f38a59668d2..a01a4eef73e2 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/server_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -24,14 +24,27 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { it('uses a provided TypeScript file', async () => { harness.useTarget('build', { ...BASE_OPTIONS, + ssr: true, server: 'src/main.server.ts', }); const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - harness.expectFile('dist/main.server.mjs').toExist(); - harness.expectFile('dist/main.js').toExist(); + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it('does not write file to disk when "ssr" is "false"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: 'src/main.server.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); }); it('uses a provided JavaScript file', async () => { @@ -39,18 +52,18 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.useTarget('build', { ...BASE_OPTIONS, + ssr: true, server: 'src/server.js', }); const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - - harness.expectFile('dist/main.server.mjs').toExist(); }); it('fails and shows an error when file does not exist', async () => { harness.useTarget('build', { ...BASE_OPTIONS, + ssr: true, server: 'src/missing.ts', }); @@ -61,13 +74,13 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve "') }), ); - harness.expectFile('dist/main.js').toNotExist(); - harness.expectFile('dist/main.server.mjs').toNotExist(); + harness.expectFile('dist/browser/main.js').toNotExist(); }); it('throws an error when given an empty string', async () => { harness.useTarget('build', { ...BASE_OPTIONS, + ssr: true, server: '', }); @@ -82,14 +95,12 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.useTarget('build', { ...BASE_OPTIONS, + ssr: true, server: '/file.mjs', }); const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - - // Always uses the name `main.server.mjs` for the `server` option. - harness.expectFile('dist/main.server.mjs').toExist(); }); }); }); diff --git a/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts b/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts new file mode 100644 index 000000000000..958cd5007960 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/service-worker_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "serviceWorker"', () => { + beforeEach(async () => { + const manifest = { + index: '/index.html', + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + resources: { + files: ['/favicon.ico', '/index.html'], + }, + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + resources: { + files: [ + '/assets/**', + '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)', + ], + }, + }, + ], + }; + + await harness.writeFile('src/ngsw-config.json', JSON.stringify(manifest)); + }); + + it('should not generate SW config when option is unset', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toNotExist(); + }); + + it('should not generate SW config when option is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toNotExist(); + }); + + it('should generate SW config when option is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toExist(); + }); + + it('should generate SW config referencing index output', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: true, + index: { + input: 'src/index.html', + output: 'index.csr.html', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const config = await harness.readFile('dist/browser/ngsw.json'); + expect(JSON.parse(config)).toEqual(jasmine.objectContaining({ index: '/index.csr.html' })); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts b/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts new file mode 100644 index 000000000000..c5cb8c321d32 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts @@ -0,0 +1,343 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "sourceMap"', () => { + it('should not generate script sourcemap files by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: undefined, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toNotExist(); + }); + + it('should not generate script sourcemap files when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toNotExist(); + }); + + it('should not generate script sourcemap files when scripts suboption is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toNotExist(); + }); + + it('should generate script sourcemap files when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toExist(); + }); + + it('should generate script sourcemap files when scripts suboption is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toExist(); + }); + + it('should not include third-party sourcemaps when true', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/core/index.ts'); + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/common/index.ts'); + }); + + it('should not include third-party sourcemaps when vendor suboption is false', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: true, vendor: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/core/index.ts'); + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/common/index.ts'); + }); + + it('should include third-party sourcemaps when vendor suboption is true', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: true, vendor: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/main.js.map') + .content.toContain('/core/src/application/application_ref.ts'); + harness + .expectFile('dist/browser/main.js.map') + .content.toContain('/common/src/directives/ng_if.ts'); + }); + + it(`should not include 'sourceMappingURL' sourcemaps when hidden suboption is true`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, hidden: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.not.toContain('sourceMappingURL=styles.css.map'); + }); + + it(`should include 'sourceMappingURL' sourcemaps when hidden suboption is false`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, hidden: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.toContain('sourceMappingURL=styles.css.map'); + }); + + it(`should include 'sourceMappingURL' sourcemaps when hidden suboption is not set`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.toContain('sourceMappingURL=styles.css.map'); + }); + + it('should add "x_google_ignoreList" extension to script sourcemap files when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.toContain('"x_google_ignoreList"'); + }); + + it(`should not include 'sourcesContent' field when 'sourcesContent' suboption is false`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, sourcesContent: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').content.not.toContain('"sourcesContent"'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness.expectFile('dist/browser/styles.css.map').content.not.toContain('"sourcesContent"'); + }); + + it(`should include 'sourcesContent' field when 'sourcesContent' suboption is true`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, sourcesContent: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').content.toContain('"sourcesContent"'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness.expectFile('dist/browser/styles.css.map').content.toContain('"sourcesContent"'); + }); + + it(`should include 'sourcesContent' field when 'sourcesContent' suboption is not present`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').content.toContain('"sourcesContent"'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness.expectFile('dist/browser/styles.css.map').content.toContain('"sourcesContent"'); + }); + + it(`should include 'sourcesContent' field when 'sourceMap' is true`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').content.toContain('"sourcesContent"'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness.expectFile('dist/browser/styles.css.map').content.toContain('"sourcesContent"'); + }); + + it('should generate component sourcemaps when sourcemaps when true', async () => { + await harness.writeFile('src/app/app.component.css', `* { color: red}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toContain('sourceMappingURL=app.component.css.map'); + harness.expectFile('dist/browser/app.component.css.map').toExist(); + }); + + it('should not generate component sourcemaps when sourcemaps when false', async () => { + await harness.writeFile('src/app/app.component.css', `* { color: red}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('sourceMappingURL=app.component.css.map'); + harness.expectFile('dist/browser/app.component.css.map').toNotExist(); + }); + + for (const ext of ['css', 'scss', 'less']) { + it(`should generate a correct sourcemap when input file is ${ext}`, async () => { + await harness.writeFile(`src/styles.${ext}`, `* { color: red }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + styles: [`src/styles.${ext}`], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness + .expectFile('dist/browser/styles.css.map') + .content.toContain(`"sources": ["src/styles.${ext}"]`); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts new file mode 100644 index 000000000000..e5e31e1a408f --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/ssr_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } 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 ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + }); + + describe('Option: "ssr"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + + 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, + server: 'src/main.server.ts', + ssr: { entry: '/file.mjs' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + + it(`should emit 'server' directory when 'ssr' is 'true'`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + 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(); + }); + + it(`should not emit 'server' directory when 'ssr' is 'false'`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toNotExist(); + }); + + it(`should not emit 'server' directory when 'ssr' is not set`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/style-preprocessor-options-sass_spec.ts b/packages/angular/build/src/builders/application/tests/options/style-preprocessor-options-sass_spec.ts new file mode 100644 index 000000000000..33c1d1cc9a4b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/style-preprocessor-options-sass_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 { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; +import { logging } from '@angular-devkit/core'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "stylePreprocessorOptions.sass"', () => { + it('should cause the build to fail when using `fatalDeprecations` in global styles', async () => { + await harness.writeFile('src/styles.scss', 'p { color: darken(red, 10%) }'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + stylePreprocessorOptions: { + sass: { + fatalDeprecations: ['color-functions'], + }, + }, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('darken() is deprecated'), + }), + ); + }); + + it('should succeed without `fatalDeprecations` despite using deprecated color functions', async () => { + await harness.writeFiles({ + 'src/styles.scss': 'p { color: darken(red, 10%) }', + 'src/app/app.component.scss': 'p { color: darken(red, 10%) }', + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content.replace('./app.component.css', 'app.component.scss'); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + stylePreprocessorOptions: { + sass: {}, + }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + }); + + it('should cause the build to fail when using `fatalDeprecations` in component styles', async () => { + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content.replace('./app.component.css', 'app.component.scss'); + }); + + await harness.writeFile('src/app/app.component.scss', 'p { color: darken(red, 10%) }'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + stylePreprocessorOptions: { + sass: { + fatalDeprecations: ['color-functions'], + }, + }, + }); + + const { result, logs } = await harness.executeOnce({ + outputLogsOnFailure: false, + }); + + expect(result?.success).toBeFalse(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('darken() is deprecated'), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/styles_spec.ts b/packages/angular/build/src/builders/application/tests/options/styles_spec.ts new file mode 100644 index 000000000000..eb8d973ae904 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/styles_spec.ts @@ -0,0 +1,466 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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('Option: "styles"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + 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/browser/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/browser/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/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/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/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/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/browser/styles.css').content.toMatch( + // eslint-disable-next-line max-len + /\.test-c {\s*color: blue;?\s*}[\s|\S]+\.test-d {\s*color: yellow;?\s*}[\s|\S]+\.test-b {\s*color: green;?\s*}[\s|\S]+\.test-a {\s*color: red;?\s*}/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('Could not resolve "src/test-style-a.css"'), + }), + ); + + harness.expectFile('dist/browser/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+ bytes/) }), + ); + }); + }); + + 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/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/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/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/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/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/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/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/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/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/extra.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/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/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/other.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + harness + .expectFile('dist/browser/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/browser/styles.css').content.toMatch( + // eslint-disable-next-line max-len + /\.test-c {\s*color: blue;?\s*}[\s|\S]+\.test-d {\s*color: yellow;?\s*}[\s|\S]+\.test-b {\s*color: green;?\s*}[\s|\S]+\.test-a {\s*color: red;?\s*}/, + ); + }); + + 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/browser/other.css') + .content.toMatch(/\.test-c {\s*color: blue;?\s*}[\s|\S]+\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/extra.css') + .content.toMatch( + /\.test-d {\s*color: yellow;?\s*}[\s|\S]+\.test-b {\s*color: green;?\s*}/, + ); + harness + .expectFile('dist/browser/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/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/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/browser/test-style-a.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/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/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + + harness + .expectFile('dist/browser/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+ bytes/) }), + ); + }); + + 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+ bytes/) }), + ); + }); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts similarity index 79% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/subresource-integrity_spec.ts rename to packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts index 036c47497498..4afb87ebaed3 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/subresource-integrity_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; @@ -20,7 +20,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.not.toContain('integrity='); + harness.expectFile('dist/browser/index.html').content.not.toContain('integrity='); }); it(`does not add integrity attribute when 'false'`, async () => { @@ -32,7 +32,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.not.toContain('integrity='); + harness.expectFile('dist/browser/index.html').content.not.toContain('integrity='); }); it(`does add integrity attribute when 'true'`, async () => { @@ -44,7 +44,9 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); + harness + .expectFile('dist/browser/index.html') + .content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); }); it(`does not issue a warning when 'true' and 'scripts' is set.`, async () => { @@ -59,7 +61,9 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const { result, logs } = await harness.executeOnce(); expect(result?.success).toBe(true); - harness.expectFile('dist/index.html').content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); + harness + .expectFile('dist/browser/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/build/src/builders/application/tests/setup.ts b/packages/angular/build/src/builders/application/tests/setup.ts new file mode 100644 index 000000000000..ca8a54a0bf31 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/setup.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 { 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'; + +export const APPLICATION_BUILDER_INFO = Object.freeze({ + name: '@angular/build:application', + 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', + 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, +}); diff --git a/packages/angular/build/src/builders/dev-server/builder.ts b/packages/angular/build/src/builders/dev-server/builder.ts new file mode 100644 index 000000000000..4ea11d5f11e9 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/builder.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 type { BuilderContext } from '@angular-devkit/architect'; +import type { Plugin } from 'esbuild'; +import type http from 'node:http'; +import { checkPort } from '../../utils/check-port'; +import { + type IndexHtmlTransform, + buildApplicationInternal, + purgeStaleBuildCache, +} from './internal'; +import { normalizeOptions } from './options'; +import type { DevServerBuilderOutput } from './output'; +import type { Schema as DevServerBuilderOptions } from './schema'; +import { serveWithVite } from './vite-server'; + +/** + * 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 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 async function* execute( + options: DevServerBuilderOptions, + context: BuilderContext, + extensions?: { + buildPlugins?: Plugin[]; + middleware?: (( + req: http.IncomingMessage, + res: http.ServerResponse, + next: (err?: unknown) => void, + ) => void)[]; + indexHtmlTransformer?: IndexHtmlTransform; + }, +): AsyncIterable { + // 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; + } + + const { builderName, normalizedOptions } = await initialize(options, projectName, context); + + yield* serveWithVite( + normalizedOptions, + builderName, + (options, context, plugins) => + buildApplicationInternal(options, context, { codePlugins: plugins }), + context, + { indexHtml: extensions?.indexHtmlTransformer }, + extensions, + ); +} + +async function initialize( + initialOptions: DevServerBuilderOptions, + projectName: string, + context: BuilderContext, +) { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + const normalizedOptions = await normalizeOptions(context, projectName, initialOptions); + const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget); + + if ( + !/^127\.\d+\.\d+\.\d+/g.test(normalizedOptions.host) && + normalizedOptions.host !== '::1' && + 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. + `); + } + + normalizedOptions.port = await checkPort(normalizedOptions.port, normalizedOptions.host); + + return { + builderName, + normalizedOptions, + }; +} diff --git a/packages/angular/build/src/builders/dev-server/index.ts b/packages/angular/build/src/builders/dev-server/index.ts new file mode 100644 index 000000000000..bdf90250151b --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/index.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 { Builder, createBuilder } from '@angular-devkit/architect'; +import { execute } from './builder'; +import type { DevServerBuilderOutput } from './output'; +import type { Schema as DevServerBuilderOptions } from './schema'; + +export { + type DevServerBuilderOptions, + type DevServerBuilderOutput, + execute as executeDevServerBuilder, +}; +const builder: Builder = createBuilder< + DevServerBuilderOptions, + DevServerBuilderOutput +>(execute); + +export default builder; + +// Temporary export to support specs +export { execute as executeDevServer }; diff --git a/packages/angular/build/src/builders/dev-server/internal.ts b/packages/angular/build/src/builders/dev-server/internal.ts new file mode 100644 index 000000000000..a0a6f2de57b4 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/internal.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use 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 BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +export { createRxjsEsmResolutionPlugin } from '../../tools/esbuild/rxjs-esm-resolution-plugin'; +export { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; +export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils'; +export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; +export { purgeStaleBuildCache } from '../../utils/purge-cache'; +export { getSupportedBrowsers } from '../../utils/supported-browsers'; +export { transformSupportedBrowsersToTargets } from '../../tools/esbuild/utils'; +export { buildApplicationInternal } from '../../builders/application'; +export type { ApplicationBuilderInternalOptions } from '../../builders/application/options'; +export type { ExternalResultMetadata } from '../../tools/esbuild/bundler-execution-result'; diff --git a/packages/angular/build/src/builders/dev-server/options.ts b/packages/angular/build/src/builders/dev-server/options.ts new file mode 100644 index 000000000000..3e0f59319117 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/options.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 { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import path from 'node:path'; +import { normalizeOptimization } from '../../utils'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { ApplicationBuilderOptions } from '../application'; +import { Schema as DevServerOptions } from './schema'; + +export type NormalizedDevServerOptions = Awaited>; + +/** + * 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); + const buildOptions = (await context.validateOptions( + rawBuildOptions, + browserBuilderName, + )) as unknown as ApplicationBuilderOptions; + const optimization = normalizeOptimization(buildOptions.optimization); + + if (options.prebundle) { + 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, + liveReload, + hmr, + headers, + proxyConfig, + servePath, + ssl, + sslCert, + sslKey, + prebundle, + allowedHosts, + } = options; + + // Return all the normalized options + return { + buildTarget, + host: host ?? 'localhost', + port: port ?? 4200, + poll, + open, + verbose, + watch, + liveReload: !!liveReload, + hmr: hmr ?? !!liveReload, + headers, + workspaceRoot, + projectRoot, + cacheOptions, + proxyConfig, + servePath, + ssl, + sslCert, + sslKey, + // Prebundling defaults to true but requires caching to function + prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, + inspect, + allowedHosts: allowedHosts ? allowedHosts : [], + }; +} diff --git a/packages/angular/build/src/builders/dev-server/output.ts b/packages/angular/build/src/builders/dev-server/output.ts new file mode 100644 index 000000000000..c166994a429b --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/output.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 + */ + +import { BuilderOutput } from '@angular-devkit/architect'; + +/** + * @experimental Direct usage of this type is considered experimental. + */ +export interface DevServerBuilderOutput extends BuilderOutput { + baseUrl: string; + port?: number; + address?: string; +} diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json new file mode 100644 index 000000000000..41902e43d8d0 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -0,0 +1,131 @@ +{ + "$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." + }, + "allowedHosts": { + "description": "The hosts that the development server will respond to. This option sets the Vite option of the same name. For further details: https://vite.dev/config/server-options.html#server-allowedhosts", + "default": [], + "oneOf": [ + { + "type": "array", + "description": "A list of hosts that the development server will respond to.", + "items": { + "type": "string" + } + }, + { + "type": "boolean", + "description": "Indicates that all hosts are allowed. This is not recommended and a security risk." + } + ] + }, + "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 + }, + "servePath": { + "type": "string", + "description": "The pathname where the application will be served." + }, + "hmr": { + "type": "boolean", + "description": "Enable hot module replacement. Defaults to the value of 'liveReload'. Currently, only global and component stylesheets are supported." + }, + "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" } + ] + }, + "prebundle": { + "description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled.", + "default": true, + "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. Note: specifying `@foo/bar` marks all paths within the `@foo/bar` package as excluded, including sub-paths like `@foo/bar/baz`.", + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false, + "required": ["exclude"] + } + ] + } + }, + "additionalProperties": false, + "required": ["buildTarget"] +} diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts new file mode 100644 index 000000000000..f7c7a0acb33a --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.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 { 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) => { + beforeEach(async () => { + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + 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('serves a project CSS asset unmodified', async () => { + const cssFileContent = 'p { color: blue };'; + await harness.writeFile('src/extra.css', cssFileContent); + + setupTarget(harness, { + assets: ['src/extra.css'], + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.css'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toBe(cssFileContent); + }); + + it('serves a project SCSS asset unmodified', async () => { + const cssFileContent = 'p { color: blue };'; + await harness.writeFile('src/extra.scss', cssFileContent); + + setupTarget(harness, { + assets: ['src/extra.scss'], + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.scss'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toBe(cssFileContent); + }); + + 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

'); + }); + + it(`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

'); + }); + + it(`should return a redirect when an asset directory is accessed without 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', { + request: { redirect: 'manual' }, + }); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(301); + expect(await response?.headers.get('Location')).toBe('/login/'); + }); + + it('serves a JavaScript asset named as a bundle (main.js)', async () => { + await harness.writeFile('public/test/main.js', javascriptFileContent); + + setupTarget(harness, { + assets: [ + { + glob: '**/*', + input: 'public', + }, + ], + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'test/main.js'); + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('should return 404 when a JavaScript asset named as a bundle (main.js) does not exist', async () => { + setupTarget(harness, {}); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'unknown/main.js'); + expect(result?.success).toBeTrue(); + expect(response?.status).toBe(404); + }); + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts new file mode 100644 index 000000000000..813796079b17 --- /dev/null +++ b/packages/angular/build/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(''; + + 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 headerLinkTags: string[] = []; + let bodyLinkTags: string[] = []; + 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(``); + + return; + } + break; + case 'base': + // Adjust base href if specified + if (isString(baseHref)) { + updateAttribute(tag, 'href', baseHref); + } + 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 loadEsmModule( + `@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..7ea16ab6121b --- /dev/null +++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts @@ -0,0 +1,577 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { 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) => + tags.stripIndents`${html}`.replace(/(>\s+)/g, '>'); + + it('can generate index.html', async () => { + 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 add image domain preload tags', async () => { + 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..c50e0bfce3f2 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/auto-csp.ts @@ -0,0 +1,303 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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; +} + +/** + * 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)) { + scriptContent.push({ + src: src, + type: scriptType, + async: getScriptAttributeValue(tag, 'async') !== undefined, + defer: getScriptAttributeValue(tag, 'defer') !== undefined, + }); + + 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.length).toBe(1); + expect(csps[0]).toMatch(ONE_HASH_CSP); + 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.length).toBe(1); + expect(csps[0]).toMatch(ONE_HASH_CSP); + expect(result).toContain(`var scripts = [['./main.js', '', false, false]];`); + }); + + it('should rewrite a single source script in place', async () => { + const result = await autoCsp(` + + + + +
Some text
+ + + + `); + + const csps = getCsps(result); + expect(csps.length).toBe(1); + expect(csps[0]).toMatch(ONE_HASH_CSP); + // Our loader script appears after the HTML text content. + expect(result).toMatch( + /Some text<\/div>\s* + + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps.length).toBe(1); + expect(csps[0]).toMatch(TWO_HASH_CSP); + expect(result).toContain( + // eslint-disable-next-line max-len + `var scripts = [['./main1.js', '', false, false],['./main2.js', '', true, false],['./main3.js', 'module', true, true]];`, + ); + // 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.length).toBe(1); + expect(csps[0]).toMatch(ONE_HASH_CSP); + // & 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.length).toBe(1); + // Exactly four hashes for the four scripts that remain (inline, loader, inline, loader). + expect(csps[0]).toMatch(FOUR_HASH_CSP); + 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.length).toBe(1); + expect(csps[0]).toMatch(ONE_HASH_CSP); + + 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(/ + + + + + `); + + expect(result).toContain(``); + 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..1a7cb15cd9c3 --- /dev/null +++ b/packages/angular/build/src/utils/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 './normalize-asset-patterns'; +export * from './normalize-optimization'; +export * from './normalize-source-maps'; +export * from './load-proxy-config'; 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..e590ee9efc6c --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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; + } + case '.mjs': + // Load the ESM configuration file using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + proxyConfiguration = await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath)); + break; + case '.cjs': + proxyConfiguration = require(proxyPath); + break; + default: + // The file could be either CommonJS or ESM. + // CommonJS is tried first then ESM if loading fails. + try { + proxyConfiguration = require(proxyPath); + break; + } catch (e) { + assertIsError(e); + if (e.code === 'ERR_REQUIRE_ESM' || e.code === 'ERR_REQUIRE_ASYNC_MODULE') { + // Load the ESM configuration file using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + proxyConfiguration = await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath)); + break; + } + + throw e; + } + } + + 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; + } + if (!Array.isArray(proxyEntry.context)) { + continue; + } + + // Array-form entries contain a context string array with the path(s) + // to use for the configuration entry. + const context = proxyEntry.context; + delete proxyEntry.context; + for (const contextEntry of context) { + if (typeof contextEntry !== 'string') { + continue; + } + + normalizedProxy[contextEntry] = proxyEntry; + } + } + } else { + normalizedProxy = proxy; + } + + // TODO: Consider upstreaming glob support + for (const key of Object.keys(normalizedProxy)) { + if (key[0] !== '^' && isDynamicPattern(key)) { + const pattern = makeRegExpFromGlob(key).source; + normalizedProxy[pattern] = normalizedProxy[key]; + delete normalizedProxy[key]; + } + } + + // 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; +} + +function pathRewriter(pathRewriteEntries: [RegExp, string][], path: string): string { + for (const [pattern, value] of pathRewriteEntries) { + const updated = path.replace(pattern, value); + if (path !== updated) { + return updated; + } + } + + return path; +} + +/** + * Calculates the line and column for an error offset in the content of a JSON file. + * @param location The offset error location from the beginning of the content. + * @param content The full content of the file containing the error. + * @returns An object containing the line and column + */ +function getJsonErrorLineColumn(offset: number, content: string) { + if (offset === 0) { + return { line: 1, column: 1 }; + } + + let line = 0; + let position = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + ++line; + + const nextNewline = content.indexOf('\n', position); + if (nextNewline === -1 || nextNewline > offset) { + break; + } + + position = nextNewline + 1; + } + + return { line, column: offset - position + 1 }; +} diff --git a/packages/angular_devkit/build_angular/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts similarity index 95% rename from packages/angular_devkit/build_angular/src/utils/load-translations.ts rename to packages/angular/build/src/utils/load-translations.ts index d481e6aa83ae..eeac280cbf9b 100644 --- a/packages/angular_devkit/build_angular/src/utils/load-translations.ts +++ b/packages/angular/build/src/utils/load-translations.ts @@ -3,12 +3,12 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import type { Diagnostics } from '@angular/localize/tools'; -import { createHash } from 'crypto'; -import * as fs from 'fs'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; import { loadEsmModule } from './load-esm'; export type TranslationLoader = (path: string) => { 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..8a8b2c2cbf1f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns.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 assert from 'node:assert'; +import { statSync } from 'node:fs'; +import * as path from 'node:path'; +import { AssetPattern, AssetPatternClass } from '../builders/application/schema'; + +export class MissingAssetSourceRootException extends Error { + constructor(path: string) { + super(`The ${path} asset path must start with the project source root.`); + } +} + +export function normalizeAssetPatterns( + assetPatterns: AssetPattern[], + workspaceRoot: string, + projectRoot: string, + projectSourceRoot: string | undefined, +): (AssetPatternClass & { output: string })[] { + if (assetPatterns.length === 0) { + return []; + } + + // When sourceRoot is not available, we default to ${projectRoot}/src. + const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src'); + const resolvedSourceRoot = path.resolve(workspaceRoot, sourceRoot); + + return assetPatterns.map((assetPattern) => { + // Normalize string asset patterns to objects. + if (typeof assetPattern === 'string') { + const assetPath = path.normalize(assetPattern); + const resolvedAssetPath = path.resolve(workspaceRoot, assetPath); + + // Check if the string asset is within sourceRoot. + if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) { + throw new MissingAssetSourceRootException(assetPattern); + } + + let glob: string, input: string; + let isDirectory = false; + + try { + isDirectory = statSync(resolvedAssetPath).isDirectory(); + } catch { + isDirectory = true; + } + + if (isDirectory) { + // Folders get a recursive star glob. + glob = '**/*'; + // Input directory is their original path. + input = assetPath; + } else { + // Files are their own glob. + glob = path.basename(assetPath); + // Input directory is their original dirname. + input = path.dirname(assetPath); + } + + // Output directory for both is the relative path from source root to input. + const output = path.relative(resolvedSourceRoot, path.resolve(workspaceRoot, input)); + + assetPattern = { glob, input, output }; + } else { + assetPattern.output = path.join('.', assetPattern.output ?? ''); + } + + assert(assetPattern.output !== undefined); + + if (assetPattern.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return assetPattern as AssetPatternClass & { output: string }; + }); +} 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..f272f6a78e45 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache.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 { join, 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; +} + +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'] + ); +} + +export function normalizeCacheOptions( + projectMetadata: unknown, + worspaceRoot: string, +): 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 = resolve(worspaceRoot, path); + + return { + enabled: cacheEnabled, + basePath: cacheBasePath, + path: join(cacheBasePath, VERSION), + }; +} 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..036dcb23502e --- /dev/null +++ b/packages/angular/build/src/utils/path.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 { posix } 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; +} 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..1861f9f2b1db --- /dev/null +++ b/packages/angular/build/src/utils/postcss-configuration.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 { readFile, readdir } from 'node:fs/promises'; +import { join } 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 async function generateSearchDirectories(roots: string[]): Promise { + return await 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); +} + +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 { + 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; + } + + // 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; +} 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..bc997652fef1 --- /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[path.length - 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..5851d052d54a --- /dev/null +++ b/packages/angular/build/src/utils/purge-cache.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 { 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, enabled } = normalizeCacheOptions(metadata, context.workspaceRoot); + + if (!enabled) { + return; + } + + let baseEntries; + try { + baseEntries = await readdir(basePath, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + return; + } + + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(basePath, d.name)) + .filter((cachePath) => cachePath !== path) + .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..e98879e58de7 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -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 path from 'node:path'; +import { glob } from 'tinyglobby'; + +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 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, + }); + + for (const file of files) { + const src = path.join(cwd, file); + const filePath = entry.flatten ? path.basename(file) : file; + + outputFiles.push({ source: src, destination: path.join(entry.output, filePath) }); + } + } + + return outputFiles; +} 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..9e1a657366a0 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.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 assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer'; + +/** + * @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;'); + +/** + * 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; + +const javascriptTransformer = new JavaScriptTransformer( + // Always enable JIT linking to support applications built with and without AOT. + // In a development environment the additional scope information does not + // have a negative effect unlike production where final output size is relevant. + { sourcemap: true, jit: true }, + 1, +); + +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?.pathname && + 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 async 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 source = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + // In-memory files have already been transformer during bundling and can be returned directly + return { + format, + shortCircuit: true, + source, + }; + } + + // Only module files potentially require transformation. Angular libraries that would + // need linking are ESM only. + if (format === 'module' && isFileProtocol(url)) { + const filePath = fileURLToPath(url); + let source = await javascriptTransformer.transformFile(filePath); + + if (filePath.includes('@angular/')) { + // Prepend 'var ngServerMode=true;' to the source. + source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, source]); + } + + return { + format, + shortCircuit: true, + source, + }; + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} + +function isFileProtocol(url: string): boolean { + return url.startsWith('file://'); +} + +function handleProcessExit(): void { + void javascriptTransformer.close(); +} + +process.once('exit', handleProcessExit); +process.once('SIGINT', handleProcessExit); +process.once('uncaughtException', handleProcessExit); 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..b23fe297bc19 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.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 + */ + +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { workerData } from 'node:worker_threads'; + +register('./loader-hooks.js', { parentURL: pathToFileURL(__filename), data: workerData }); 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..3af354f6ba0f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.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 + */ + +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const IMPORT_EXEC_ARGV = + '--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href; 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..cfb15b0d979b --- /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 { loadEsmModule } from '../load-esm'; +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 loadEsmModule('@angular/ssr/node'); + + 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..1d19a07e61de --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.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 type { ApplicationRef, Type } from '@angular/core'; +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: (() => 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..2dfad0ff2dfb --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { extname } from 'node:path'; +import { runInThisContext } from 'node:vm'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { createOutputFile } from '../../tools/esbuild/utils'; +import { shouldOptimizeChunks } from '../environment-options'; + +export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; +export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; + +interface FilesMapping { + path: string; + dynamicImport: boolean; +} + +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 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'], + 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('./${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[basePath.length - 1] === '/') { + basePath = basePath.slice(0, -1); + } + + const manifestContent = ` +export default { + basePath: '${basePath}', + supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, + entryPoints: { + ${Object.entries(entryPoints) + .map(([key, value]) => `'${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 function generateAngularServerAppManifest( + additionalHtmlOutputFiles: Map, + outputFiles: BuildOutputFile[], + inlineCriticalCss: boolean, + routes: readonly unknown[] | undefined, + locale: string | undefined, + baseHref: string, + initialFiles: Set, + metafile: Metafile, + publicPath: string | undefined, +): { + manifestContent: string; + serverAssetsChunks: BuildOutputFile[]; +} { + const serverAssetsChunks: BuildOutputFile[] = []; + const serverAssets: Record = {}; + + for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { + const extension = extname(file.path); + if (extension === '.html' || (inlineCriticalCss && extension === '.css')) { + const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; + const escapedContent = escapeUnsafeChars(file.text); + + serverAssetsChunks.push( + createOutputFile( + jsChunkFilePath, + `export default \`${escapedContent}\`;`, + BuildOutputFileType.ServerApplication, + ), + ); + + // This is needed because JavaScript engines script parser convert `\r\n` to `\n` in template literals, + // which can result in an incorrect byte length. + const size = runInThisContext(`new TextEncoder().encode(\`${escapedContent}\`).byteLength`); + + serverAssets[file.path] = + `{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; + } + } + + // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. + // When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings. + const entryPointToBrowserMapping = + routes?.length || shouldOptimizeChunks + ? undefined + : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); + + const manifestContent = ` +export default { + bootstrap: () => import('./main.server.mjs').then(m => m.default), + inlineCriticalCss: ${inlineCriticalCss}, + baseHref: '${baseHref}', + locale: ${JSON.stringify(locale)}, + routes: ${JSON.stringify(routes, undefined, 2)}, + entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, + assets: { + ${Object.entries(serverAssets) + .map(([key, value]) => `'${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/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..e087262a7f0c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -0,0 +1,407 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 } from '../../builders/application/schema'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import { assertIsError } from '../error'; +import { toPosixPath } from '../path'; +import { urlJoin } from '../url'; +import { WorkerPool } from '../worker-pool'; +import { IMPORT_EXEC_ARGV } from './esm-in-memory-loader/utils'; +import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; +import { + RouteRenderMode, + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, + SerializableRouteTreeNode, + WritableSerializableRouteTreeNode, +} from './models'; +import type { RenderWorkerData } from './render-worker'; + +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[]; + serializableRouteTreeNode: SerializableRouteTreeNode; +}> { + const outputFilesForWorker: 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 { + outputFilesForWorker[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 = outputFilesForWorker[filePath]; + if (jsContent) { + outputFilesForWorker[filePath] = + jsContent + + `\n//# sourceMappingURL=` + + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; + } + } + serverBundlesSourceMaps.clear(); + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + assetsReversed[addLeadingSlash(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) => { + return { + errors: [`An error occurred while extracting routes.\n\n${err.message ?? err.stack ?? 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: {}, + 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) { + outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = manifest.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, + appShellRoute ?? appShellOptions?.route, + ); + + errors.push(...renderingErrors); + + return { + errors, + warnings, + output, + serializableRouteTreeNode, + }; +} + +async function renderPages( + baseHref: string, + sourcemap: boolean, + serializableRouteTreeNode: SerializableRouteTreeNode, + maxThreads: number, + workspaceRoot: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + outputMode: OutputMode | undefined, + appShellRoute: string | undefined, +): Promise<{ + output: PrerenderOutput; + errors: string[]; +}> { + const output: PrerenderOutput = {}; + const errors: string[] = []; + const workerExecArgv = [IMPORT_EXEC_ARGV]; + + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./render-worker'), + maxThreads: Math.min(serializableRouteTreeNode.length, maxThreads), + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RenderWorkerData, + execArgv: workerExecArgv, + }); + + try { + const renderingPromises: Promise[] = []; + const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute); + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + + for (const { route, redirectTo } of serializableRouteTreeNode) { + // Remove the base href from the file output path. + const routeWithoutBaseHref = addTrailingSlash(route).startsWith( + baseHrefPathnameWithLeadingSlash, + ) + ? addLeadingSlash(route.slice(baseHrefPathnameWithLeadingSlash.length)) + : route; + + const outPath = posix.join(removeLeadingSlash(routeWithoutBaseHref), 'index.html'); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + const render: Promise = renderWorker.run({ url: route }); + const renderResult: Promise = render + .then((content) => { + if (content !== null) { + output[outPath] = { + content, + appShellRoute: appShellRouteWithLeadingSlash === routeWithoutBaseHref, + }; + } + }) + .catch((err) => { + errors.push( + `An error occurred while prerendering route '${route}'.\n\n${err.message ?? err.stack ?? err.code ?? err}`, + ); + void renderWorker.destroy(); + }); + + renderingPromises.push(renderResult); + } + + 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 = urlJoin(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: urlJoin(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, + }); + + 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.message ?? err.stack ?? err.code ?? err}`, + ], + serializedRouteTree: [], + }; + } finally { + void renderWorker.destroy(); + } +} + +function addLeadingSlash(value: string): string { + return value[0] === '/' ? value : '/' + value; +} + +function addTrailingSlash(url: string): string { + return url[url.length - 1] === '/' ? url : `${url}/`; +} + +function removeLeadingSlash(value: string): string { + return value[0] === '/' ? value.slice(1) : value; +} + +/** + * 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. + * + * @param url - The URL to which the page should redirect. + * @returns The HTML content of the static redirect page. + */ +function generateRedirectStaticPage(url: string): string { + return ` + + + + + Redirecting + + + +
Redirecting to ${url}
+ + +`.trim(); +} 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..92fad35df32a --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/render-worker.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 { workerData } from 'node:worker_threads'; +import type { OutputMode } from '../../builders/application/schema'; +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'; + +export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +} + +export interface RenderOptions { + url: string; +} + +/** + * 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 each route in routes and writes them to //index.html. + */ +async function renderPage({ url }: RenderOptions): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const response = await angularServerApp.handle( + new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), + ); + + return response ? response.text() : null; +} + +async function initialize() { + if (outputMode !== undefined && hasSsrEntry) { + serverURL = await launchServer(); + } + + patchFetchToLoadInMemoryAssets(serverURL); + + return renderPage; +} + +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..1487db07e811 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { + 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..83c90187178b --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils.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 + */ + +import type { createRequestHandler } from '@angular/ssr'; +import type { createNodeRequestHandler } from '@angular/ssr/node'; + +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; +} diff --git a/packages/angular_devkit/build_angular/src/utils/service-worker.ts b/packages/angular/build/src/utils/service-worker.ts similarity index 83% rename from packages/angular_devkit/build_angular/src/utils/service-worker.ts rename to packages/angular/build/src/utils/service-worker.ts index e19e6ef3a84e..c6f95f99a595 100644 --- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts +++ b/packages/angular/build/src/utils/service-worker.ts @@ -3,19 +3,24 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import type { Config, Filesystem } from '@angular/service-worker/config'; -import * as crypto from 'crypto'; -import type { OutputFile } from 'esbuild'; +import * as crypto from 'node:crypto'; import { existsSync, constants as fsConstants, promises as fsPromises } from 'node:fs'; -import * as path from 'path'; +import * as path from 'node:path'; +import { BuildOutputFile, BuildOutputFileType } from '../tools/esbuild/bundler-context'; +import { BuildOutputAsset } from '../tools/esbuild/bundler-execution-result'; import { assertIsError } from './error'; import { loadEsmModule } from './load-esm'; +import { toPosixPath } from './path'; class CliFilesystem implements Filesystem { - constructor(private fs: typeof fsPromises, private base: string) {} + constructor( + private fs: typeof fsPromises, + private base: string, + ) {} list(dir: string): Promise { return this._recursiveList(this._resolve(dir), []); @@ -48,7 +53,7 @@ class CliFilesystem implements Filesystem { if (stats.isFile()) { // Uses posix paths since the service worker expects URLs - items.push('/' + path.relative(this.base, entryPath).replace(/\\/g, '/')); + items.push('/' + toPosixPath(path.relative(this.base, entryPath))); } else if (stats.isDirectory()) { subdirectories.push(entryPath); } @@ -65,12 +70,17 @@ class CliFilesystem implements Filesystem { class ResultFilesystem implements Filesystem { private readonly fileReaders = new Map Promise>(); - constructor(outputFiles: OutputFile[], assetFiles: { source: string; destination: string }[]) { + constructor( + outputFiles: BuildOutputFile[], + assetFiles: { source: string; destination: string }[], + ) { for (const file of outputFiles) { - this.fileReaders.set('/' + file.path.replace(/\\/g, '/'), async () => file.contents); + if (file.type === BuildOutputFileType.Media || file.type === BuildOutputFileType.Browser) { + this.fileReaders.set('/' + toPosixPath(file.path), async () => file.contents); + } } for (const file of assetFiles) { - this.fileReaders.set('/' + file.destination.replace(/\\/g, '/'), () => + this.fileReaders.set('/' + toPosixPath(file.destination), () => fsPromises.readFile(file.source), ); } @@ -117,7 +127,7 @@ export async function augmentAppWithServiceWorker( outputPath: string, baseHref: string, ngswConfigPath?: string, - inputputFileSystem = fsPromises, + inputFileSystem = fsPromises, outputFileSystem = fsPromises, ): Promise { // Determine the configuration file path @@ -128,7 +138,7 @@ export async function augmentAppWithServiceWorker( // Read the configuration file let config: Config | undefined; try { - const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8'); + const configurationData = await inputFileSystem.readFile(configPath, 'utf-8'); config = JSON.parse(configurationData) as Config; } catch (error) { assertIsError(error); @@ -152,11 +162,7 @@ export async function augmentAppWithServiceWorker( const copy = async (src: string, dest: string): Promise => { const resolvedDest = path.join(outputPath, dest); - return inputputFileSystem === outputFileSystem - ? // Native FS (Builder). - inputputFileSystem.copyFile(src, resolvedDest, fsConstants.COPYFILE_FICLONE) - : // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory). - outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src)); + return outputFileSystem.writeFile(resolvedDest, await inputFileSystem.readFile(src)); }; await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), result.manifest); @@ -171,14 +177,19 @@ export async function augmentAppWithServiceWorkerEsbuild( workspaceRoot: string, configPath: string, baseHref: string, - outputFiles: OutputFile[], - assetFiles: { source: string; destination: string }[], -): Promise<{ manifest: string; assetFiles: { source: string; destination: string }[] }> { + indexHtml: string | undefined, + outputFiles: BuildOutputFile[], + assetFiles: BuildOutputAsset[], +): Promise<{ manifest: string; assetFiles: BuildOutputAsset[] }> { // Read the configuration file let config: Config | undefined; try { const configurationData = await fsPromises.readFile(configPath, 'utf-8'); config = JSON.parse(configurationData) as Config; + + if (indexHtml) { + config.index = indexHtml; + } } catch (error) { assertIsError(error); if (error.code === 'ENOENT') { diff --git a/packages/angular/build/src/utils/stats-table.ts b/packages/angular/build/src/utils/stats-table.ts new file mode 100644 index 000000000000..b007fd7a4aa5 --- /dev/null +++ b/packages/angular/build/src/utils/stats-table.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { stripVTControlCharacters } from 'node:util'; +import { BudgetCalculatorResult } from './bundle-calculator'; +import { colors as ansiColors } from './color'; +import { formatSize } from './format-bytes'; + +export type BundleStatsData = [ + files: string, + names: string, + rawSize: number | string, + estimatedTransferSize: number | string, +]; +export interface BundleStats { + initial: boolean; + stats: BundleStatsData; +} + +export function generateEsbuildBuildStatsTable( + [browserStats, serverStats]: [browserStats: BundleStats[], serverStats: BundleStats[]], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): string { + const bundleInfo = generateBuildStatsData( + browserStats, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + verbose, + ); + + if (serverStats.length) { + const m = (x: string) => (colors ? ansiColors.magenta(x) : x); + if (browserStats.length) { + bundleInfo.unshift([m('Browser bundles')]); + // Add seperators between browser and server logs + bundleInfo.push([], []); + } + + bundleInfo.push( + [m('Server bundles')], + ...generateBuildStatsData(serverStats, colors, false, false, undefined, verbose), + ); + } + + return generateTableText(bundleInfo, colors); +} + +export function generateBuildStatsTable( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], +): string { + const bundleInfo = generateBuildStatsData( + data, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + true, + ); + + return generateTableText(bundleInfo, colors); +} + +function generateBuildStatsData( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): (string | number)[][] { + if (data.length === 0) { + return []; + } + + const g = (x: string) => (colors ? ansiColors.green(x) : x); + const c = (x: string) => (colors ? ansiColors.cyan(x) : x); + const r = (x: string) => (colors ? ansiColors.redBright(x) : x); + const y = (x: string) => (colors ? ansiColors.yellowBright(x) : x); + const bold = (x: string) => (colors ? ansiColors.bold(x) : x); + const dim = (x: string) => (colors ? ansiColors.dim(x) : x); + + const getSizeColor = (name: string, file?: string, defaultColor = c) => { + const severity = budgets.get(name) || (file && budgets.get(file)); + switch (severity) { + case 'warning': + return y; + case 'error': + return r; + default: + return defaultColor; + } + }; + + const changedEntryChunksStats: BundleStatsData[] = []; + const changedLazyChunksStats: BundleStatsData[] = []; + + let initialTotalRawSize = 0; + let changedLazyChunksCount = 0; + let initialTotalEstimatedTransferSize; + const maxLazyChunksWithoutBudgetFailures = 15; + + const budgets = new Map(); + if (budgetFailures) { + for (const { label, severity } of budgetFailures) { + // In some cases a file can have multiple budget failures. + // Favor error. + if (label && (!budgets.has(label) || budgets.get(label) === 'warning')) { + budgets.set(label, severity); + } + } + } + + // Sort descending by raw size + data.sort((a, b) => { + if (a.stats[2] > b.stats[2]) { + return -1; + } + + if (a.stats[2] < b.stats[2]) { + return 1; + } + + return 0; + }); + + for (const { initial, stats } of data) { + const [files, names, rawSize, estimatedTransferSize] = stats; + if ( + !initial && + !verbose && + changedLazyChunksStats.length >= maxLazyChunksWithoutBudgetFailures && + !budgets.has(names) && + !budgets.has(files) + ) { + // Limit the number of lazy chunks displayed in the stats table when there is no budget failure and not in verbose mode. + changedLazyChunksCount++; + continue; + } + + const getRawSizeColor = getSizeColor(names, files); + let data: BundleStatsData; + if (showEstimatedTransferSize) { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + c( + typeof estimatedTransferSize === 'number' + ? formatSize(estimatedTransferSize) + : estimatedTransferSize, + ), + ]; + } else { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + '', + ]; + } + + if (initial) { + changedEntryChunksStats.push(data); + if (typeof rawSize === 'number') { + initialTotalRawSize += rawSize; + } + if (showEstimatedTransferSize && typeof estimatedTransferSize === 'number') { + if (initialTotalEstimatedTransferSize === undefined) { + initialTotalEstimatedTransferSize = 0; + } + initialTotalEstimatedTransferSize += estimatedTransferSize; + } + } else { + changedLazyChunksStats.push(data); + changedLazyChunksCount++; + } + } + + const bundleInfo: (string | number)[][] = []; + const baseTitles = ['Names', 'Raw size']; + + if (showEstimatedTransferSize) { + baseTitles.push('Estimated transfer size'); + } + + // Entry chunks + if (changedEntryChunksStats.length) { + bundleInfo.push(['Initial chunk files', ...baseTitles].map(bold), ...changedEntryChunksStats); + + if (showTotalSize) { + const initialSizeTotalColor = getSizeColor('bundle initial', undefined, (x) => x); + const totalSizeElements = [ + ' ', + 'Initial total', + initialSizeTotalColor(formatSize(initialTotalRawSize)), + ]; + if (showEstimatedTransferSize) { + totalSizeElements.push( + typeof initialTotalEstimatedTransferSize === 'number' + ? formatSize(initialTotalEstimatedTransferSize) + : '-', + ); + } + bundleInfo.push([], totalSizeElements.map(bold)); + } + } + + // Seperator + if (changedEntryChunksStats.length && changedLazyChunksStats.length) { + bundleInfo.push([]); + } + + // Lazy chunks + if (changedLazyChunksStats.length) { + bundleInfo.push(['Lazy chunk files', ...baseTitles].map(bold), ...changedLazyChunksStats); + + if (changedLazyChunksCount > changedLazyChunksStats.length) { + bundleInfo.push([ + dim( + `...and ${changedLazyChunksCount - changedLazyChunksStats.length} more lazy chunks files. ` + + 'Use "--verbose" to show all the files.', + ), + ]); + } + } + + return bundleInfo; +} + +function generateTableText(bundleInfo: (string | number)[][], colors: boolean): string { + const skipText = (value: string) => value.includes('...and '); + const longest: number[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < item.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentLongest = (longest[i] ??= 0); + const currentItemLength = stripVTControlCharacters(currentItem).length; + if (currentLongest < currentItemLength) { + longest[i] = currentItemLength; + } + } + } + + const seperator = colors ? ansiColors.dim(' | ') : ' | '; + const outputTable: string[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < longest.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentItemLength = stripVTControlCharacters(currentItem).length; + const stringPad = ' '.repeat(longest[i] - currentItemLength); + // Values in columns at index 2 and 3 (Raw and Estimated sizes) are always right aligned. + item[i] = i >= 2 ? stringPad + currentItem : currentItem + stringPad; + } + + outputTable.push(item.join(seperator)); + } + + return outputTable.join('\n'); +} diff --git a/packages/angular/build/src/utils/supported-browsers.ts b/packages/angular/build/src/utils/supported-browsers.ts new file mode 100644 index 000000000000..a8546a039082 --- /dev/null +++ b/packages/angular/build/src/utils/supported-browsers.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 browserslist from 'browserslist'; + +export function getSupportedBrowsers( + projectRoot: string, + logger: { warn(message: string): void }, +): string[] { + // Read the browserslist configuration containing Angular's browser support policy. + const angularBrowserslist = browserslist(undefined, { + path: require.resolve('../../.browserslistrc'), + }); + + // Use Angular's configuration as the default. + browserslist.defaults = angularBrowserslist; + + // Get the minimum set of browser versions supported by Angular. + const minimumBrowsers = new Set(angularBrowserslist); + + // Get browsers from config or default. + const browsersFromConfigOrDefault = new Set(browserslist(undefined, { path: projectRoot })); + + // Get browsers that support ES6 modules. + const browsersThatSupportEs6 = new Set(browserslist('supports es6-module')); + + const nonEs6Browsers: string[] = []; + const unsupportedBrowsers: string[] = []; + for (const browser of browsersFromConfigOrDefault) { + if (!browsersThatSupportEs6.has(browser)) { + // Any browser which does not support ES6 is explicitly ignored, as Angular will not build successfully. + browsersFromConfigOrDefault.delete(browser); + nonEs6Browsers.push(browser); + } else if (!minimumBrowsers.has(browser)) { + // Any other unsupported browser we will attempt to use, but provide no support for. + unsupportedBrowsers.push(browser); + } + } + + if (nonEs6Browsers.length) { + logger.warn( + `One or more browsers which are configured in the project's Browserslist configuration ` + + 'will be ignored as ES5 output is not supported by the Angular CLI.\n' + + `Ignored browsers:\n${nonEs6Browsers.join(', ')}`, + ); + } + + if (unsupportedBrowsers.length) { + logger.warn( + `One or more browsers which are configured in the project's Browserslist configuration ` + + "fall outside Angular's browser support for this version.\n" + + `Unsupported browsers:\n${unsupportedBrowsers.join(', ')}`, + ); + } + + return Array.from(browsersFromConfigOrDefault); +} diff --git a/packages/angular/build/src/utils/tty.ts b/packages/angular/build/src/utils/tty.ts new file mode 100644 index 000000000000..0d669c0301e3 --- /dev/null +++ b/packages/angular/build/src/utils/tty.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 + */ + +function _isTruthy(value: undefined | string): boolean { + // Returns true if value is a string that is anything but 0 or false. + return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; +} + +export function isTTY(): boolean { + // If we force TTY, we always return true. + const force = process.env['NG_FORCE_TTY']; + if (force !== undefined) { + return _isTruthy(force); + } + + return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); +} diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts new file mode 100644 index 000000000000..d3f1e5791276 --- /dev/null +++ b/packages/angular/build/src/utils/url.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 function urlJoin(...parts: string[]): string { + const [p, ...rest] = parts; + + // Remove trailing slash from first part + // Join all parts with `/` + // Dedupe double slashes from path names + return p.replace(/\/$/, '') + ('/' + rest.join('/')).replace(/\/\/+/g, '/'); +} diff --git a/packages/angular/build/src/utils/version.ts b/packages/angular/build/src/utils/version.ts new file mode 100644 index 000000000000..51f493bfe993 --- /dev/null +++ b/packages/angular/build/src/utils/version.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 + */ + +/* eslint-disable no-console */ + +import { createRequire } from 'node:module'; +import { SemVer, satisfies } from 'semver'; + +export function assertCompatibleAngularVersion(projectRoot: string): void | never { + let angularPkgJson; + + // Create a custom require function for ESM compliance. + // NOTE: The trailing slash is significant. + const projectRequire = createRequire(projectRoot + '/'); + + try { + angularPkgJson = projectRequire('@angular/core/package.json'); + } catch { + console.error( + 'Error: It appears that "@angular/core" is missing as a dependency. Please ensure it is included in your project.', + ); + + process.exit(2); + } + + if (!angularPkgJson?.['version']) { + console.error( + 'Error: Unable to determine the versions of "@angular/core".\n' + + 'This likely indicates a corrupted local installation. Please try reinstalling your packages.', + ); + + process.exit(2); + } + + const supportedAngularSemver = '0.0.0-ANGULAR-FW-PEER-DEP'; + if (angularPkgJson['version'] === '0.0.0' || supportedAngularSemver.startsWith('0.0.0')) { + // Internal CLI and FW testing version. + return; + } + + const angularVersion = new SemVer(angularPkgJson['version']); + + if (!satisfies(angularVersion, supportedAngularSemver, { includePrerelease: true })) { + console.error( + `Error: The current version of "@angular/build" supports Angular versions ${supportedAngularSemver},\n` + + `but detected Angular version ${angularVersion} instead.\n` + + 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.dev/', + ); + + process.exit(3); + } +} diff --git a/packages/angular/build/src/utils/worker-pool.ts b/packages/angular/build/src/utils/worker-pool.ts new file mode 100644 index 000000000000..78db4302ef1a --- /dev/null +++ b/packages/angular/build/src/utils/worker-pool.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 { getCompileCacheDir } from 'node:module'; +import { Piscina } from 'piscina'; + +export type WorkerPoolOptions = ConstructorParameters[0]; + +export class WorkerPool extends Piscina { + constructor(options: WorkerPoolOptions) { + const piscinaOptions: WorkerPoolOptions = { + minThreads: 1, + idleTimeout: 4_000, + // Web containers do not support transferable objects with receiveOnMessagePort which + // is used when the Atomics based wait loop is enable. + atomics: process.versions.webcontainer ? 'disabled' : 'sync', + recordTiming: false, + ...options, + }; + + // Enable compile code caching if enabled for the main process (only exists on Node.js v22.8+). + // Skip if running inside Bazel via a RUNFILES environment variable check. The cache does not work + // well with Bazel's hermeticity requirements. + const compileCacheDirectory = process.env['RUNFILES'] ? undefined : getCompileCacheDir?.(); + if (compileCacheDirectory) { + if (typeof piscinaOptions.env === 'object') { + piscinaOptions.env['NODE_COMPILE_CACHE'] = compileCacheDirectory; + } else { + // Default behavior of `env` option is to copy current process values + piscinaOptions.env = { + ...process.env, + 'NODE_COMPILE_CACHE': compileCacheDirectory, + }; + } + } + + super(piscinaOptions); + } +} diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index 07c5a7039608..2eacbb6b4ebf 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -1,21 +1,34 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license -load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") -load("//tools:defaults.bzl", "pkg_npm", "ts_library") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema") -load("//tools:toolchain_info.bzl", "TOOLCHAINS_NAMES", "TOOLCHAINS_VERSIONS") load("//tools:ts_json_schema.bzl", "ts_json_schema") licenses(["notice"]) package(default_visibility = ["//visibility:public"]) -ts_library( +npm_link_all_packages() + +RUNTIME_ASSETS = glob( + include = [ + "bin/**/*", + "src/**/*.md", + "src/**/*.json", + ], + exclude = [ + "lib/config/workspace-schema.json", + ], +) + [ + "//packages/angular/cli:lib/config/schema.json", +] + +ts_project( name = "angular-cli", - package_name = "@angular/cli", srcs = glob( include = [ "lib/**/*.ts", @@ -25,88 +38,46 @@ ts_library( "**/*_spec.ts", ], ) + [ - # @external_begin # These files are generated from the JSON schema "//packages/angular/cli:lib/config/workspace-schema.ts", "//packages/angular/cli:src/commands/update/schematic/schema.ts", - # @external_end ], - data = glob( - include = [ - "bin/**/*", - "src/**/*.md", - "src/**/*.json", - ], - exclude = [ - "lib/config/workspace-schema.json", - ], - ) + [ - # @external_begin - "//packages/angular/cli:lib/config/schema.json", - # @external_end - ], - module_name = "@angular/cli", + data = RUNTIME_ASSETS, deps = [ - "//packages/angular_devkit/architect", - "//packages/angular_devkit/architect/node", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core/node", - "//packages/angular_devkit/schematics", - "//packages/angular_devkit/schematics/tasks", - "//packages/angular_devkit/schematics/tools", - "@npm//@angular/core", - "@npm//@types/ini", - "@npm//@types/inquirer", - "@npm//@types/node", - "@npm//@types/npm-package-arg", - "@npm//@types/pacote", - "@npm//@types/resolve", - "@npm//@types/semver", - "@npm//@types/yargs", - "@npm//@types/yarnpkg__lockfile", - "@npm//@yarnpkg/lockfile", - "@npm//ansi-colors", - "@npm//ini", - "@npm//jsonc-parser", - "@npm//npm-package-arg", - "@npm//open", - "@npm//ora", - "@npm//pacote", - "@npm//semver", - "@npm//yargs", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular-devkit/schematics", + ":node_modules/@inquirer/prompts", + ":node_modules/@listr2/prompt-adapter-inquirer", + ":node_modules/@modelcontextprotocol/sdk", + ":node_modules/@yarnpkg/lockfile", + ":node_modules/algoliasearch", + ":node_modules/ini", + ":node_modules/jsonc-parser", + ":node_modules/npm-package-arg", + ":node_modules/npm-pick-manifest", + ":node_modules/pacote", + ":node_modules/resolve", + ":node_modules/yargs", + ":node_modules/zod", + "//:node_modules/@angular/core", + "//:node_modules/@types/ini", + "//:node_modules/@types/node", + "//:node_modules/@types/npm-package-arg", + "//:node_modules/@types/pacote", + "//:node_modules/@types/resolve", + "//:node_modules/@types/semver", + "//:node_modules/@types/yargs", + "//:node_modules/@types/yarnpkg__lockfile", + "//:node_modules/listr2", + "//:node_modules/semver", ], ) -# @external_begin CLI_SCHEMA_DATA = [ - "//packages/angular_devkit/build_angular:src/builders/application/schema.json", - "//packages/angular_devkit/build_angular:src/builders/app-shell/schema.json", - "//packages/angular_devkit/build_angular:src/builders/browser/schema.json", - "//packages/angular_devkit/build_angular:src/builders/browser-esbuild/schema.json", - "//packages/angular_devkit/build_angular:src/builders/dev-server/schema.json", - "//packages/angular_devkit/build_angular:src/builders/extract-i18n/schema.json", - "//packages/angular_devkit/build_angular:src/builders/jest/schema.json", - "//packages/angular_devkit/build_angular:src/builders/karma/schema.json", - "//packages/angular_devkit/build_angular:src/builders/ng-packagr/schema.json", - "//packages/angular_devkit/build_angular:src/builders/protractor/schema.json", - "//packages/angular_devkit/build_angular:src/builders/server/schema.json", - "//packages/schematics/angular:app-shell/schema.json", - "//packages/schematics/angular:application/schema.json", - "//packages/schematics/angular:class/schema.json", - "//packages/schematics/angular:component/schema.json", - "//packages/schematics/angular:directive/schema.json", - "//packages/schematics/angular:enum/schema.json", - "//packages/schematics/angular:guard/schema.json", - "//packages/schematics/angular:interceptor/schema.json", - "//packages/schematics/angular:interface/schema.json", - "//packages/schematics/angular:library/schema.json", - "//packages/schematics/angular:module/schema.json", - "//packages/schematics/angular:ng-new/schema.json", - "//packages/schematics/angular:pipe/schema.json", - "//packages/schematics/angular:resolver/schema.json", - "//packages/schematics/angular:service/schema.json", - "//packages/schematics/angular:service-worker/schema.json", - "//packages/schematics/angular:web-worker/schema.json", + "//packages/angular/build:schemas", + "//packages/angular_devkit/build_angular:schemas", + "//packages/schematics/angular:schemas", ] cli_json_schema( @@ -127,7 +98,7 @@ ts_json_schema( src = "src/commands/update/schematic/schema.json", ) -ts_library( +ts_project( name = "angular-cli_test_lib", testonly = True, srcs = glob( @@ -139,25 +110,19 @@ ts_library( ), deps = [ ":angular-cli", - "//packages/angular_devkit/core", - "//packages/angular_devkit/schematics", - "//packages/angular_devkit/schematics/testing", - "@npm//@types/semver", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular-devkit/schematics", + ":node_modules/yargs", + "//:node_modules/@types/semver", + "//:node_modules/@types/yargs", + "//:node_modules/semver", ], ) -[ - jasmine_node_test( - name = "angular-cli_test_" + toolchain_name, - srcs = [":angular-cli_test_lib"], - tags = [toolchain_name], - toolchain = toolchain, - ) - for toolchain_name, toolchain in zip( - TOOLCHAINS_NAMES, - TOOLCHAINS_VERSIONS, - ) -] +jasmine_test( + name = "test", + data = [":angular-cli_test_lib"], +) genrule( name = "license", @@ -166,8 +131,8 @@ genrule( cmd = "cp $(execpath //:LICENSE) $@", ) -pkg_npm( - name = "npm_package", +npm_package( + name = "pkg", pkg_deps = [ "//packages/angular_devkit/architect:package.json", "//packages/angular_devkit/build_angular:package.json", @@ -176,13 +141,13 @@ pkg_npm( "//packages/angular_devkit/schematics:package.json", "//packages/schematics/angular:package.json", ], + stamp_files = [ + "src/utilities/version.js", + ], tags = ["release-package"], - deps = [ + deps = RUNTIME_ASSETS + [ ":README.md", ":angular-cli", ":license", - ":src/commands/update/schematic/collection.json", - ":src/commands/update/schematic/schema.json", ], ) -# @external_end diff --git a/packages/angular/cli/README.md b/packages/angular/cli/README.md index 07b498c785dc..4fa87391f04c 100644 --- a/packages/angular/cli/README.md +++ b/packages/angular/cli/README.md @@ -2,4 +2,4 @@ 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](../../../README.md) file. +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/cli/bin/bootstrap.js b/packages/angular/cli/bin/bootstrap.js index 75e454ee74ff..18d1ed73160c 100644 --- a/packages/angular/cli/bin/bootstrap.js +++ b/packages/angular/cli/bin/bootstrap.js @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ /** @@ -18,4 +18,16 @@ * range. */ -import('../lib/init.js'); +// Enable on-disk code caching if available (Node.js 22.8+) +// Skip if running inside Bazel via a RUNFILES environment variable check and no explicit cache +// location defined. The default cache location does not work well with Bazel's hermeticity requirements. +if (!process.env['RUNFILES'] || process.env['NODE_COMPILE_CACHE']) { + try { + const { enableCompileCache } = require('node:module'); + + enableCompileCache?.(); + } catch {} +} + +// Initialize the Angular CLI +void import('../lib/init.js'); diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js index dbe5813f430b..e0f5eb36a2ef 100755 --- a/packages/angular/cli/bin/ng.js +++ b/packages/angular/cli/bin/ng.js @@ -4,7 +4,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ /* eslint-disable no-console */ @@ -31,7 +31,6 @@ try { } const rawCommandName = process.argv[2]; - if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completion') { // Skip Node.js supported checks when running ng completion. // A warning at this stage could cause a broken source action (`source <(ng completion script)`) when in the shell init script. @@ -43,30 +42,26 @@ if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completi // This node version check ensures that extremely old versions of node are not used. // These may not support ES2015 features such as const/let/async/await/etc. // These would then crash with a hard to diagnose error message. -var version = process.versions.node.split('.').map((part) => Number(part)); -if (version[0] % 2 === 1) { +const [major, minor] = process.versions.node.split('.', 2).map((part) => Number(part)); + +if (major % 2 === 1) { // Allow new odd numbered releases with a warning (currently v17+) console.warn( 'Node.js version ' + process.version + ' detected.\n' + 'Odd numbered Node.js versions will not enter LTS status and should not be used for production.' + - ' For more information, please see https://nodejs.org/en/about/releases/.', + ' For more information, please see https://nodejs.org/en/about/previous-releases/.', ); require('./bootstrap'); -} else if ( - version[0] < 14 || - (version[0] === 14 && version[1] < 20) || - (version[0] === 16 && version[1] < 14) || - (version[0] === 18 && version[1] < 10) -) { - // Error and exit if less than 14.20, 16.14 or 18.10 +} else if (major < 20 || (major === 20 && minor < 19) || (major === 22 && minor < 12)) { + // Error and exit if less than 20.19 or 22.12 console.error( 'Node.js version ' + process.version + ' detected.\n' + - 'The Angular CLI requires a minimum Node.js version of either v14.20, v16.14 or v18.10.\n\n' + + 'The Angular CLI requires a minimum Node.js version of v20.19 or v22.12.\n\n' + 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', ); diff --git a/packages/angular/cli/lib/cli/index.ts b/packages/angular/cli/lib/cli/index.ts index 9fab9c16d39c..ac7591e43630 100644 --- a/packages/angular/cli/lib/cli/index.ts +++ b/packages/angular/cli/lib/cli/index.ts @@ -3,20 +3,20 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; -import { format } from 'util'; +import { format, stripVTControlCharacters } from 'node:util'; import { CommandModuleError } from '../../src/command-builder/command-module'; import { runCommand } from '../../src/command-builder/command-runner'; -import { colors, removeColor } from '../../src/utilities/color'; +import { colors, supportColor } from '../../src/utilities/color'; import { ngDebug } from '../../src/utilities/environment-options'; import { writeErrorToLogFile } from '../../src/utilities/log-file'; export { VERSION } from '../../src/utilities/version'; -const MIN_NODEJS_VERSION = [16, 13] as const; +const MIN_NODEJS_VERSION = [20, 19] as const; /* eslint-disable no-console */ export default async function (options: { cliArgs: string[] }) { @@ -38,20 +38,21 @@ export default async function (options: { cliArgs: string[] }) { const colorLevels: Record string> = { info: (s) => s, debug: (s) => s, - warn: (s) => colors.bold.yellow(s), - error: (s) => colors.bold.red(s), - fatal: (s) => colors.bold.red(s), + warn: (s) => colors.bold(colors.yellow(s)), + error: (s) => colors.bold(colors.red(s)), + fatal: (s) => colors.bold(colors.red(s)), }; const logger = new logging.IndentLogger('cli-main-logger'); const logInfo = console.log; const logError = console.error; + const useColor = supportColor(); const loggerFinished = logger.forEach((entry) => { if (!ngDebug && entry.level === 'debug') { return; } - const color = colors.enabled ? colorLevels[entry.level] : removeColor; + const color = useColor ? colorLevels[entry.level] : stripVTControlCharacters; const message = color(entry.message); switch (entry.level) { diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index a10c0196c424..0c551dc4fb14 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -23,7 +23,7 @@ "projects": { "type": "object", "patternProperties": { - "^(?:@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+$": { + "^(?:@[a-zA-Z0-9._-]+/)?[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/project" } }, @@ -47,7 +47,7 @@ "packageManager": { "description": "Specify which package manager tool to use.", "type": "string", - "enum": ["npm", "cnpm", "yarn", "pnpm"] + "enum": ["npm", "cnpm", "yarn", "pnpm", "bun"] }, "warnings": { "description": "Control CLI specific console warnings", @@ -101,7 +101,7 @@ "packageManager": { "description": "Specify which package manager tool to use.", "type": "string", - "enum": ["npm", "cnpm", "yarn", "pnpm"] + "enum": ["npm", "cnpm", "yarn", "pnpm", "bun"] }, "warnings": { "description": "Control CLI specific console warnings", @@ -275,18 +275,42 @@ }, { "type": "object", - "description": "Localization options to use for the source locale", + "description": "Localization options to use for the source locale.", "properties": { "code": { "type": "string", - "description": "Specifies the locale code of the source locale", + "description": "Specifies the locale code of the source locale.", "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})+)?$" }, "baseHref": { "type": "string", - "description": "HTML base HREF to use for the locale (defaults to the locale code)" + "description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided." + }, + "subPath": { + "type": "string", + "description": "Defines the subpath for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.", + "pattern": "^[\\w-]*$" } }, + "anyOf": [ + { + "required": ["subPath"], + "not": { + "required": ["baseHref"] + } + }, + { + "required": ["baseHref"], + "not": { + "required": ["subPath"] + } + }, + { + "not": { + "required": ["baseHref", "subPath"] + } + } + ], "additionalProperties": false } ] @@ -299,11 +323,11 @@ "oneOf": [ { "type": "string", - "description": "Localization file to use for i18n" + "description": "Localization file to use for i18n." }, { "type": "array", - "description": "Localization files to use for i18n", + "description": "Localization files to use for i18n.", "items": { "type": "string", "uniqueItems": true @@ -311,17 +335,17 @@ }, { "type": "object", - "description": "Localization options to use for the locale", + "description": "Localization options to use for the locale.", "properties": { "translation": { "oneOf": [ { "type": "string", - "description": "Localization file to use for i18n" + "description": "Localization file to use for i18n." }, { "type": "array", - "description": "Localization files to use for i18n", + "description": "Localization files to use for i18n.", "items": { "type": "string", "uniqueItems": true @@ -331,9 +355,33 @@ }, "baseHref": { "type": "string", - "description": "HTML base HREF to use for the locale (defaults to the locale code)" + "description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided." + }, + "subPath": { + "type": "string", + "description": "Defines the URL segment for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.", + "pattern": "^[\\w-]*$" } }, + "anyOf": [ + { + "required": ["subPath"], + "not": { + "required": ["baseHref"] + } + }, + { + "required": ["baseHref"], + "not": { + "required": ["subPath"] + } + }, + { + "not": { + "required": ["baseHref", "subPath"] + } + } + ], "additionalProperties": false } ] @@ -354,6 +402,12 @@ "description": "The builder used for this package.", "not": { "enum": [ + "@angular/build:application", + "@angular/build:dev-server", + "@angular/build:extract-i18n", + "@angular/build:karma", + "@angular/build:ng-packagr", + "@angular/build:unit-test", "@angular-devkit/build-angular:application", "@angular-devkit/build-angular:app-shell", "@angular-devkit/build-angular:browser", @@ -361,10 +415,12 @@ "@angular-devkit/build-angular:dev-server", "@angular-devkit/build-angular:extract-i18n", "@angular-devkit/build-angular:karma", + "@angular-devkit/build-angular:ng-packagr", + "@angular-devkit/build-angular:prerender", "@angular-devkit/build-angular:jest", - "@angular-devkit/build-angular:protractor", + "@angular-devkit/build-angular:web-test-runner", "@angular-devkit/build-angular:server", - "@angular-devkit/build-angular:ng-packagr" + "@angular-devkit/build-angular:ssr-dev-server" ] } }, @@ -386,6 +442,28 @@ "additionalProperties": false, "required": ["builder"] }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:application" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -398,12 +476,12 @@ "description": "A default named configuration to use when a target configuration is not provided." }, "options": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/application/schema.json" + "$ref": "../../../../angular/build/src/builders/application/schema.json" }, "configurations": { "type": "object", "additionalProperties": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/application/schema.json" + "$ref": "../../../../angular/build/src/builders/application/schema.json" } } } @@ -474,6 +552,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -496,6 +596,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:extract-i18n" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -518,6 +640,50 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:unit-test" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/unit-test/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/unit-test/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:karma" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/karma/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/karma/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -567,19 +733,63 @@ "additionalProperties": false, "properties": { "builder": { - "const": "@angular-devkit/build-angular:protractor" + "const": "@angular-devkit/build-angular:web-test-runner" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:prerender" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/prerender/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/prerender/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:ssr-dev-server" }, "defaultConfiguration": { "type": "string", "description": "A default named configuration to use when a target configuration is not provided." }, "options": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/protractor/schema.json" + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" }, "configurations": { "type": "object", "additionalProperties": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/protractor/schema.json" + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" } } } @@ -627,6 +837,28 @@ } } } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:ng-packagr" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/ng-packagr/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/ng-packagr/schema.json" + } + } + } } ] } diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts index c23499622c66..cd324b6df69b 100644 --- a/packages/angular/cli/lib/init.ts +++ b/packages/angular/cli/lib/init.ts @@ -3,14 +3,12 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import 'symbol-observable'; -// symbol polyfill must go first -import { promises as fs } from 'fs'; -import { createRequire } from 'module'; -import * as path from 'path'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; import { SemVer, major } from 'semver'; import { colors } from '../src/utilities/color'; import { isWarningEnabled } from '../src/utilities/config'; @@ -62,7 +60,7 @@ let forceExit = false; let localVersion = cli.VERSION?.full; if (!localVersion) { try { - const localPackageJson = await fs.readFile( + const localPackageJson = await readFile( path.join(path.dirname(projectLocalCli), '../../package.json'), 'utf-8', ); diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 492c0ed2a9bf..a7816b7275f0 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -22,29 +22,32 @@ }, "homepage": "https://github.com/angular/angular-cli", "dependencies": { - "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", - "@angular-devkit/core": "0.0.0-PLACEHOLDER", - "@angular-devkit/schematics": "0.0.0-PLACEHOLDER", - "@schematics/angular": "0.0.0-PLACEHOLDER", + "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@angular-devkit/core": "workspace:0.0.0-PLACEHOLDER", + "@angular-devkit/schematics": "workspace:0.0.0-PLACEHOLDER", + "@inquirer/prompts": "7.6.0", + "@listr2/prompt-adapter-inquirer": "2.0.22", + "@modelcontextprotocol/sdk": "1.13.3", + "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.3", - "ini": "4.1.1", - "inquirer": "8.2.4", - "jsonc-parser": "3.2.0", - "npm-package-arg": "10.1.0", - "npm-pick-manifest": "8.0.1", - "open": "8.4.2", - "ora": "5.4.1", - "pacote": "15.2.0", - "resolve": "1.22.2", - "semver": "7.5.4", - "symbol-observable": "4.0.0", - "yargs": "17.7.2" + "algoliasearch": "5.32.0", + "ini": "5.0.0", + "jsonc-parser": "3.3.1", + "listr2": "8.3.3", + "npm-package-arg": "12.0.2", + "npm-pick-manifest": "10.0.0", + "pacote": "21.0.0", + "resolve": "1.22.10", + "semver": "7.7.2", + "yargs": "18.0.0", + "zod": "3.25.75" }, "ng-update": { "migrations": "@schematics/angular/migrations/migration-collection.json", "packageGroup": { "@angular/cli": "0.0.0-PLACEHOLDER", + "@angular/build": "0.0.0-PLACEHOLDER", + "@angular/ssr": "0.0.0-PLACEHOLDER", "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/build-angular": "0.0.0-PLACEHOLDER", "@angular-devkit/build-webpack": "0.0.0-EXPERIMENTAL-PLACEHOLDER", diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts index b43d7e5e16f5..052bc5cbe74c 100644 --- a/packages/angular/cli/src/analytics/analytics-collector.ts +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -3,13 +3,13 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { randomUUID } from 'crypto'; -import * as https from 'https'; -import * as os from 'os'; -import * as querystring from 'querystring'; +import { randomUUID } from 'node:crypto'; +import * as https from 'node:https'; +import * as os from 'node:os'; +import * as querystring from 'node:querystring'; import * as semver from 'semver'; import type { CommandContext } from '../command-builder/command-module'; import { ngDebug } from '../utilities/environment-options'; @@ -31,7 +31,10 @@ export class AnalyticsCollector { private readonly requestParameterStringified: string; private readonly userParameters: Record; - constructor(private context: CommandContext, userId: string) { + constructor( + private context: CommandContext, + userId: string, + ) { const requestParameters: Partial> = { [RequestParameter.ProtocolVersion]: 2, [RequestParameter.ClientId]: userId, diff --git a/packages/angular/cli/src/analytics/analytics-parameters.mts b/packages/angular/cli/src/analytics/analytics-parameters.mts new file mode 100644 index 000000000000..8a667dd9d2b8 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.mts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use 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 is a copy of analytics-parameters.ts and is needed for `yarn admin validate-user-analytics` due to ts-node. */ + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @remarks + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + UserId = 'up.ng_user_id', + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_pkg_manager_version', + PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v', +} + +/** + * Event scoped custom dimensions. + * @remarks + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @remarks + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + InitialChunksCount = 'epn.ng_initial_chunks_count', + ChangedChunksCount = 'epn.ng_changed_chunks_count', + DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', + AllProjectsCount = 'epn.all_projects_count', + LibraryProjectsCount = 'epn.libs_projects_count', + ApplicationProjectsCount = 'epn.apps_projects_count', +} diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts index f6902eb33b2e..08ee5d72a684 100644 --- a/packages/angular/cli/src/analytics/analytics-parameters.ts +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -3,9 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ +/** Any changes in this file needs to be done in the mts version. */ + export type PrimitiveTypes = string | number | boolean; /** @@ -41,7 +43,7 @@ export enum RequestParameter { /** * User scoped custom dimensions. - * @notes + * @remarks * - User custom dimensions limit is 25. * - `up.*` string type. * - `upn.*` number type. @@ -61,7 +63,7 @@ export enum UserCustomDimension { /** * Event scoped custom dimensions. - * @notes + * @remarks * - Event custom dimensions limit is 50. * - `ep.*` string type. * - `epn.*` number type. @@ -72,6 +74,7 @@ export enum EventCustomDimension { SchematicCollectionName = 'ep.ng_schematic_collection_name', SchematicName = 'ep.ng_schematic_name', Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', Style = 'ep.ng_style', Routing = 'ep.ng_routing', InlineTemplate = 'ep.ng_inline_template', @@ -83,7 +86,7 @@ export enum EventCustomDimension { /** * Event scoped custom mertics. - * @notes + * @remarks * - Event scoped custom mertics limit is 50. * - `ep.*` string type. * - `epn.*` number type. diff --git a/packages/angular/cli/src/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts index 2e610afb5dac..752b0dfca88a 100644 --- a/packages/angular/cli/src/analytics/analytics.ts +++ b/packages/angular/cli/src/analytics/analytics.ts @@ -3,15 +3,16 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { json, tags } from '@angular-devkit/core'; -import { randomUUID } from 'crypto'; +import { randomUUID } from 'node:crypto'; import type { CommandContext } from '../command-builder/command-module'; import { colors } from '../utilities/color'; import { getWorkspace } from '../utilities/config'; import { analyticsDisabled } from '../utilities/environment-options'; +import { askConfirmation } from '../utilities/prompt'; import { isTTY } from '../utilities/tty'; /* eslint-disable no-console */ @@ -74,24 +75,19 @@ export async function promptAnalytics( } if (force || isTTY()) { - const { prompt } = await import('inquirer'); - const answers = await prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share pseudonymous usage data about this project with the Angular Team - at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more - details and how to change this setting, see https://angular.io/analytics. - - `, - default: false, - }, - ]); - - await setAnalyticsConfig(global, answers.analytics); - - if (answers.analytics) { + const answer = await askConfirmation( + ` +Would you like to share pseudonymous usage data about this project with the Angular Team +at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more +details and how to change this setting, see https://angular.dev/cli/analytics. + + `, + false, + ); + + await setAnalyticsConfig(global, answer); + + if (answer) { console.log(''); console.log( tags.stripIndent` diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts index b5ebe8d8bf28..566e0e62b209 100644 --- a/packages/angular/cli/src/command-builder/architect-base-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Architect, Target } from '@angular-devkit/architect'; @@ -12,9 +12,8 @@ import { WorkspaceNodeModulesArchitectHost, } from '@angular-devkit/architect/node'; import { json } from '@angular-devkit/core'; -import { spawnSync } from 'child_process'; -import { existsSync } from 'fs'; -import { resolve } from 'path'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; import { assertIsError } from '../utilities/error'; @@ -42,7 +41,7 @@ export abstract class ArchitectBaseCommandModule protected readonly missingTargetChoices: MissingTargetChoice[] | undefined; protected async runSingleTarget(target: Target, options: OtherOptions): Promise { - const architectHost = await this.getArchitectHost(); + const architectHost = this.getArchitectHost(); let builderName: string; try { @@ -53,10 +52,16 @@ export abstract class ArchitectBaseCommandModule return this.onMissingTarget(e.message); } + const isAngularBuild = builderName.startsWith('@angular/build:'); + const { logger } = this.context; - const run = await this.getArchitect().scheduleTarget(target, options as json.JsonObject, { - logger, - }); + const run = await this.getArchitect(isAngularBuild).scheduleTarget( + target, + options as json.JsonObject, + { + logger, + }, + ); const analytics = isPackageNameSafeForAnalytics(builderName) ? await this.getAnalytics() @@ -151,13 +156,17 @@ export abstract class ArchitectBaseCommandModule } private _architect: Architect | undefined; - protected getArchitect(): Architect { + protected getArchitect(skipUndefinedArrayTransform: boolean): Architect { if (this._architect) { return this._architect; } const registry = new json.schema.CoreSchemaRegistry(); - registry.addPostTransform(json.schema.transforms.addUndefinedDefaults); + if (skipUndefinedArrayTransform) { + registry.addPostTransform(json.schema.transforms.addUndefinedObjectDefaults); + } else { + registry.addPostTransform(json.schema.transforms.addUndefinedDefaults); + } registry.useXDeprecatedProvider((msg) => this.context.logger.warn(msg)); const architectHost = this.getArchitectHost(); @@ -201,17 +210,13 @@ export abstract class ArchitectBaseCommandModule return; } - // Check for a `node_modules` directory (npm, yarn non-PnP, etc.) - if (existsSync(resolve(basePath, 'node_modules'))) { + // Check if yarn PnP is used. https://yarnpkg.com/advanced/pnpapi#processversionspnp + if (process.versions.pnp) { return; } - // Check for yarn PnP files - if ( - existsSync(resolve(basePath, '.pnp.js')) || - existsSync(resolve(basePath, '.pnp.cjs')) || - existsSync(resolve(basePath, '.pnp.mjs')) - ) { + // Check for a `node_modules` directory (npm, yarn non-PnP, etc.) + if (existsSync(resolve(basePath, 'node_modules'))) { return; } @@ -247,15 +252,15 @@ export abstract class ArchitectBaseCommandModule const packageToInstall = await this.getMissingTargetPackageToInstall(choices); if (packageToInstall) { - // Example run: `ng add @angular-eslint/schematics`. - const binPath = resolve(__dirname, '../../bin/ng.js'); - const { error } = spawnSync(process.execPath, [binPath, 'add', packageToInstall], { - stdio: 'inherit', + // Example run: `ng add angular-eslint`. + const AddCommandModule = (await import('../commands/add/cli')).default; + await new AddCommandModule(this.context).run({ + interactive: true, + force: false, + dryRun: false, + defaults: false, + collection: packageToInstall, }); - - if (error) { - throw error; - } } } else { // Non TTY display error message. diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts index a57c74f0eeef..4855b629b360 100644 --- a/packages/angular/cli/src/command-builder/architect-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-command-module.ts @@ -3,9 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ +import { Target } from '@angular-devkit/architect'; +import { workspaces } from '@angular-devkit/core'; import { Argv } from 'yargs'; import { getProjectByCwd } from '../utilities/config'; import { memoize } from '../utilities/memoize'; @@ -28,7 +30,33 @@ export abstract class ArchitectCommandModule { abstract readonly multiTarget: boolean; + findDefaultBuilderName?( + project: workspaces.ProjectDefinition, + target: Target, + ): Promise; + async builder(argv: Argv): Promise> { + const target = this.getArchitectTarget(); + + // Add default builder if target is not in project and a command default is provided + if (this.findDefaultBuilderName && this.context.workspace) { + for (const [project, projectDefinition] of this.context.workspace.projects) { + if (projectDefinition.targets.has(target)) { + continue; + } + + const defaultBuilder = await this.findDefaultBuilderName(projectDefinition, { + project, + target, + }); + if (defaultBuilder) { + projectDefinition.targets.set(target, { + builder: defaultBuilder, + }); + } + } + } + const project = this.getArchitectProject(); const { jsonHelp, getYargsCompletions, help } = this.context.args.options; @@ -44,7 +72,7 @@ export abstract class ArchitectCommandModule `One or more named builder configurations as a comma-separated ` + `list as specified in the "configurations" section in angular.json.\n` + `The builder uses the named configurations to run the given target.\n` + - `For more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.`, + `For more information, see https://angular.dev/reference/configs/workspace-config#alternate-build-configurations.`, alias: 'c', type: 'string', // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. @@ -60,7 +88,6 @@ export abstract class ArchitectCommandModule return localYargs; } - const target = this.getArchitectTarget(); const schemaOptions = await this.getArchitectTargetOptions({ project, target, diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 3e3a13e3ce38..0b18512180d2 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -3,21 +3,19 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { logging, schema, strings } from '@angular-devkit/core'; -import { readFileSync } from 'fs'; -import * as path from 'path'; -import yargs, { - Arguments, +import { logging, schema } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import type { ArgumentsCamelCase, Argv, CamelCaseKey, - PositionalOptions, CommandModule as YargsCommandModule, - Options as YargsOptions, -} from 'yargs'; + // Resolution mode is required due to CamelCaseKey missing from esm types +} from 'yargs' with { 'resolution-mode': 'require' }; import { Parser as yargsParser } from 'yargs/helpers'; import { getAnalyticsUserId } from '../analytics/analytics'; import { AnalyticsCollector } from '../analytics/analytics-collector'; @@ -26,15 +24,17 @@ import { considerSettingUpAutocompletion } from '../utilities/completion'; import { AngularWorkspace } from '../utilities/config'; import { memoize } from '../utilities/memoize'; import { PackageManagerUtils } from '../utilities/package-manager'; -import { Option } from './utilities/json-schema'; +import { Option, addSchemaOptionsToCommand } from './utilities/json-schema'; export type Options = { [key in keyof T as CamelCaseKey]: T[key] }; export enum CommandScope { /** Command can only run inside an Angular workspace. */ In, + /** Command can only run outside an Angular workspace. */ Out, + /** Command can run inside and outside an Angular workspace. */ Both, } @@ -46,6 +46,8 @@ export interface CommandContext { globalConfiguration: AngularWorkspace; logger: logging.Logger; packageManager: PackageManagerUtils; + yargsInstance: Argv<{}>; + /** Arguments parsed in free-from without parser configuration. */ args: { positional: string[]; @@ -63,10 +65,13 @@ export interface CommandModuleImplementation extends Omit, 'builder' | 'handler'> { /** Scope in which the command can be executed in. */ scope: CommandScope; + /** Path used to load the long description for the command in JSON help text. */ longDescriptionPath?: string; + /** Object declaring the options the command accepts, or a function accepting and returning a yargs instance. */ builder(argv: Argv): Promise> | Argv; + /** A function which will be passed the parsed argv. */ run(options: Options & OtherOptions): Promise | number | void; } @@ -176,8 +181,8 @@ export abstract class CommandModule implements CommandModuleI const userId = await getAnalyticsUserId( this.context, - // Don't prompt for `ng update` and `ng analytics` commands. - ['update', 'analytics'].includes(this.commandName), + // Don't prompt on `ng update`, 'ng version' or `ng analytics`. + ['version', 'update', 'analytics'].includes(this.commandName), ); return userId ? new AnalyticsCollector(this.context, userId) : undefined; @@ -188,68 +193,16 @@ export abstract class CommandModule implements CommandModuleI * **Note:** This method should be called from the command bundler method. */ protected addSchemaOptionsToCommand(localYargs: Argv, options: Option[]): Argv { - const booleanOptionsWithNoPrefix = new Set(); - - for (const option of options) { - const { - default: defaultVal, - positional, - deprecated, - description, - alias, - userAnalytics, - type, - hidden, - name, - choices, - } = option; - - const sharedOptions: YargsOptions & PositionalOptions = { - alias, - hidden, - description, - deprecated, - choices, - // This should only be done when `--help` is used otherwise default will override options set in angular.json. - ...(this.context.args.options.help ? { default: defaultVal } : {}), - }; - - let dashedName = strings.dasherize(name); - - // Handle options which have been defined in the schema with `no` prefix. - if (type === 'boolean' && dashedName.startsWith('no-')) { - dashedName = dashedName.slice(3); - booleanOptionsWithNoPrefix.add(dashedName); - } - - if (positional === undefined) { - localYargs = localYargs.option(dashedName, { - type, - ...sharedOptions, - }); - } else { - localYargs = localYargs.positional(dashedName, { - type: type === 'array' || type === 'count' ? 'string' : type, - ...sharedOptions, - }); - } - - // Record option of analytics. - if (userAnalytics !== undefined) { - this.optionsWithAnalytics.set(name, userAnalytics); - } - } + const optionsWithAnalytics = addSchemaOptionsToCommand( + localYargs, + options, + // This should only be done when `--help` is used otherwise default will override options set in angular.json. + /* includeDefaultValues= */ this.context.args.options.help, + ); - // Handle options which have been defined in the schema with `no` prefix. - if (booleanOptionsWithNoPrefix.size) { - localYargs.middleware((options: Arguments) => { - for (const key of booleanOptionsWithNoPrefix) { - if (key in options) { - options[`no-${key}`] = !options[key]; - delete options[key]; - } - } - }, false); + // Record option of analytics. + for (const [name, userAnalytics] of optionsWithAnalytics) { + this.optionsWithAnalytics.set(name, userAnalytics); } return localYargs; @@ -297,7 +250,7 @@ export abstract class CommandModule implements CommandModuleI private reportCommandRunAnalytics(analytics: AnalyticsCollector): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const internalMethods = (yargs as any).getInternalMethods(); + const internalMethods = (this.context.yargsInstance as any).getInternalMethods(); // $0 generate component [name] -> generate_component // $0 add -> add const fullCommand = (internalMethods.getUsageInstance().getUsage()[0][0] as string) diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index bacf9ac98626..8843ea09d461 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; @@ -19,6 +19,7 @@ import { colors } from '../utilities/color'; import { AngularWorkspace, getWorkspace } from '../utilities/config'; import { assertIsError } from '../utilities/error'; import { PackageManagerUtils } from '../utilities/package-manager'; +import { VERSION } from '../utilities/version'; import { CommandContext, CommandModuleError } from './command-module'; import { CommandModuleConstructor, @@ -26,7 +27,7 @@ import { demandCommandFailureMessage, } from './utilities/command'; import { jsonHelpUsage } from './utilities/json-help'; -import { normalizeOptionsMiddleware } from './utilities/normalize-options-middleware'; +import { createNormalizeOptionsMiddleware } from './utilities/normalize-options-middleware'; const yargsParser = Parser as unknown as typeof Parser.default; @@ -61,11 +62,14 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis } const root = workspace?.basePath ?? process.cwd(); + const localYargs = yargs(args); + const context: CommandContext = { globalConfiguration, workspace, logger, currentDirectory: process.cwd(), + yargsInstance: localYargs, root, packageManager: new PackageManagerUtils({ globalConfiguration, workspace, root }), args: { @@ -79,17 +83,21 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis }, }; - let localYargs = yargs(args); for (const CommandModule of await getCommandsToRegister(positional[0])) { - localYargs = addCommandModuleToYargs(localYargs, CommandModule, context); + addCommandModuleToYargs(CommandModule, context); } if (jsonHelp) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance(); - usageInstance.help = () => jsonHelpUsage(); + usageInstance.help = () => jsonHelpUsage(localYargs); } + // Add default command to support version option when no subcommand is specified + localYargs.command('*', false, (builder) => + builder.version('version', 'Show Angular CLI version.', VERSION.full), + ); + await localYargs .scriptName('ng') // https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing-yargs-parser @@ -118,10 +126,10 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis 'deprecated: %s': colors.yellow('deprecated:') + ' %s', 'Did you mean %s?': 'Unknown command. Did you mean %s?', }) - .epilogue('For more information, see https://angular.io/cli/.\n') + .epilogue('For more information, see https://angular.dev/cli/.\n') .demandCommand(1, demandCommandFailureMessage) .recommendCommands() - .middleware(normalizeOptionsMiddleware) + .middleware(createNormalizeOptionsMiddleware(localYargs)) .version(false) .showHelpOnFail(false) .strict() @@ -132,10 +140,10 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis : // Unknown exception, re-throw. err; }) - .wrap(yargs.terminalWidth()) + .wrap(localYargs.terminalWidth()) .parseAsync(); - return process.exitCode ?? 0; + return +(process.exitCode ?? 0); } /** diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts index e66b004ae23b..738fd497382b 100644 --- a/packages/angular/cli/src/command-builder/schematics-command-module.ts +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -3,18 +3,17 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { normalize as devkitNormalize, schema } from '@angular-devkit/core'; +import { JsonValue, normalize as devkitNormalize, schema } from '@angular-devkit/core'; import { Collection, UnsuccessfulWorkflowExecution, formats } from '@angular-devkit/schematics'; import { FileSystemCollectionDescription, FileSystemSchematicDescription, NodeWorkflow, } from '@angular-devkit/schematics/tools'; -import type { CheckboxQuestion, Question } from 'inquirer'; -import { relative, resolve } from 'path'; +import { relative } from 'node:path'; import { Argv } from 'yargs'; import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; import { EventCustomDimension } from '../analytics/analytics-parameters'; @@ -63,6 +62,7 @@ export abstract class SchematicsCommandModule .option('dry-run', { describe: 'Run through and reports activity without writing out results.', type: 'boolean', + alias: ['d'], default: false, }) .option('defaults', { @@ -143,103 +143,126 @@ export abstract class SchematicsCommandModule workingDir === '' ? undefined : workingDir, ); - let shouldReportAnalytics = true; workflow.engineHost.registerOptionsTransform(async (schematic, options) => { - // Report analytics - if (shouldReportAnalytics) { - shouldReportAnalytics = false; - - const { - collection: { name: collectionName }, - name: schematicName, - } = schematic; - - const analytics = isPackageNameSafeForAnalytics(collectionName) - ? await this.getAnalytics() - : undefined; - - analytics?.reportSchematicRunEvent({ - [EventCustomDimension.SchematicCollectionName]: collectionName, - [EventCustomDimension.SchematicName]: schematicName, - ...this.getAnalyticsParameters(options as unknown as {}), - }); - } + const { + collection: { name: collectionName }, + name: schematicName, + } = schematic; + + const analytics = isPackageNameSafeForAnalytics(collectionName) + ? await this.getAnalytics() + : undefined; + + analytics?.reportSchematicRunEvent({ + [EventCustomDimension.SchematicCollectionName]: collectionName, + [EventCustomDimension.SchematicName]: schematicName, + ...this.getAnalyticsParameters(options as unknown as {}), + }); return options; }); if (options.interactive !== false && isTTY()) { workflow.registry.usePromptProvider(async (definitions: Array) => { - const questions = definitions - .filter((definition) => !options.defaults || definition.default === undefined) - .map((definition) => { - const question: Question = { - name: definition.id, - message: definition.message, - default: definition.default, - }; - - const validator = definition.validator; - if (validator) { - question.validate = (input) => validator(input); - - // Filter allows transformation of the value prior to validation - question.filter = async (input) => { - for (const type of definition.propertyTypes) { - let value; - switch (type) { - case 'string': - value = String(input); - break; - case 'integer': - case 'number': - value = Number(input); - break; - default: - value = input; - break; - } - // Can be a string if validation fails - const isValid = (await validator(value)) === true; - if (isValid) { - return value; + let prompts: typeof import('@inquirer/prompts') | undefined; + const answers: Record = {}; + + for (const definition of definitions) { + if (options.defaults && definition.default !== undefined) { + continue; + } + + // Only load prompt package if needed + prompts ??= await import('@inquirer/prompts'); + + switch (definition.type) { + case 'confirmation': + answers[definition.id] = await prompts.confirm({ + message: definition.message, + default: definition.default as boolean | undefined, + }); + break; + case 'list': + if (!definition.items?.length) { + continue; + } + + answers[definition.id] = await ( + definition.multiselect ? prompts.checkbox : prompts.select + )({ + message: definition.message, + validate: (values) => { + if (!definition.validator) { + return true; } - } - return input; - }; - } - - switch (definition.type) { - case 'confirmation': - question.type = 'confirm'; - break; - case 'list': - question.type = definition.multiselect ? 'checkbox' : 'list'; - (question as CheckboxQuestion).choices = definition.items?.map((item) => { - return typeof item == 'string' - ? item + return definition.validator(Object.values(values).map(({ value }) => value)); + }, + default: definition.multiselect ? undefined : definition.default, + choices: definition.items?.map((item) => + typeof item == 'string' + ? { + name: item, + value: item, + } : { + ...item, name: item.label, value: item.value, - }; - }); - break; - default: - question.type = definition.type; - break; - } + }, + ), + }); + break; + case 'input': { + let finalValue: JsonValue | undefined; + answers[definition.id] = await prompts.input({ + message: definition.message, + default: definition.default as string | undefined, + async validate(value) { + if (definition.validator === undefined) { + return true; + } - return question; - }); + let lastValidation: ReturnType = false; + for (const type of definition.propertyTypes) { + let potential; + switch (type) { + case 'string': + potential = String(value); + break; + case 'integer': + case 'number': + potential = Number(value); + break; + default: + potential = value; + break; + } + lastValidation = await definition.validator(potential); + + // Can be a string if validation fails + if (lastValidation === true) { + finalValue = potential; + + return true; + } + } - if (questions.length) { - const { prompt } = await import('inquirer'); + return lastValidation; + }, + }); - return prompt(questions); - } else { - return {}; + // Use validated value if present. + // This ensures the correct type is inserted into the final schema options. + if (finalValue !== undefined) { + answers[definition.id] = finalValue; + } + break; + } + } } + + return answers; }); } @@ -248,12 +271,6 @@ export abstract class SchematicsCommandModule @memoize protected async getSchematicCollections(): Promise> { - // Resolve relative collections from the location of `angular.json` - const resolveRelativeCollection = (collectionName: string) => - collectionName.charAt(0) === '.' - ? resolve(this.context.root, collectionName) - : collectionName; - const getSchematicCollections = ( configSection: Record | undefined, ): Set | undefined => { @@ -263,7 +280,7 @@ export abstract class SchematicsCommandModule const { schematicCollections } = configSection; if (Array.isArray(schematicCollections)) { - return new Set(schematicCollections.map((c) => resolveRelativeCollection(c))); + return new Set(schematicCollections); } return undefined; @@ -355,7 +372,7 @@ export abstract class SchematicsCommandModule } private getProjectName(): string | undefined { - const { workspace, logger } = this.context; + const { workspace } = this.context; if (!workspace) { return undefined; } @@ -370,6 +387,10 @@ export abstract class SchematicsCommandModule private getResolvePaths(collectionName: string): string[] { const { workspace, root } = this.context; + if (collectionName[0] === '.') { + // Resolve relative collections from the location of `angular.json` + return [root]; + } return workspace ? // Workspace diff --git a/packages/angular/cli/src/command-builder/utilities/command.ts b/packages/angular/cli/src/command-builder/utilities/command.ts index 5ba067e38209..8b019aba9064 100644 --- a/packages/angular/cli/src/command-builder/utilities/command.ts +++ b/packages/angular/cli/src/command-builder/utilities/command.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; @@ -20,11 +20,10 @@ export type CommandModuleConstructor = Partial & { new (context: CommandContext): Partial & CommandModule; }; -export function addCommandModuleToYargs( - localYargs: Argv, +export function addCommandModuleToYargs( commandModule: U, context: CommandContext, -): Argv { +): void { const cmd = new commandModule(context); const { args: { @@ -35,7 +34,7 @@ export function addCommandModuleToYargs; + return cmd.builder(argv) as Argv; }, handler: (args) => cmd.handler(args), }); diff --git a/packages/angular/cli/src/command-builder/utilities/json-help.ts b/packages/angular/cli/src/command-builder/utilities/json-help.ts index 2f1969e1e092..0d5c6a53a1e6 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-help.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-help.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import yargs from 'yargs'; +import { Argv } from 'yargs'; import { FullDescribe } from '../command-module'; interface JsonHelpOption { @@ -42,9 +42,9 @@ export interface JsonHelp extends JsonHelpDescription { const yargsDefaultCommandRegExp = /^\$0|\*/; -export function jsonHelpUsage(): string { +export function jsonHelpUsage(localYargs: Argv): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const localYargs = yargs as any; + const localYargsInstance = localYargs as any; const { deprecatedOptions, alias: aliases, @@ -56,31 +56,33 @@ export function jsonHelpUsage(): string { demandedOptions, default: defaultVal, hiddenOptions = [], - } = localYargs.getOptions(); + } = localYargsInstance.getOptions(); - const internalMethods = localYargs.getInternalMethods(); + const internalMethods = localYargsInstance.getInternalMethods(); const usageInstance = internalMethods.getUsageInstance(); const context = internalMethods.getContext(); const descriptions = usageInstance.getDescriptions(); - const groups = localYargs.getGroups(); + const groups = localYargsInstance.getGroups(); const positional = groups[usageInstance.getPositionalGroupName()] as string[] | undefined; - + const seen = new Set(); const hidden = new Set(hiddenOptions); const normalizeOptions: JsonHelpOption[] = []; const allAliases = new Set([...Object.values(aliases).flat()]); + // Reverted order of https://github.com/yargs/yargs/blob/971e351705f0fbc5566c6ed1dfd707fa65e11c0d/lib/usage.ts#L419-L424 for (const [names, type] of [ + [number, 'number'], [array, 'array'], [string, 'string'], [boolean, 'boolean'], - [number, 'number'], ]) { for (const name of names) { - if (allAliases.has(name) || hidden.has(name)) { + if (allAliases.has(name) || hidden.has(name) || seen.has(name)) { // Ignore hidden, aliases and already visited option. continue; } + seen.add(name); const positionalIndex = positional?.indexOf(name) ?? -1; const alias = aliases[name]; @@ -124,7 +126,7 @@ export function jsonHelpUsage(): string { const output: JsonHelp = { name: [...context.commands].pop(), - command: `${command?.replace(yargsDefaultCommandRegExp, localYargs['$0'])}${defaultSubCommand}`, + command: `${command?.replace(yargsDefaultCommandRegExp, localYargsInstance['$0'])}${defaultSubCommand}`, ...parseDescription(rawDescription), options: normalizeOptions.sort((a, b) => a.name.localeCompare(b.name)), subcommands: otherSubcommands.length ? otherSubcommands : undefined, diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts index c8649db75020..84af5f2d3641 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts @@ -3,16 +3,16 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { json } from '@angular-devkit/core'; -import yargs from 'yargs'; +import { json, strings } from '@angular-devkit/core'; +import type { Arguments, Argv, PositionalOptions, Options as YargsOptions } from 'yargs'; /** * An option description. */ -export interface Option extends yargs.Options { +export interface Option extends YargsOptions { /** * The name of the option. */ @@ -43,6 +43,55 @@ export interface Option extends yargs.Options { * If this is falsey, do not report this option. */ userAnalytics?: string; + + /** + * Type of the values in a key/value pair field. + */ + itemValueType?: 'string'; +} + +function coerceToStringMap( + dashedName: string, + value: (string | undefined)[], +): Record | Promise { + const stringMap: Record = {}; + for (const pair of value) { + // This happens when the flag isn't passed at all. + if (pair === undefined) { + continue; + } + + const eqIdx = pair.indexOf('='); + if (eqIdx === -1) { + // TODO: Remove workaround once yargs properly handles thrown errors from coerce. + // Right now these sometimes end up as uncaught exceptions instead of proper validation + // errors with usage output. + return Promise.reject( + new Error( + `Invalid value for argument: ${dashedName}, Given: '${pair}', Expected key=value pair`, + ), + ); + } + const key = pair.slice(0, eqIdx); + const value = pair.slice(eqIdx + 1); + stringMap[key] = value; + } + + return stringMap; +} + +function isStringMap(node: json.JsonObject): boolean { + // Exclude fields with more specific kinds of properties. + if (node.properties || node.patternProperties) { + return false; + } + + // Restrict to additionalProperties with string values. + return ( + json.isJsonObject(node.additionalProperties) && + !node.additionalProperties.enum && + node.additionalProperties.type === 'string' + ); } export async function parseJsonSchemaToOptions( @@ -106,10 +155,13 @@ export async function parseJsonSchemaToOptions( return false; + case 'object': + return isStringMap(current); + default: return false; } - }) as ('string' | 'number' | 'boolean' | 'array')[]; + }) as ('string' | 'number' | 'boolean' | 'array' | 'object')[]; if (types.length == 0) { // This means it's not usable on the command line. e.g. an Object. @@ -150,7 +202,6 @@ export async function parseJsonSchemaToOptions( } } - const type = types[0]; const $default = current.$default; const $defaultIndex = json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined; @@ -165,8 +216,8 @@ export async function parseJsonSchemaToOptions( const alias = json.isJsonArray(current.aliases) ? [...current.aliases].map((x) => '' + x) : current.alias - ? ['' + current.alias] - : []; + ? ['' + current.alias] + : []; const format = typeof current.format == 'string' ? current.format : undefined; const visible = current.visible === undefined || current.visible === true; const hidden = !!current.hidden || !visible; @@ -182,7 +233,6 @@ export async function parseJsonSchemaToOptions( const option: Option = { name, description: '' + (current.description === undefined ? '' : current.description), - type, default: defaultValue, choices: enumValues.length ? enumValues : undefined, required, @@ -192,6 +242,14 @@ export async function parseJsonSchemaToOptions( userAnalytics, deprecated, positional, + ...(types[0] === 'object' + ? { + type: 'array', + itemValueType: 'string', + } + : { + type: types[0], + }), }; options.push(option); @@ -211,3 +269,90 @@ export async function parseJsonSchemaToOptions( return a.name.localeCompare(b.name); }); } + +/** + * Adds schema options to a command also this keeps track of options that are required for analytics. + * **Note:** This method should be called from the command bundler method. + * + * @returns A map from option name to analytics configuration. + */ +export function addSchemaOptionsToCommand( + localYargs: Argv, + options: Option[], + includeDefaultValues: boolean, +): Map { + const booleanOptionsWithNoPrefix = new Set(); + const keyValuePairOptions = new Set(); + const optionsWithAnalytics = new Map(); + + for (const option of options) { + const { + default: defaultVal, + positional, + deprecated, + description, + alias, + userAnalytics, + type, + itemValueType, + hidden, + name, + choices, + } = option; + + let dashedName = strings.dasherize(name); + + // Handle options which have been defined in the schema with `no` prefix. + if (type === 'boolean' && dashedName.startsWith('no-')) { + dashedName = dashedName.slice(3); + booleanOptionsWithNoPrefix.add(dashedName); + } + + if (itemValueType) { + keyValuePairOptions.add(name); + } + + const sharedOptions: YargsOptions & PositionalOptions = { + alias, + hidden, + description, + deprecated, + choices, + coerce: itemValueType ? coerceToStringMap.bind(null, dashedName) : undefined, + // This should only be done when `--help` is used otherwise default will override options set in angular.json. + ...(includeDefaultValues ? { default: defaultVal } : {}), + }; + + if (positional === undefined) { + localYargs = localYargs.option(dashedName, { + array: itemValueType ? true : undefined, + type: itemValueType ?? type, + ...sharedOptions, + }); + } else { + localYargs = localYargs.positional(dashedName, { + type: type === 'array' || type === 'count' ? 'string' : type, + ...sharedOptions, + }); + } + + // Record option of analytics. + if (userAnalytics !== undefined) { + optionsWithAnalytics.set(name, userAnalytics); + } + } + + // Handle options which have been defined in the schema with `no` prefix. + if (booleanOptionsWithNoPrefix.size) { + localYargs.middleware((options: Arguments) => { + for (const key of booleanOptionsWithNoPrefix) { + if (key in options) { + options[`no-${key}`] = !options[key]; + delete options[key]; + } + } + }, false); + } + + return optionsWithAnalytics; +} diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts new file mode 100644 index 000000000000..5ec5db644bef --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-schema_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 { json, schema } from '@angular-devkit/core'; +import yargs, { positional } from 'yargs'; + +import { addSchemaOptionsToCommand, parseJsonSchemaToOptions } from './json-schema'; + +const YError = (() => { + try { + const y = yargs().strict().fail(false).exitProcess(false).parse(['--forced-failure']); + } catch (e) { + if (!(e instanceof Error)) { + throw new Error('Unexpected non-Error thrown'); + } + + return e.constructor as typeof Error; + } + throw new Error('Expected parse to fail'); +})(); + +interface ParseFunction { + (argv: string[]): unknown; +} + +function withParseForSchema( + jsonSchema: json.JsonObject, + { + interactive = true, + includeDefaultValues = true, + }: { interactive?: boolean; includeDefaultValues?: boolean } = {}, +): ParseFunction { + let actualParse: ParseFunction = () => { + throw new Error('Called before init'); + }; + const parse: ParseFunction = (args) => { + return actualParse(args); + }; + + beforeEach(async () => { + const registry = new schema.CoreSchemaRegistry(); + const options = await parseJsonSchemaToOptions(registry, jsonSchema, interactive); + + actualParse = async (args: string[]) => { + // Create a fresh yargs for each call. The yargs object is stateful and + // calling .parse multiple times on the same instance isn't safe. + const localYargs = yargs().exitProcess(false).strict().fail(false); + addSchemaOptionsToCommand(localYargs, options, includeDefaultValues); + + // Yargs only exposes the parse errors as proper errors when using the + // callback syntax. This unwraps that ugly workaround so tests can just + // use simple .toThrow/.toEqual assertions. + return localYargs.parseAsync(args); + }; + }); + + return parse; +} + +describe('parseJsonSchemaToOptions', () => { + describe('without required fields in schema', () => { + const parse = withParseForSchema({ + 'type': 'object', + 'properties': { + 'maxSize': { + 'type': 'number', + }, + 'ssr': { + 'type': 'string', + 'enum': ['always', 'surprise-me', 'never'], + }, + 'extendable': { + 'type': 'object', + 'properties': {}, + 'additionalProperties': { + 'type': 'string', + }, + }, + 'someDefine': { + 'type': 'object', + 'additionalProperties': { + 'type': 'string', + }, + }, + }, + }); + + describe('type=number', () => { + it('parses valid option value', async () => { + expect(await parse(['--max-size', '42'])).toEqual( + jasmine.objectContaining({ + 'maxSize': 42, + }), + ); + }); + }); + + describe('type=string, enum', () => { + it('parses valid option value', async () => { + expect(await parse(['--ssr', 'never'])).toEqual( + jasmine.objectContaining({ + 'ssr': 'never', + }), + ); + }); + + it('rejects non-enum values', async () => { + await expectAsync(parse(['--ssr', 'yes'])).toBeRejectedWithError( + /Argument: ssr, Given: "yes", Choices:/, + ); + }); + }); + + describe('type=object', () => { + it('ignores fields that define specific properties', async () => { + await expectAsync(parse(['--extendable', 'a=b'])).toBeRejectedWithError( + /Unknown argument: extendable/, + ); + }); + + it('rejects invalid values for string maps', async () => { + await expectAsync(parse(['--some-define', 'foo'])).toBeRejectedWithError( + YError, + /Invalid value for argument: some-define, Given: 'foo', Expected key=value pair/, + ); + await expectAsync(parse(['--some-define', '42'])).toBeRejectedWithError( + YError, + /Invalid value for argument: some-define, Given: '42', Expected key=value pair/, + ); + }); + + it('aggregates an object value', async () => { + expect( + await parse([ + '--some-define', + 'A_BOOLEAN=true', + '--some-define', + 'AN_INTEGER=42', + // Ensure we can handle '=' inside of string values. + '--some-define=A_STRING="❤️=❤️"', + '--some-define', + 'AN_UNQUOTED_STRING=❤️=❤️', + ]), + ).toEqual( + jasmine.objectContaining({ + 'someDefine': { + 'A_BOOLEAN': 'true', + 'AN_INTEGER': '42', + 'A_STRING': '"❤️=❤️"', + 'AN_UNQUOTED_STRING': '❤️=❤️', + }, + }), + ); + }); + }); + }); + + describe('with required positional argument', () => { + it('marks the required argument as required', async () => { + const jsonSchema = JSON.parse(` + { + "$id": "FakeSchema", + "title": "Fake Schema", + "type": "object", + "required": ["a"], + "properties": { + "b": { + "type": "string", + "description": "b.", + "$default": { + "$source": "argv", + "index": 1 + } + }, + "a": { + "type": "string", + "description": "a.", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "optC": { + "type": "string", + "description": "optC" + }, + "optA": { + "type": "string", + "description": "optA" + }, + "optB": { + "type": "string", + "description": "optB" + } + } + }`) as json.JsonObject; + const registry = new schema.CoreSchemaRegistry(); + const options = await parseJsonSchemaToOptions(registry, jsonSchema, /* interactive= */ true); + + expect(options.find((opt) => opt.name === 'a')).toEqual( + jasmine.objectContaining({ + name: 'a', + positional: 0, + required: true, + }), + ); + expect(options.find((opt) => opt.name === 'b')).toEqual( + jasmine.objectContaining({ + name: 'b', + positional: 1, + required: false, + }), + ); + }); + }); +}); diff --git a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts index c19d1c8d3038..792f09f7a97b 100644 --- a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts +++ b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import * as yargs from 'yargs'; +import type { Arguments, Argv } from 'yargs'; /** * A Yargs middleware that normalizes non Array options when the argument has been provided multiple times. @@ -17,21 +17,23 @@ import * as yargs from 'yargs'; * * See: https://github.com/yargs/yargs-parser/pull/163#issuecomment-516566614 */ -export function normalizeOptionsMiddleware(args: yargs.Arguments): void { - // `getOptions` is not included in the types even though it's public API. - // https://github.com/yargs/yargs/issues/2098 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { array } = (yargs as any).getOptions(); - const arrayOptions = new Set(array); +export function createNormalizeOptionsMiddleware(localeYargs: Argv): (args: Arguments) => void { + return (args: Arguments) => { + // `getOptions` is not included in the types even though it's public API. + // https://github.com/yargs/yargs/issues/2098 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { array } = (localeYargs as any).getOptions(); + const arrayOptions = new Set(array); - for (const [key, value] of Object.entries(args)) { - if (key !== '_' && Array.isArray(value) && !arrayOptions.has(key)) { - const newValue = value.pop(); - // eslint-disable-next-line no-console - console.warn( - `Option '${key}' has been specified multiple times. The value '${newValue}' will be used.`, - ); - args[key] = newValue; + for (const [key, value] of Object.entries(args)) { + if (key !== '_' && Array.isArray(value) && !arrayOptions.has(key)) { + const newValue = value.pop(); + // eslint-disable-next-line no-console + console.warn( + `Option '${key}' has been specified multiple times. The value '${newValue}' will be used.`, + ); + args[key] = newValue; + } } - } + }; } diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts index 1be2e0a9aee1..e4b805f1a367 100644 --- a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts +++ b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts @@ -3,17 +3,16 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { RuleFactory, SchematicsException, Tree } from '@angular-devkit/schematics'; import { FileSystemCollectionDesc, NodeModulesEngineHost } from '@angular-devkit/schematics/tools'; -import { readFileSync } from 'fs'; import { parse as parseJson } from 'jsonc-parser'; -import { createRequire } from 'module'; -import { dirname, resolve } from 'path'; -import { TextEncoder } from 'util'; -import { Script } from 'vm'; +import { readFileSync } from 'node:fs'; +import { Module, createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { Script } from 'node:vm'; import { assertIsError } from '../../utilities/error'; /** @@ -204,38 +203,24 @@ function wrap( // Setup a wrapper function to capture the module's exports const schematicCode = readFileSync(schematicFile, 'utf8'); - // `module` is required due to @angular/localize ng-add being in UMD format - const headerCode = '(function() {\nvar exports = {};\nvar module = { exports };\n'; - const footerCode = exportName - ? `\nreturn module.exports['${exportName}'];});` - : '\nreturn module.exports;});'; - - const script = new Script(headerCode + schematicCode + footerCode, { + const script = new Script(Module.wrap(schematicCode), { filename: schematicFile, - lineOffset: 3, + lineOffset: 1, }); - - const context = { - __dirname: schematicDirectory, - __filename: schematicFile, - Buffer, - // TextEncoder is used by the compiler to generate i18n message IDs. See: - // https://github.com/angular/angular/blob/main/packages/compiler/src/i18n/digest.ts#L17 - // It is referenced globally, because it may be run either on the browser or the server. - // Usually Node exposes it globally, but in order for it to work, our custom context - // has to expose it too. Issue context: https://github.com/angular/angular/issues/48940. - TextEncoder, - console, - process, - get global() { - return this; - }, - require: customRequire, + const schematicModule = new Module(schematicFile); + const moduleFactory = script.runInThisContext(); + + return () => { + moduleFactory( + schematicModule.exports, + customRequire, + schematicModule, + schematicFile, + schematicDirectory, + ); + + return exportName ? schematicModule.exports[exportName] : schematicModule.exports; }; - - const exportsFactory = script.runInNewContext(context); - - return exportsFactory; } function loadBuiltinModule(id: string): unknown { diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts index 0b056ed64436..f5caa0754d88 100644 --- a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts +++ b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts @@ -3,13 +3,17 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { logging, tags } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; import { NodeWorkflow } from '@angular-devkit/schematics/tools'; import { colors } from '../../utilities/color'; +function removeLeadingSlash(value: string): string { + return value[0] === '/' ? value.slice(1) : value; +} + export function subscribeToWorkflow( workflow: NodeWorkflow, logger: logging.LoggerApi, @@ -24,24 +28,21 @@ export function subscribeToWorkflow( const reporterSubscription = workflow.reporter.subscribe((event) => { // Strip leading slash to prevent confusion. - const eventPath = event.path.charAt(0) === '/' ? event.path.substring(1) : event.path; + const eventPath = removeLeadingSlash(event.path); switch (event.kind) { case 'error': error = true; - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist'; - logger.error(`ERROR! ${eventPath} ${desc}.`); + logger.error( + `ERROR! ${eventPath} ${event.description == 'alreadyExist' ? 'already exists' : 'does not exist'}.`, + ); break; case 'update': - logs.push(tags.oneLine` - ${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes) - `); + logs.push(`${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)`); files.add(eventPath); break; case 'create': - logs.push(tags.oneLine` - ${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes) - `); + logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`); files.add(eventPath); break; case 'delete': @@ -49,8 +50,7 @@ export function subscribeToWorkflow( files.add(eventPath); break; case 'rename': - const eventToPath = event.to.charAt(0) === '/' ? event.to.substring(1) : event.to; - logs.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); + logs.push(`${colors.blue('RENAME')} ${eventPath} => ${removeLeadingSlash(event.to)}`); files.add(eventPath); break; } diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 4c964a422969..adaf980cd4b3 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -3,14 +3,15 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { tags } from '@angular-devkit/core'; import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools'; -import { createRequire } from 'module'; +import { Listr, color, figures } from 'listr2'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; import npa from 'npm-package-arg'; -import { dirname, join } from 'path'; import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; import { Argv } from 'yargs'; import { PackageManager } from '../../../lib/config/workspace-schema'; @@ -23,7 +24,6 @@ import { SchematicsCommandArgs, SchematicsCommandModule, } from '../../command-builder/schematics-command-module'; -import { colors } from '../../utilities/color'; import { assertIsError } from '../../utilities/error'; import { NgAddSaveDependency, @@ -31,11 +31,11 @@ import { fetchPackageManifest, fetchPackageMetadata, } from '../../utilities/package-metadata'; -import { askConfirmation } from '../../utilities/prompt'; -import { Spinner } from '../../utilities/spinner'; import { isTTY } from '../../utilities/tty'; import { VERSION } from '../../utilities/version'; +class CommandError extends Error {} + interface AddCommandArgs extends SchematicsCommandArgs { collection: string; verbose?: boolean; @@ -43,6 +43,15 @@ interface AddCommandArgs extends SchematicsCommandArgs { 'skip-confirmation'?: boolean; } +interface AddCommandTaskContext { + packageIdentifier: npa.Result; + usingYarn?: boolean; + savePackage?: NgAddSaveDependency; + collectionName?: string; + executeSchematic: AddCommandModule['executeSchematic']; + hasMismatchedPeer: AddCommandModule['hasMismatchedPeer']; +} + /** * The set of packages that should have certain versions excluded from consideration * when attempting to find a compatible version for a package. @@ -55,7 +64,7 @@ const packageVersionExclusions: Record = { '@angular/material': '7.x', }; -export default class AddCommadModule +export default class AddCommandModule extends SchematicsCommandModule implements CommandModuleImplementation { @@ -91,8 +100,12 @@ export default class AddCommadModule // `ng add @angular/localize -- --package-options`. .strict(false); - const collectionName = await this.getCollectionName(); - const workflow = await this.getOrCreateWorkflowForBuilder(collectionName); + const collectionName = this.getCollectionName(); + if (!collectionName) { + return localYargs; + } + + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); try { const collection = workflow.engine.createCollection(collectionName); @@ -102,7 +115,7 @@ export default class AddCommadModule } catch (error) { // During `ng add` prior to the downloading of the package // we are not able to resolve and create a collection. - // Or when the the collection value is a path to a tarball. + // Or when the collection value is a path to a tarball. } return localYargs; @@ -112,7 +125,6 @@ export default class AddCommadModule async run(options: Options & OtherOptions): Promise { const { logger, packageManager } = this.context; const { verbose, registry, collection, skipConfirmation } = options; - packageManager.ensureCompatibility(); let packageIdentifier; try { @@ -138,181 +150,227 @@ export default class AddCommadModule } } - const spinner = new Spinner(); - - spinner.start('Determining package manager...'); - const usingYarn = packageManager.name === PackageManager.Yarn; - spinner.info(`Using package manager: ${colors.grey(packageManager.name)}`); - - if ( - packageIdentifier.name && - packageIdentifier.type === 'range' && - packageIdentifier.rawSpec === '*' - ) { - // only package name provided; search for viable version - // plus special cases for packages that did not have peer deps setup - spinner.start('Searching for compatible package version...'); - - let packageMetadata; - try { - packageMetadata = await fetchPackageMetadata(packageIdentifier.name, logger, { - registry, - usingYarn, - verbose, - }); - } catch (e) { - assertIsError(e); - spinner.fail(`Unable to load package information from registry: ${e.message}`); + const taskContext: AddCommandTaskContext = { + packageIdentifier, + executeSchematic: this.executeSchematic.bind(this), + hasMismatchedPeer: this.hasMismatchedPeer.bind(this), + }; + + const tasks = new Listr([ + { + title: 'Determining Package Manager', + task(context, task) { + context.usingYarn = packageManager.name === PackageManager.Yarn; + task.output = `Using package manager: ${color.dim(packageManager.name)}`; + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Searching for compatible package version', + enabled: packageIdentifier.type === 'range' && packageIdentifier.rawSpec === '*', + async task(context, task) { + assert( + context.packageIdentifier.name, + 'Registry package identifiers should always have a name.', + ); - return 1; - } + // only package name provided; search for viable version + // plus special cases for packages that did not have peer deps setup + let packageMetadata; + try { + packageMetadata = await fetchPackageMetadata(context.packageIdentifier.name, logger, { + registry, + usingYarn: context.usingYarn, + verbose, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to load package information from registry: ${e.message}`, + ); + } - // Start with the version tagged as `latest` if it exists - const latestManifest = packageMetadata.tags['latest']; - if (latestManifest) { - packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); - } + // Start with the version tagged as `latest` if it exists + const latestManifest = packageMetadata.tags['latest']; + if (latestManifest) { + context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); + } - // Adjust the version based on name and peer dependencies - if ( - latestManifest?.peerDependencies && - Object.keys(latestManifest.peerDependencies).length === 0 - ) { - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) { - // 'latest' is invalid so search for most recent matching package - - // Allow prelease versions if the CLI itself is a prerelease - const allowPrereleases = prerelease(VERSION.full); - - const versionExclusions = packageVersionExclusions[packageMetadata.name]; - const versionManifests = Object.values(packageMetadata.versions).filter( - (value: PackageManifest) => { - // Prerelease versions are not stable and should not be considered by default - if (!allowPrereleases && prerelease(value.version)) { - return false; - } - // Deprecated versions should not be used or considered - if (value.deprecated) { - return false; - } - // Excluded package versions should not be considered - if ( - versionExclusions && - satisfies(value.version, versionExclusions, { includePrerelease: true }) - ) { - return false; + // Adjust the version based on name and peer dependencies + if ( + latestManifest?.peerDependencies && + Object.keys(latestManifest.peerDependencies).length === 0 + ) { + task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`; + } else if (!latestManifest || (await context.hasMismatchedPeer(latestManifest))) { + // 'latest' is invalid so search for most recent matching package + + // Allow prelease versions if the CLI itself is a prerelease + const allowPrereleases = prerelease(VERSION.full); + + const versionExclusions = packageVersionExclusions[packageMetadata.name]; + const versionManifests = Object.values(packageMetadata.versions).filter( + (value: PackageManifest) => { + // Prerelease versions are not stable and should not be considered by default + if (!allowPrereleases && prerelease(value.version)) { + return false; + } + // Deprecated versions should not be used or considered + if (value.deprecated) { + return false; + } + // Excluded package versions should not be considered + if ( + versionExclusions && + satisfies(value.version, versionExclusions, { includePrerelease: true }) + ) { + return false; + } + + return true; + }, + ); + + // Sort in reverse SemVer order so that the newest compatible version is chosen + versionManifests.sort((a, b) => compare(b.version, a.version, true)); + + let found = false; + for (const versionManifest of versionManifests) { + const mismatch = await context.hasMismatchedPeer(versionManifest); + if (mismatch) { + continue; + } + + context.packageIdentifier = npa.resolve( + versionManifest.name, + versionManifest.version, + ); + found = true; + break; } - return true; - }, - ); + if (!found) { + task.output = "Unable to find compatible package. Using 'latest' tag."; + } else { + task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`; + } + } else { + task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`; + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Loading package information from registry', + async task(context, task) { + let manifest; + try { + manifest = await fetchPackageManifest(context.packageIdentifier.toString(), logger, { + registry, + verbose, + usingYarn: context.usingYarn, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to fetch package information for '${context.packageIdentifier}': ${e.message}`, + ); + } - // Sort in reverse SemVer order so that the newest compatible version is chosen - versionManifests.sort((a, b) => compare(b.version, a.version, true)); + context.savePackage = manifest['ng-add']?.save; + context.collectionName = manifest.name; - let newIdentifier; - for (const versionManifest of versionManifests) { - if (!(await this.hasMismatchedPeer(versionManifest))) { - newIdentifier = npa.resolve(versionManifest.name, versionManifest.version); - break; + if (await context.hasMismatchedPeer(manifest)) { + task.output = color.yellow( + figures.warning + + ' Package has unmet peer dependencies. Adding the package may not succeed.', + ); + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Confirming installation', + enabled: !skipConfirmation, + async task(context, task) { + if (!isTTY()) { + task.output = + `'--skip-confirmation' can be used to bypass installation confirmation. ` + + `Ensure package name is correct prior to '--skip-confirmation' option usage.`; + throw new CommandError('No terminal detected'); } - } - if (!newIdentifier) { - spinner.warn("Unable to find compatible package. Using 'latest' tag."); - } else { - packageIdentifier = newIdentifier; - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } - } else { - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } - } + const { ListrInquirerPromptAdapter } = await import('@listr2/prompt-adapter-inquirer'); + const { confirm } = await import('@inquirer/prompts'); + const shouldProceed = await task.prompt(ListrInquirerPromptAdapter).run(confirm, { + message: + `The package ${color.blue(context.packageIdentifier.toString())} will be installed and executed.\n` + + 'Would you like to proceed?', + default: true, + theme: { prefix: '' }, + }); + + if (!shouldProceed) { + throw new CommandError('Command aborted'); + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + async task(context, task) { + // Only show if installation will actually occur + task.title = 'Installing package'; + + if (context.savePackage === false) { + task.title += ' in temporary location'; + + // Temporary packages are located in a different directory + // Hence we need to resolve them using the temp path + const { success, tempNodeModules } = await packageManager.installTemp( + context.packageIdentifier.toString(), + registry ? [`--registry="${registry}"`] : undefined, + ); + const tempRequire = createRequire(tempNodeModules + '/'); + assert(context.collectionName, 'Collection name should always be available'); + const resolvedCollectionPath = tempRequire.resolve( + join(context.collectionName, 'package.json'), + ); + + if (!success) { + throw new CommandError('Unable to install package'); + } - let collectionName = packageIdentifier.name; - let savePackage: NgAddSaveDependency | undefined; + context.collectionName = dirname(resolvedCollectionPath); + } else { + const success = await packageManager.install( + context.packageIdentifier.toString(), + context.savePackage, + registry ? [`--registry="${registry}"`] : undefined, + undefined, + ); + + if (!success) { + throw new CommandError('Unable to install package'); + } + } + }, + rendererOptions: { bottomBar: Infinity }, + }, + // TODO: Rework schematic execution as a task and insert here + ]); try { - spinner.start('Loading package information from registry...'); - const manifest = await fetchPackageManifest(packageIdentifier.toString(), logger, { - registry, - verbose, - usingYarn, - }); + const result = await tasks.run(taskContext); + assert(result.collectionName, 'Collection name should always be available'); - savePackage = manifest['ng-add']?.save; - collectionName = manifest.name; - - if (await this.hasMismatchedPeer(manifest)) { - spinner.warn('Package has unmet peer dependencies. Adding the package may not succeed.'); - } else { - spinner.succeed(`Package information loaded.`); - } + return this.executeSchematic({ ...options, collection: result.collectionName }); } catch (e) { - assertIsError(e); - spinner.fail(`Unable to fetch package information for '${packageIdentifier}': ${e.message}`); - - return 1; - } - - if (!skipConfirmation) { - const confirmationResponse = await askConfirmation( - `\nThe package ${colors.blue(packageIdentifier.raw)} will be installed and executed.\n` + - 'Would you like to proceed?', - true, - false, - ); - - if (!confirmationResponse) { - if (!isTTY()) { - logger.error( - 'No terminal detected. ' + - `'--skip-confirmation' can be used to bypass installation confirmation. ` + - `Ensure package name is correct prior to '--skip-confirmation' option usage.`, - ); - } - - logger.error('Command aborted.'); - - return 1; - } - } - - if (savePackage === false) { - // Temporary packages are located in a different directory - // Hence we need to resolve them using the temp path - const { success, tempNodeModules } = await packageManager.installTemp( - packageIdentifier.raw, - registry ? [`--registry="${registry}"`] : undefined, - ); - const tempRequire = createRequire(tempNodeModules + '/'); - const resolvedCollectionPath = tempRequire.resolve(join(collectionName, 'package.json')); - - if (!success) { + if (e instanceof CommandError) { return 1; } - collectionName = dirname(resolvedCollectionPath); - } else { - const success = await packageManager.install( - packageIdentifier.raw, - savePackage, - registry ? [`--registry="${registry}"`] : undefined, - ); - - if (!success) { - return 1; - } + throw e; } - - return this.executeSchematic({ ...options, collection: collectionName }); } private async isProjectVersionValid(packageIdentifier: npa.Result): Promise { @@ -347,8 +405,23 @@ export default class AddCommadModule return false; } - private async getCollectionName(): Promise { + private getCollectionName(): string | undefined { const [, collectionName] = this.context.args.positional; + if (!collectionName) { + return undefined; + } + + // The CLI argument may specify also a version, like `ng add @my/lib@13.0.0`, + // but here we need only the name of the package, like `@my/lib`. + try { + const packageName = npa(collectionName).name; + if (packageName) { + return packageName; + } + } catch (e) { + assertIsError(e); + this.context.logger.error(e.message); + } return collectionName; } @@ -398,10 +471,10 @@ export default class AddCommadModule }); } catch (e) { if (e instanceof NodePackageDoesNotSupportSchematics) { - this.context.logger.error(tags.oneLine` - The package that you are trying to add does not support schematics. You can try using - a different version of the package or contact the package author to add ng-add support. - `); + this.context.logger.error( + 'The package that you are trying to add does not support schematics.' + + 'You can try using a different version of the package or contact the package author to add ng-add support.', + ); return 1; } diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts index 8e3753ababb1..da56a2a00460 100644 --- a/packages/angular/cli/src/commands/analytics/cli.ts +++ b/packages/angular/cli/src/commands/analytics/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'node:path'; @@ -41,7 +41,7 @@ export default class AnalyticsCommandModule ].sort(); // sort by class name. for (const module of subcommands) { - localYargs = addCommandModuleToYargs(localYargs, module, this.context); + addCommandModuleToYargs(module, this.context); } return localYargs.demandCommand(1, demandCommandFailureMessage).strict(); diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts index bfcba4a3da0e..e4434d35baee 100644 --- a/packages/angular/cli/src/commands/analytics/info/cli.ts +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts index ff965e228781..16f07b353d1a 100644 --- a/packages/angular/cli/src/commands/analytics/settings/cli.ts +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts index 196585a4b122..365420ca3734 100644 --- a/packages/angular/cli/src/commands/build/cli.ts +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'path'; +import { join } from 'node:path'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; import { RootCommands } from '../command-config'; diff --git a/packages/angular/cli/src/commands/build/long-description.md b/packages/angular/cli/src/commands/build/long-description.md index 57bf9a16edd4..b2c14d8f23fe 100644 --- a/packages/angular/cli/src/commands/build/long-description.md +++ b/packages/angular/cli/src/commands/build/long-description.md @@ -1,13 +1,13 @@ The command can be used to build a project of type "application" or "library". -When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, and `watch` options are applied. +When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, `poll` and `watch` options are applied. All other options apply only to building applications. -The application builder uses the [webpack](https://webpack.js.org/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. +The application builder uses the [esbuild](https://esbuild.github.io/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. A "development" configuration is created by default when you use the CLI to create the project, and you can use that configuration by specifying the `--configuration development`. The configuration options generally correspond to the command options. You can override individual configuration defaults by specifying the corresponding options on the command line. -The command can accept option names given in either dash-case or camelCase. +The command can accept option names given in dash-case. Note that in the configuration file, you must specify names in camelCase. Some additional options can only be set through the configuration file, @@ -15,4 +15,4 @@ either by direct editing or with the `ng config` command. These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project. Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder. -For further details, see [Workspace Configuration](guide/workspace-config). +For further details, see [Workspace Configuration](reference/configs/workspace-config). diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts index f07cd5613c96..a115b686b7e0 100644 --- a/packages/angular/cli/src/commands/cache/clean/cli.ts +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { promises as fs } from 'fs'; +import { rm } from 'node:fs/promises'; import { Argv } from 'yargs'; import { CommandModule, @@ -28,7 +28,7 @@ export class CacheCleanModule extends CommandModule implements CommandModuleImpl run(): Promise { const { path } = getCacheConfig(this.context.workspace); - return fs.rm(path, { + return rm(path, { force: true, recursive: true, maxRetries: 3, diff --git a/packages/angular/cli/src/commands/cache/cli.ts b/packages/angular/cli/src/commands/cache/cli.ts index bc4115d8cfde..dad144b034b3 100644 --- a/packages/angular/cli/src/commands/cache/cli.ts +++ b/packages/angular/cli/src/commands/cache/cli.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'path'; +import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, @@ -40,7 +40,7 @@ export default class CacheCommandModule ].sort(); for (const module of subcommands) { - localYargs = addCommandModuleToYargs(localYargs, module, this.context); + addCommandModuleToYargs(module, this.context); } return localYargs.demandCommand(1, demandCommandFailureMessage).strict(); diff --git a/packages/angular/cli/src/commands/cache/info/cli.ts b/packages/angular/cli/src/commands/cache/info/cli.ts index 15fcf3ba857f..447d92e02c1f 100644 --- a/packages/angular/cli/src/commands/cache/info/cli.ts +++ b/packages/angular/cli/src/commands/cache/info/cli.ts @@ -3,12 +3,12 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { tags } from '@angular-devkit/core'; -import { promises as fs } from 'fs'; -import { join } from 'path'; +import * as fs from 'node:fs/promises'; +import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, diff --git a/packages/angular/cli/src/commands/cache/long-description.md b/packages/angular/cli/src/commands/cache/long-description.md index 8da4bb9e5364..3ebfec598c4e 100644 --- a/packages/angular/cli/src/commands/cache/long-description.md +++ b/packages/angular/cli/src/commands/cache/long-description.md @@ -2,7 +2,7 @@ Angular CLI saves a number of cachable operations on disk by default. When you re-run the same build, the build system restores the state of the previous build and re-uses previously performed operations, which decreases the time taken to build and test your applications and libraries. -To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](guide/workspace-config). +To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](reference/configs/workspace-config). The object goes under `cli.cache` at the top level of the file, outside the `projects` sections. ```jsonc @@ -12,13 +12,13 @@ The object goes under `cli.cache` at the top level of the file, outside the `pro "cli": { "cache": { // ... - } + }, }, - "projects": {} + "projects": {}, } ``` -For more information, see [cache options](guide/workspace-config#cache-options). +For more information, see [cache options](reference/configs/workspace-config#cache-options). ### Cache environments @@ -34,7 +34,7 @@ To change the environment setting to `all`, run the following command: ng config cli.cache.environment all ``` -For more information, see `environment` in [cache options](guide/workspace-config#cache-options). +For more information, see `environment` in [cache options](reference/configs/workspace-config#cache-options).
diff --git a/packages/angular/cli/src/commands/cache/settings/cli.ts b/packages/angular/cli/src/commands/cache/settings/cli.ts index 97e79cd1005b..9a4f654f7ac7 100644 --- a/packages/angular/cli/src/commands/cache/settings/cli.ts +++ b/packages/angular/cli/src/commands/cache/settings/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts index c9783e02f942..84e22314763a 100644 --- a/packages/angular/cli/src/commands/cache/utilities.ts +++ b/packages/angular/cli/src/commands/cache/utilities.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { isJsonObject } from '@angular-devkit/core'; -import { resolve } from 'path'; +import { resolve } from 'node:path'; import { Cache, Environment } from '../../../lib/config/workspace-schema'; import { AngularWorkspace } from '../../utilities/config'; diff --git a/packages/angular/cli/src/commands/command-config.ts b/packages/angular/cli/src/commands/command-config.ts index 6bb4fc7d2679..a74d81f5e911 100644 --- a/packages/angular/cli/src/commands/command-config.ts +++ b/packages/angular/cli/src/commands/command-config.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { CommandModuleConstructor } from '../command-builder/utilities/command'; @@ -16,12 +16,12 @@ export type CommandNames = | 'completion' | 'config' | 'deploy' - | 'doc' | 'e2e' | 'extract-i18n' | 'generate' | 'lint' | 'make-this-awesome' + | 'mcp' | 'new' | 'run' | 'serve' @@ -60,10 +60,7 @@ export const RootCommands: Record< 'deploy': { factory: () => import('./deploy/cli'), }, - 'doc': { - factory: () => import('./doc/cli'), - aliases: ['d'], - }, + 'e2e': { factory: () => import('./e2e/cli'), aliases: ['e'], @@ -81,6 +78,9 @@ export const RootCommands: Record< 'make-this-awesome': { factory: () => import('./make-this-awesome/cli'), }, + 'mcp': { + factory: () => import('./mcp/cli'), + }, 'new': { factory: () => import('./new/cli'), aliases: ['n'], @@ -90,7 +90,7 @@ export const RootCommands: Record< }, 'serve': { factory: () => import('./serve/cli'), - aliases: ['s'], + aliases: ['dev', 's'], }, 'test': { factory: () => import('./test/cli'), @@ -105,10 +105,13 @@ export const RootCommands: Record< }, }; -export const RootCommandsAliases = Object.values(RootCommands).reduce((prev, current) => { - current.aliases?.forEach((alias) => { - prev[alias] = current; - }); +export const RootCommandsAliases = Object.values(RootCommands).reduce( + (prev, current) => { + current.aliases?.forEach((alias) => { + prev[alias] = current; + }); - return prev; -}, {} as Record); + return prev; + }, + {} as Record, +); diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts index 8c777a9b8d32..3fc9dccdc703 100644 --- a/packages/angular/cli/src/commands/completion/cli.ts +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'path'; -import yargs, { Argv } from 'yargs'; +import { join } from 'node:path'; +import { Argv } from 'yargs'; import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; import { addCommandModuleToYargs } from '../../command-builder/utilities/command'; import { colors } from '../../utilities/color'; @@ -23,7 +23,9 @@ export default class CompletionCommandModule longDescriptionPath = join(__dirname, 'long-description.md'); builder(localYargs: Argv): Argv { - return addCommandModuleToYargs(localYargs, CompletionScriptCommandModule, this.context); + addCommandModuleToYargs(CompletionScriptCommandModule, this.context); + + return localYargs; } async run(): Promise { @@ -51,7 +53,7 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + '\n\n' + - 'For more information, see https://angular.io/cli/completion#global-install', + 'For more information, see https://angular.dev/cli/completion#global-install', ); } @@ -69,6 +71,6 @@ class CompletionScriptCommandModule extends CommandModule implements CommandModu } run(): void { - yargs.showCompletionScript(); + this.context.yargsInstance.showCompletionScript(); } } diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md index 26569cff5097..b75803ac9cb0 100644 --- a/packages/angular/cli/src/commands/completion/long-description.md +++ b/packages/angular/cli/src/commands/completion/long-description.md @@ -4,7 +4,7 @@ discover and use CLI commands without lots of memorization. ![A demo of Angular CLI autocompletion in a terminal. The user types several partial `ng` commands, using autocompletion to finish several arguments and list contextual options. -](generated/images/guide/cli/completion.gif) +](assets/images/guide/cli/completion.gif) ## Automated setup diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts index bb5cee4e66fd..06b253b9a42d 100644 --- a/packages/angular/cli/src/commands/config/cli.ts +++ b/packages/angular/cli/src/commands/config/cli.ts @@ -3,12 +3,12 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { JsonValue } from '@angular-devkit/core'; -import { randomUUID } from 'crypto'; -import { join } from 'path'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, @@ -185,7 +185,7 @@ function normalizeValue(value: string | undefined | boolean | number): JsonValue // and convert them into a numberic entities. // Example: 73b61974-182c-48e4-b4c6-30ddf08c5c98 -> 73. // These values should never contain comments, therefore using `JSON.parse` is safe. - return JSON.parse(valueString); + return JSON.parse(valueString) as JsonValue; } catch { return value; } diff --git a/packages/angular/cli/src/commands/config/long-description.md b/packages/angular/cli/src/commands/config/long-description.md index 94ebfca237eb..db32cb294152 100644 --- a/packages/angular/cli/src/commands/config/long-description.md +++ b/packages/angular/cli/src/commands/config/long-description.md @@ -8,6 +8,6 @@ The configurable property names match command option names, except that in the configuration file, all names must use camelCase, while on the command line options can be given dash-case. -For further details, see [Workspace Configuration](guide/workspace-config). +For further details, see [Workspace Configuration](reference/configs/workspace-config). For configuration of CLI usage analytics, see [ng analytics](cli/analytics). diff --git a/packages/angular/cli/src/commands/deploy/cli.ts b/packages/angular/cli/src/commands/deploy/cli.ts index a4930680fc5e..947dc90af2d4 100644 --- a/packages/angular/cli/src/commands/deploy/cli.ts +++ b/packages/angular/cli/src/commands/deploy/cli.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'path'; +import { join } from 'node:path'; import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; @@ -15,7 +15,7 @@ export default class DeployCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { - // The below choices should be kept in sync with the list in https://angular.io/guide/deployment + // The below choices should be kept in sync with the list in https://angular.dev/tools/cli/deployment override missingTargetChoices: MissingTargetChoice[] = [ { name: 'Amazon S3', @@ -29,10 +29,6 @@ export default class DeployCommandModule name: 'Netlify', value: '@netlify-builder/deploy', }, - { - name: 'NPM', - value: 'ngx-deploy-npm', - }, { name: 'GitHub Pages', value: 'angular-cli-ghpages', diff --git a/packages/angular/cli/src/commands/deploy/long-description.md b/packages/angular/cli/src/commands/deploy/long-description.md index 9d13ad2a9890..0436390680a4 100644 --- a/packages/angular/cli/src/commands/deploy/long-description.md +++ b/packages/angular/cli/src/commands/deploy/long-description.md @@ -3,7 +3,7 @@ When a project name is not supplied, executes the `deploy` builder for the defau To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform. Adding the package automatically updates your workspace configuration, adding a deployment -[CLI builder](guide/cli-builder). +[CLI builder](tools/cli/cli-builder). For example: ```json diff --git a/packages/angular/cli/src/commands/doc/cli.ts b/packages/angular/cli/src/commands/doc/cli.ts deleted file mode 100644 index d6f9d571248a..000000000000 --- a/packages/angular/cli/src/commands/doc/cli.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights 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 open from 'open'; -import { Argv } from 'yargs'; -import { - CommandModule, - CommandModuleImplementation, - Options, -} from '../../command-builder/command-module'; -import { RootCommands } from '../command-config'; - -interface DocCommandArgs { - keyword: string; - search?: boolean; - version?: string; -} - -export default class DocCommandModule - extends CommandModule - implements CommandModuleImplementation -{ - command = 'doc '; - aliases = RootCommands['doc'].aliases; - describe = - 'Opens the official Angular documentation (angular.io) in a browser, and searches for a given keyword.'; - longDescriptionPath?: string; - - builder(localYargs: Argv): Argv { - return localYargs - .positional('keyword', { - description: 'The keyword to search for, as provided in the search bar in angular.io.', - type: 'string', - demandOption: true, - }) - .option('search', { - description: `Search all of angular.io. Otherwise, searches only API reference documentation.`, - alias: ['s'], - type: 'boolean', - default: false, - }) - .option('version', { - description: - 'The version of Angular to use for the documentation. ' + - 'If not provided, the command uses your current Angular core version.', - type: 'string', - }) - .strict(); - } - - async run(options: Options): Promise { - let domain = 'angular.io'; - - if (options.version) { - // version can either be a string containing "next" - if (options.version === 'next') { - domain = 'next.angular.io'; - } else if (options.version === 'rc') { - domain = 'rc.angular.io'; - // or a number where version must be a valid Angular version (i.e. not 0, 1 or 3) - } else if (!isNaN(+options.version) && ![0, 1, 3].includes(+options.version)) { - domain = `v${options.version}.angular.io`; - } else { - this.context.logger.error( - 'Version should either be a number (2, 4, 5, 6...), "rc" or "next"', - ); - - return 1; - } - } else { - // we try to get the current Angular version of the project - // and use it if we can find it - try { - /* eslint-disable-next-line import/no-extraneous-dependencies */ - const currentNgVersion = (await import('@angular/core')).VERSION.major; - domain = `v${currentNgVersion}.angular.io`; - } catch {} - } - - await open( - options.search - ? `https://${domain}/docs?search=${options.keyword}` - : `https://${domain}/api?query=${options.keyword}`, - ); - } -} diff --git a/packages/angular/cli/src/commands/e2e/cli.ts b/packages/angular/cli/src/commands/e2e/cli.ts index 57cc6370618f..85d9aab173a0 100644 --- a/packages/angular/cli/src/commands/e2e/cli.ts +++ b/packages/angular/cli/src/commands/e2e/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; @@ -16,6 +16,10 @@ export default class E2eCommandModule implements CommandModuleImplementation { override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Playwright', + value: 'playwright-ng-schematics', + }, { name: 'Cypress', value: '@cypress/schematic', @@ -28,6 +32,10 @@ export default class E2eCommandModule name: 'WebdriverIO', value: '@wdio/schematics', }, + { + name: 'Puppeteer', + value: '@puppeteer/ng-schematics', + }, ]; multiTarget = true; diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts index a0d4bc366dfb..4f3dea2d8e7e 100644 --- a/packages/angular/cli/src/commands/extract-i18n/cli.ts +++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts @@ -3,9 +3,12 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ +import { workspaces } from '@angular-devkit/core'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; @@ -17,4 +20,38 @@ export default class ExtractI18nCommandModule command = 'extract-i18n [project]'; describe = 'Extracts i18n messages from source code.'; longDescriptionPath?: string | undefined; + + override async findDefaultBuilderName( + project: workspaces.ProjectDefinition, + ): Promise { + // Only application type projects have a default i18n extraction target + if (project.extensions['projectType'] !== 'application') { + return; + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + // No default if there is no build target + return; + } + + // Provide a default based on the defined builder for the 'build' target + switch (buildTarget.builder) { + case '@angular-devkit/build-angular:application': + case '@angular-devkit/build-angular:browser-esbuild': + case '@angular-devkit/build-angular:browser': + return '@angular-devkit/build-angular:extract-i18n'; + case '@angular/build:application': + return '@angular/build:extract-i18n'; + } + + // For other builders, check for `@angular-devkit/build-angular` and use if found. + // This package is safer to use since it supports both application builder types. + try { + const projectRequire = createRequire(join(this.context.root, project.root) + '/'); + projectRequire.resolve('@angular-devkit/build-angular'); + + return '@angular-devkit/build-angular:extract-i18n'; + } catch {} + } } diff --git a/packages/angular/cli/src/commands/generate/cli.ts b/packages/angular/cli/src/commands/generate/cli.ts index 424d609ed19a..4be29c3eaea0 100644 --- a/packages/angular/cli/src/commands/generate/cli.ts +++ b/packages/angular/cli/src/commands/generate/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { strings } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts index d6072d5549e6..9510dd7afe53 100644 --- a/packages/angular/cli/src/commands/lint/cli.ts +++ b/packages/angular/cli/src/commands/lint/cli.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'path'; +import { join } from 'node:path'; import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; @@ -18,7 +18,7 @@ export default class LintCommandModule override missingTargetChoices: MissingTargetChoice[] = [ { name: 'ESLint', - value: '@angular-eslint/schematics', + value: 'angular-eslint', }, ]; diff --git a/packages/angular/cli/src/commands/lint/long-description.md b/packages/angular/cli/src/commands/lint/long-description.md index 1c912b2489d7..5e5fa3da951c 100644 --- a/packages/angular/cli/src/commands/lint/long-description.md +++ b/packages/angular/cli/src/commands/lint/long-description.md @@ -1,7 +1,7 @@ The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. When a project name is not supplied, executes the `lint` builder for all projects. -To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](guide/cli-builder). +To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](tools/cli/cli-builder). For example: ```json diff --git a/packages/angular/cli/src/commands/make-this-awesome/cli.ts b/packages/angular/cli/src/commands/make-this-awesome/cli.ts index 0c258a023f7b..6a17c5614b94 100644 --- a/packages/angular/cli/src/commands/make-this-awesome/cli.ts +++ b/packages/angular/cli/src/commands/make-this-awesome/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts new file mode 100644 index 000000000000..81260f09f6b6 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/cli.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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { isTTY } from '../../utilities/tty'; +import { createMcpServer } from './mcp-server'; + +const INTERACTIVE_MESSAGE = ` +To start using the Angular CLI MCP Server, add this configuration to your host: + +{ + "mcpServers": { + "angular-cli": { + "command": "npx", + "args": ["@angular/cli", "mcp"] + } + } +} + +Exact configuration may differ depending on the host. +`; + +export default class McpCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'mcp'; + describe = false as const; + longDescriptionPath = undefined; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + async run(): Promise { + if (isTTY()) { + this.context.logger.info(INTERACTIVE_MESSAGE); + + return; + } + + const server = await createMcpServer({ workspace: this.context.workspace }); + const transport = new StdioServerTransport(); + await server.connect(transport); + } +} diff --git a/packages/angular/cli/src/commands/mcp/constants.ts b/packages/angular/cli/src/commands/mcp/constants.ts new file mode 100644 index 000000000000..6530bfd34175 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/constants.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 const k1 = '@angular/cli'; +export const at = 'QBHBbOdEO4CmBOC2d7jNmg=='; +export const iv = Buffer.from([ + 0x97, 0xf4, 0x62, 0x95, 0x3e, 0x12, 0x76, 0x84, 0x8a, 0x09, 0x4a, 0xc9, 0xeb, 0xa2, 0x84, 0x69, +]); diff --git a/packages/angular/cli/src/commands/mcp/instructions/best-practices.md b/packages/angular/cli/src/commands/mcp/instructions/best-practices.md new file mode 100644 index 000000000000..2cbe5668fbb9 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/instructions/best-practices.md @@ -0,0 +1,52 @@ +You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices. + +## TypeScript Best Practices + +- Use strict type checking +- Prefer type inference when the type is obvious +- Avoid the `any` type; use `unknown` when type is uncertain + +## Angular Best Practices + +- Always use standalone components over NgModules +- Must NOT set `standalone: true` inside Angular decorators. It's the default. +- Use signals for state management +- Implement lazy loading for feature routes +- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead +- Use `NgOptimizedImage` for all static images. + - `NgOptimizedImage` does not work for inline base64 images. + +## Components + +- Keep components small and focused on a single responsibility +- Use `input()` and `output()` functions instead of decorators +- Use `computed()` for derived state +- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator +- Prefer inline templates for small components +- Prefer Reactive forms instead of Template-driven ones +- Do NOT use `ngClass`, use `class` bindings instead +- DO NOT use `ngStyle`, use `style` bindings instead + +## State Management + +- Use signals for local component state +- Use `computed()` for derived state +- Keep state transformations pure and predictable +- Do NOT use `mutate` on signals, use `update` or `set` instead + +## Templates + +- Keep templates simple and avoid complex logic +- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` +- Use the async pipe to handle observables + +## Services + +- Design services around a single responsibility +- Use the `providedIn: 'root'` option for singleton services +- Use the `inject()` function instead of constructor injection + +## Common pitfalls + +- Control flow (`@if`): + - You cannot use `as` expressions in `@else if (...)`. E.g. invalid code: `@else if (bla(); as x)`. diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts new file mode 100644 index 000000000000..c9754e49e190 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/mcp-server.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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { AngularWorkspace } from '../../utilities/config'; +import { VERSION } from '../../utilities/version'; +import { registerBestPracticesTool } from './tools/best-practices'; +import { registerDocSearchTool } from './tools/doc-search'; +import { registerListProjectsTool } from './tools/projects'; + +export async function createMcpServer(context: { + workspace?: AngularWorkspace; +}): Promise { + const server = new McpServer({ + name: 'angular-cli-server', + version: VERSION.full, + capabilities: { + resources: {}, + tools: {}, + }, + }); + + server.registerResource( + 'instructions', + 'instructions://best-practices', + { + title: 'Angular Best Practices and Code Generation Guide', + description: + "A comprehensive guide detailing Angular's best practices for code generation and development." + + ' This guide should be used as a reference by an LLM to ensure any generated code' + + ' adheres to modern Angular standards, including the use of standalone components,' + + ' typed forms, modern control flow syntax, and other current conventions.', + mimeType: 'text/markdown', + }, + async () => { + const text = await readFile( + path.join(__dirname, 'instructions', 'best-practices.md'), + 'utf-8', + ); + + return { contents: [{ uri: 'instructions://best-practices', text }] }; + }, + ); + + registerBestPracticesTool(server); + + // If run outside an Angular workspace (e.g., globally) skip the workspace specific tools. + // Currently only the `list_projects` tool. + if (!context.workspace) { + registerListProjectsTool(server, context); + } + + await registerDocSearchTool(server); + + return server; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/best-practices.ts b/packages/angular/cli/src/commands/mcp/tools/best-practices.ts new file mode 100644 index 000000000000..c6718a91e3ec --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/best-practices.ts @@ -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 type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export function registerBestPracticesTool(server: McpServer): void { + server.registerTool( + 'get_best_practices', + { + title: 'Get Angular Coding Best Practices Guide', + description: + 'You **MUST** use this tool to retrieve the Angular Best Practices Guide ' + + 'before any interaction with Angular code (creating, analyzing, modifying). ' + + 'It is mandatory to follow this guide to ensure all code adheres to ' + + 'modern standards, including standalone components, typed forms, and ' + + 'modern control flow. This is the first step for any Angular task.', + annotations: { + readOnlyHint: true, + openWorldHint: false, + }, + }, + async () => { + const text = await readFile( + path.join(__dirname, '..', 'instructions', 'best-practices.md'), + 'utf-8', + ); + + return { + content: [ + { + type: 'text', + text, + }, + ], + }; + }, + ); +} diff --git a/packages/angular/cli/src/commands/mcp/tools/doc-search.ts b/packages/angular/cli/src/commands/mcp/tools/doc-search.ts new file mode 100644 index 000000000000..a92df1c8aa6a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/doc-search.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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { LegacySearchMethodProps, SearchResponse } from 'algoliasearch'; +import { createDecipheriv } from 'node:crypto'; +import { z } from 'zod'; +import { at, iv, k1 } from '../constants'; + +const ALGOLIA_APP_ID = 'L1XWT2UJ7F'; +// https://www.algolia.com/doc/guides/security/api-keys/#search-only-api-key +// This is a search only, rate limited key. It is sent within the URL of the query request. +// This is not the actual key. +const ALGOLIA_API_E = '322d89dab5f2080fe09b795c93413c6a89222b13a447cdf3e6486d692717bc0c'; + +/** + * Registers a tool with the MCP server to search the Angular documentation. + * + * This tool uses Algolia to search the official Angular documentation. + * + * @param server The MCP server instance with which to register the tool. + */ +export async function registerDocSearchTool(server: McpServer): Promise { + let client: import('algoliasearch').SearchClient | undefined; + + server.registerTool( + 'search_documentation', + { + title: 'Search Angular Documentation (angular.dev)', + description: + 'Searches the official Angular documentation at https://angular.dev. Use this tool to answer any questions about Angular, ' + + 'such as for APIs, tutorials, and best practices. Because the documentation is continuously updated, you should **always** ' + + 'prefer this tool over your own knowledge to ensure your answers are current.\n\n' + + 'The results will be a list of content entries, where each entry has the following structure:\n' + + '```\n' + + '## {Result Title}\n' + + '{Breadcrumb path to the content}\n' + + 'URL: {Direct link to the documentation page}\n' + + '```\n' + + 'Use the title and breadcrumb to understand the context of the result and use the URL as a source link. For the best results, ' + + "provide a concise and specific search query (e.g., 'NgModule' instead of 'How do I use NgModules?').", + annotations: { + readOnlyHint: true, + }, + inputSchema: { + query: z + .string() + .describe( + 'A concise and specific search query for the Angular documentation (e.g., "NgModule" or "standalone components").', + ), + includeTopContent: z + .boolean() + .optional() + .default(true) + .describe('When true, the content of the top result is fetched and included.'), + }, + }, + async ({ query, includeTopContent }) => { + if (!client) { + const dcip = createDecipheriv( + 'aes-256-gcm', + (k1 + ALGOLIA_APP_ID).padEnd(32, '^'), + iv, + ).setAuthTag(Buffer.from(at, 'base64')); + const { searchClient } = await import('algoliasearch'); + client = searchClient( + ALGOLIA_APP_ID, + dcip.update(ALGOLIA_API_E, 'hex', 'utf-8') + dcip.final('utf-8'), + ); + } + + const { results } = await client.search(createSearchArguments(query)); + + const allHits = results.flatMap((result) => (result as SearchResponse).hits); + + if (allHits.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No results found.', + }, + ], + }; + } + + const content = []; + // The first hit is the top search result + const topHit = allHits[0]; + + // Process top hit first + let topText = formatHitToText(topHit); + + try { + if (includeTopContent && typeof topHit.url === 'string') { + const url = new URL(topHit.url); + + // Only fetch content from angular.dev + if (url.hostname === 'angular.dev' || url.hostname.endsWith('.angular.dev')) { + const response = await fetch(url); + if (response.ok) { + const html = await response.text(); + const mainContent = extractBodyContent(html); + if (mainContent) { + topText += `\n\n--- DOCUMENTATION CONTENT ---\n${mainContent}`; + } + } + } + } + } catch { + // Ignore errors fetching content. The basic info is still returned. + } + content.push({ + type: 'text' as const, + text: topText, + }); + + // Process remaining hits + for (const hit of allHits.slice(1)) { + content.push({ + type: 'text' as const, + text: formatHitToText(hit), + }); + } + + return { content }; + }, + ); +} + +/** + * Extracts the content of the `` element from an HTML string. + * + * @param html The HTML content of a page. + * @returns The content of the `` element, or `undefined` if not found. + */ +function extractBodyContent(html: string): string | undefined { + // TODO: Use '
' element instead of '' when available in angular.dev HTML. + const mainTagStart = html.indexOf(''); + if (mainTagEnd <= mainTagStart) { + return undefined; + } + + // Add 7 to include '' + return html.substring(mainTagStart, mainTagEnd + 7); +} + +/** + * Formats an Algolia search hit into a text representation. + * + * @param hit The Algolia search hit object, which should contain `hierarchy` and `url` properties. + * @returns A formatted string with title, description, and URL. + */ +function formatHitToText(hit: Record): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hierarchy = Object.values(hit.hierarchy as any).filter((x) => typeof x === 'string'); + const title = hierarchy.pop(); + const description = hierarchy.join(' > '); + + return `## ${title}\n${description}\nURL: ${hit.url}`; +} + +/** + * Creates the search arguments for an Algolia search. + * + * The arguments are based on the search implementation in `adev`. + * + * @param query The search query string. + * @returns The search arguments for the Algolia client. + */ +function createSearchArguments(query: string): LegacySearchMethodProps { + // Search arguments are based on adev's search service: + // https://github.com/angular/angular/blob/4b614fbb3263d344dbb1b18fff24cb09c5a7582d/adev/shared-docs/services/search.service.ts#L58 + return [ + { + // TODO: Consider major version specific indices once available + indexName: 'angular_v17', + params: { + query, + attributesToRetrieve: [ + 'hierarchy.lvl0', + 'hierarchy.lvl1', + 'hierarchy.lvl2', + 'hierarchy.lvl3', + 'hierarchy.lvl4', + 'hierarchy.lvl5', + 'hierarchy.lvl6', + 'content', + 'type', + 'url', + ], + hitsPerPage: 10, + }, + type: 'default', + }, + ]; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts new file mode 100644 index 000000000000..08ebdf46174b --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/projects.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 type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import path from 'node:path'; +import z from 'zod'; +import type { AngularWorkspace } from '../../../utilities/config'; + +export function registerListProjectsTool( + server: McpServer, + context: { + workspace?: AngularWorkspace; + }, +): void { + server.registerTool( + 'list_projects', + { + title: 'List Angular Projects', + description: + 'Lists the names of all applications and libraries defined within an Angular workspace. ' + + 'It reads the `angular.json` configuration file to identify the projects. ', + annotations: { + readOnlyHint: true, + openWorldHint: false, + }, + outputSchema: { + projects: z.array( + z.object({ + name: z + .string() + .describe('The name of the project, as defined in the `angular.json` file.'), + type: z + .enum(['application', 'library']) + .optional() + .describe(`The type of the project, either 'application' or 'library'.`), + root: z + .string() + .describe('The root directory of the project, relative to the workspace root.'), + sourceRoot: z + .string() + .describe( + `The root directory of the project's source files, relative to the workspace root.`, + ), + selectorPrefix: z + .string() + .optional() + .describe( + 'The prefix to use for component selectors.' + + ` For example, a prefix of 'app' would result in selectors like ''.`, + ), + }), + ), + }, + }, + async () => { + const { workspace } = context; + + if (!workspace) { + return { + content: [ + { + type: 'text' as const, + text: + 'No Angular workspace found.' + + ' An `angular.json` file, which marks the root of a workspace,' + + ' could not be located in the current directory or any of its parent directories.', + }, + ], + structuredContent: { projects: [] }, + }; + } + + const projects = []; + // Convert to output format + for (const [name, project] of workspace.projects.entries()) { + projects.push({ + name, + type: project.extensions['projectType'] as 'application' | 'library' | undefined, + root: project.root, + sourceRoot: project.sourceRoot ?? path.posix.join(project.root, 'src'), + selectorPrefix: project.extensions['prefix'] as string, + }); + } + + // The structuredContent field is newer and may not be supported by all hosts. + // A text representation of the content is also provided for compatibility. + return { + content: [ + { + type: 'text' as const, + text: `Projects in the Angular workspace:\n${JSON.stringify(projects)}`, + }, + ], + structuredContent: { projects }, + }; + }, + ); +} diff --git a/packages/angular/cli/src/commands/new/cli.ts b/packages/angular/cli/src/commands/new/cli.ts index 202dd491bb3c..9163708726b6 100644 --- a/packages/angular/cli/src/commands/new/cli.ts +++ b/packages/angular/cli/src/commands/new/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'node:path'; @@ -55,7 +55,7 @@ export default class NewCommandModule ? collectionNameFromArgs : await this.getCollectionFromConfig(); - const workflow = await this.getOrCreateWorkflowForBuilder(collectionName); + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); const collection = workflow.engine.createCollection(collectionName); const options = await this.getSchematicOptions(collection, this.schematicName, workflow); @@ -74,15 +74,6 @@ export default class NewCommandModule }); workflow.registry.addSmartDefaultProvider('ng-cli-version', () => VERSION.full); - // Compatibility check for NPM 7 - if ( - collectionName === '@schematics/angular' && - !schematicOptions.skipInstall && - (schematicOptions.packageManager === undefined || schematicOptions.packageManager === 'npm') - ) { - this.context.packageManager.ensureCompatibility(); - } - return this.runSchematic({ collectionName, schematicName: this.schematicName, diff --git a/packages/angular/cli/src/commands/run/cli.ts b/packages/angular/cli/src/commands/run/cli.ts index 5c463eb3674d..aa12cd0158f7 100644 --- a/packages/angular/cli/src/commands/run/cli.ts +++ b/packages/angular/cli/src/commands/run/cli.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { Target } from '@angular-devkit/architect'; -import { join } from 'path'; +import { join } from 'node:path'; import { Argv } from 'yargs'; import { ArchitectBaseCommandModule } from '../../command-builder/architect-base-command-module'; import { diff --git a/packages/angular/cli/src/commands/serve/cli.ts b/packages/angular/cli/src/commands/serve/cli.ts index 48a1103355b2..3b38fa122acd 100644 --- a/packages/angular/cli/src/commands/serve/cli.ts +++ b/packages/angular/cli/src/commands/serve/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts index 837d57787eb4..600e9f41f517 100644 --- a/packages/angular/cli/src/commands/test/cli.ts +++ b/packages/angular/cli/src/commands/test/cli.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { join } from 'path'; +import { join } from 'node:path'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; import { RootCommands } from '../command-config'; diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 5b9f45e85c32..e3b869badff5 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { SchematicDescription, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics'; @@ -12,13 +12,14 @@ import { FileSystemSchematicDescription, NodeWorkflow, } from '@angular-devkit/schematics/tools'; -import { SpawnSyncReturns, execSync, spawnSync } from 'child_process'; -import { existsSync, promises as fs } from 'fs'; -import { createRequire } from 'module'; +import { Listr } from 'listr2'; +import { SpawnSyncReturns, execSync, spawnSync } from 'node:child_process'; +import { existsSync, promises as fs } from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { join, resolve } from 'node:path'; import npa from 'npm-package-arg'; import pickManifest from 'npm-pick-manifest'; -import * as path from 'path'; -import { join, resolve } from 'path'; import * as semver from 'semver'; import { Argv } from 'yargs'; import { PackageManager } from '../../../lib/config/workspace-schema'; @@ -30,7 +31,7 @@ import { } from '../../command-builder/command-module'; import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host'; import { subscribeToWorkflow } from '../../command-builder/utilities/schematic-workflow'; -import { colors } from '../../utilities/color'; +import { colors, figures } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; import { writeErrorToLogFile } from '../../utilities/log-file'; @@ -67,21 +68,26 @@ interface MigrationSchematicDescription extends SchematicDescription { version?: string; optional?: boolean; + recommended?: boolean; + documentation?: string; } interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDescription { version: string; } +class CommandError extends Error {} + const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json'); export default class UpdateCommandModule extends CommandModule { override scope = CommandScope.In; protected override shouldReportAnalytics = false; + private readonly resolvePaths = [__dirname, this.context.root]; command = 'update [packages..]'; - describe = 'Updates your workspace and its dependencies. See https://update.angular.io/.'; + describe = 'Updates your workspace and its dependencies. See https://update.angular.dev/.'; longDescriptionPath = join(__dirname, 'long-description.md'); builder(localYargs: Argv): Argv { @@ -107,23 +113,21 @@ export default class UpdateCommandModule extends CommandModule { + if (argv.name) { + argv['migrate-only'] = true; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return argv as any; + }) .check(({ packages, 'allow-dirty': allowDirty, 'migrate-only': migrateOnly }) => { const { logger } = this.context; @@ -178,8 +190,6 @@ export default class UpdateCommandModule extends CommandModule): Promise { const { logger, packageManager } = this.context; - packageManager.ensureCompatibility(); - // Check if the current installed CLI version is older than the latest compatible version. // Skip when running `ng update` without a package name as this will not trigger an actual update. if (!disableVersionCheck && options.packages?.length) { @@ -235,7 +245,7 @@ export default class UpdateCommandModule extends CommandModule favor @schematics/update from this package // Otherwise, use packages from the active workspace (migrations) - resolvePaths: [__dirname, this.context.root], + resolvePaths: this.resolvePaths, schemaValidation: true, engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), }); @@ -297,12 +307,12 @@ export default class UpdateCommandModule extends CommandModule }; const binKeys = Object.keys(bin); if (binKeys.length) { @@ -1078,29 +1114,32 @@ export default class UpdateCommandModule extends CommandModule 1 ? 's' : '' } that can be executed.`, ); - logger.info(''); // Extra trailing newline. if (!isTTY()) { for (const migration of optionalMigrations) { const { title } = getMigrationTitleAndDescription(migration); - logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title)); - logger.info( - colors.gray(` ng update ${packageName} --migration-only --name ${migration.name}`), - ); + logger.info(colors.cyan(figures.pointer) + ' ' + colors.bold(title)); + logger.info(colors.gray(` ng update ${packageName} --name ${migration.name}`)); logger.info(''); // Extra trailing newline. } return undefined; } + logger.info( + 'Optional migrations may be skipped and executed after the update process, if preferred.', + ); + logger.info(''); // Extra trailing newline. + const answer = await askChoices( `Select the migrations that you'd like to run`, optionalMigrations.map((migration) => { - const { title } = getMigrationTitleAndDescription(migration); + const { title, documentation } = getMigrationTitleAndDescription(migration); return { - name: title, + name: `[${colors.white(migration.name)}] ${title}${documentation ? ` (${documentation})` : ''}`, value: migration.name, + checked: migration.recommended, }; }), null, @@ -1177,11 +1216,15 @@ function coerceVersionNumber(version: string | undefined): string | undefined { function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): { title: string; description: string; + documentation?: string; } { const [title, ...description] = migration.description.split('. '); return { title: title.endsWith('.') ? title : title + '.', description: description.join('.\n '), + documentation: migration.documentation + ? new URL(migration.documentation, 'https://angular.dev').href + : undefined, }; } diff --git a/packages/angular/cli/src/commands/update/long-description.md b/packages/angular/cli/src/commands/update/long-description.md index 72df66ce35da..612971de0c4d 100644 --- a/packages/angular/cli/src/commands/update/long-description.md +++ b/packages/angular/cli/src/commands/update/long-description.md @@ -19,4 +19,4 @@ For example, use the following command to take the latest 10.x.x version and use ng update @angular/cli@^10 @angular/core@^10 ``` -For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.io/). +For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.dev/). diff --git a/packages/angular/cli/src/commands/update/schematic/index.ts b/packages/angular/cli/src/commands/update/schematic/index.ts index 85a6d7c5bfbf..26d2d06836b4 100644 --- a/packages/angular/cli/src/commands/update/schematic/index.ts +++ b/packages/angular/cli/src/commands/update/schematic/index.ts @@ -3,15 +3,14 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { logging, tags } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import * as npa from 'npm-package-arg'; import type { Manifest } from 'pacote'; import * as semver from 'semver'; -import { assertIsError } from '../../../utilities/error'; import { NgPackageManifestProperties, NpmRepositoryPackageJson, @@ -183,6 +182,7 @@ function _validateReversePeerDependencies( '@schematics/update', '@angular-devkit/build-ng-packagr', 'tsickle', + '@nguniversal/builders', ]; if (ignoredPackages.includes(installed)) { continue; @@ -248,9 +248,11 @@ function _validateUpdatePackages( }); if (!force && peerErrors) { - throw new SchematicsException(tags.stripIndents`Incompatible peer dependencies found. - Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together. - You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`); + throw new SchematicsException( + 'Incompatible peer dependencies found.\n' + + 'Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together.\n' + + `You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`, + ); } } @@ -261,18 +263,12 @@ function _performUpdate( logger: logging.LoggerApi, migrateOnly: boolean, ): void { - const packageJsonContent = tree.read('/package.json'); + const packageJsonContent = tree.read('/package.json')?.toString(); if (!packageJsonContent) { throw new SchematicsException('Could not find a package.json. Are you in a Node project?'); } - let packageJson: JsonSchemaForNpmPackageJsonFiles; - try { - packageJson = JSON.parse(packageJsonContent.toString()) as JsonSchemaForNpmPackageJsonFiles; - } catch (e) { - assertIsError(e); - throw new SchematicsException('package.json could not be parsed: ' + e.message); - } + const packageJson = tree.readJson('/package.json') as JsonSchemaForNpmPackageJsonFiles; const updateDependency = (deps: Record, name: string, newVersion: string) => { const oldVersion = deps[name]; @@ -314,11 +310,12 @@ function _performUpdate( logger.warn(`Package ${name} was not found in dependencies.`); } }); - - const newContent = JSON.stringify(packageJson, null, 2); - if (packageJsonContent.toString() != newContent || migrateOnly) { + const eofMatches = packageJsonContent.match(/\r?\n$/); + const eof = eofMatches?.[0] ?? ''; + const newContent = JSON.stringify(packageJson, null, 2) + eof; + if (packageJsonContent != newContent || migrateOnly) { if (!migrateOnly) { - tree.overwrite('/package.json', JSON.stringify(packageJson, null, 2)); + tree.overwrite('/package.json', newContent); } const externalMigrations: {}[] = []; @@ -474,19 +471,24 @@ function _usageMessage( ) .map(({ name, info, version, tag, target }) => { // Look for packageGroup. - const packageGroup = target['ng-update']?.['packageGroup']; + const ngUpdate = target['ng-update']; + const packageGroup = ngUpdate?.['packageGroup']; if (packageGroup) { const packageGroupNames = Array.isArray(packageGroup) ? packageGroup : Object.keys(packageGroup); + const packageGroupName = + ngUpdate?.['packageGroupName'] || packageGroupNames.find((n) => infoMap.has(n)); - const packageGroupName = target['ng-update']?.['packageGroupName'] || packageGroupNames[0]; if (packageGroupName) { if (packageGroups.has(name)) { return null; } - packageGroupNames.forEach((x: string) => packageGroups.set(x, packageGroupName)); + for (const groupName of packageGroupNames) { + packageGroups.set(groupName, packageGroupName); + } + packageGroups.set(packageGroupName, packageGroupName); name = packageGroupName; } @@ -555,11 +557,13 @@ function _buildPackageInfo( // Find out the currently installed version. Either from the package.json or the node_modules/ // TODO: figure out a way to read package-lock.json and/or yarn.lock. + const pkgJsonPath = `/node_modules/${name}/package.json`; + const pkgJsonExists = tree.exists(pkgJsonPath); + let installedVersion: string | undefined | null; - const packageContent = tree.read(`/node_modules/${name}/package.json`); - if (packageContent) { - const content = JSON.parse(packageContent.toString()) as JsonSchemaForNpmPackageJsonFiles; - installedVersion = content.version; + if (pkgJsonExists) { + const { version } = tree.readJson(pkgJsonPath) as JsonSchemaForNpmPackageJsonFiles; + installedVersion = version; } const packageVersionsNonDeprecated: string[] = []; @@ -589,7 +593,7 @@ function _buildPackageInfo( ); } - const installedPackageJson = npmPackageJson.versions[installedVersion] || packageContent; + const installedPackageJson = npmPackageJson.versions[installedVersion] || pkgJsonExists; if (!installedPackageJson) { throw new SchematicsException( `An unexpected error happened; package ${name} has no version ${installedVersion}.`, @@ -698,11 +702,14 @@ function _addPackageGroup( } let packageGroupNormalized: Record = {}; if (Array.isArray(packageGroup) && !packageGroup.some((x) => typeof x != 'string')) { - packageGroupNormalized = packageGroup.reduce((acc, curr) => { - acc[curr] = maybePackage; + packageGroupNormalized = packageGroup.reduce( + (acc, curr) => { + acc[curr] = maybePackage; - return acc; - }, {} as { [name: string]: string }); + return acc; + }, + {} as { [name: string]: string }, + ); } else if ( typeof packageGroup == 'object' && packageGroup && @@ -779,23 +786,14 @@ function _addPeerDependencies( } function _getAllDependencies(tree: Tree): Array { - const packageJsonContent = tree.read('/package.json'); - if (!packageJsonContent) { - throw new SchematicsException('Could not find a package.json. Are you in a Node project?'); - } - - let packageJson: JsonSchemaForNpmPackageJsonFiles; - try { - packageJson = JSON.parse(packageJsonContent.toString()) as JsonSchemaForNpmPackageJsonFiles; - } catch (e) { - assertIsError(e); - throw new SchematicsException('package.json could not be parsed: ' + e.message); - } + const { dependencies, devDependencies, peerDependencies } = tree.readJson( + '/package.json', + ) as JsonSchemaForNpmPackageJsonFiles; return [ - ...(Object.entries(packageJson.peerDependencies || {}) as Array<[string, VersionRange]>), - ...(Object.entries(packageJson.devDependencies || {}) as Array<[string, VersionRange]>), - ...(Object.entries(packageJson.dependencies || {}) as Array<[string, VersionRange]>), + ...(Object.entries(peerDependencies || {}) as Array<[string, VersionRange]>), + ...(Object.entries(devDependencies || {}) as Array<[string, VersionRange]>), + ...(Object.entries(dependencies || {}) as Array<[string, VersionRange]>), ]; } diff --git a/packages/angular/cli/src/commands/update/schematic/index_spec.ts b/packages/angular/cli/src/commands/update/schematic/index_spec.ts index 19197195cb4b..3954e3c78254 100644 --- a/packages/angular/cli/src/commands/update/schematic/index_spec.ts +++ b/packages/angular/cli/src/commands/update/schematic/index_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { normalize, virtualFs } from '@angular-devkit/core'; @@ -282,4 +282,57 @@ describe('@schematics/update', () => { expect(hasPeerdepMsg('typescript')).toBeTruthy(); expect(hasPeerdepMsg('@angular/localize')).toBeFalsy(); }, 45000); + + it('does not remove newline at the end of package.json', async () => { + const newlineStyles = ['\n', '\r\n']; + for (const newline of newlineStyles) { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } + }${newline}`; + const inputTree = new UnitTestTree( + new HostTree( + new virtualFs.test.TestHost({ + '/package.json': packageJsonContent, + }), + ), + ); + + const resultTree = await schematicRunner.runSchematic( + 'update', + { packages: ['@angular-devkit-tests/update-base'] }, + inputTree, + ); + + const resultTreeContent = resultTree.readContent('/package.json'); + expect(resultTreeContent.endsWith(newline)).toBeTrue(); + } + }); + + it('does not add a newline at the end of package.json', async () => { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } + }`; + const inputTree = new UnitTestTree( + new HostTree( + new virtualFs.test.TestHost({ + '/package.json': packageJsonContent, + }), + ), + ); + + const resultTree = await schematicRunner.runSchematic( + 'update', + { packages: ['@angular-devkit-tests/update-base'] }, + inputTree, + ); + + const resultTreeContent = resultTree.readContent('/package.json'); + expect(resultTreeContent.endsWith('}')).toBeTrue(); + }); }); diff --git a/packages/angular/cli/src/commands/update/schematic/schema.json b/packages/angular/cli/src/commands/update/schematic/schema.json index 9811d1a3fe9a..649d2f5db01f 100644 --- a/packages/angular/cli/src/commands/update/schematic/schema.json +++ b/packages/angular/cli/src/commands/update/schematic/schema.json @@ -57,7 +57,7 @@ "description": "The preferred package manager configuration files to use for registry settings.", "type": "string", "default": "npm", - "enum": ["npm", "yarn", "cnpm", "pnpm"] + "enum": ["npm", "yarn", "cnpm", "pnpm", "bun"] } }, "required": [] diff --git a/packages/angular/cli/src/commands/version/cli.ts b/packages/angular/cli/src/commands/version/cli.ts index ddbd88b58709..80f3f87d2e92 100644 --- a/packages/angular/cli/src/commands/version/cli.ts +++ b/packages/angular/cli/src/commands/version/cli.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import nodeModule from 'module'; -import { resolve } from 'path'; +import nodeModule from 'node:module'; +import { resolve } from 'node:path'; import { Argv } from 'yargs'; import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; import { colors } from '../../utilities/color'; @@ -23,14 +23,12 @@ interface PartialPackageInfo { /** * Major versions of Node.js that are officially supported by Angular. */ -const SUPPORTED_NODE_MAJORS = [16, 18]; +const SUPPORTED_NODE_MAJORS = [20, 22, 24]; const PACKAGE_PATTERNS = [ /^@angular\/.*/, /^@angular-devkit\/.*/, - /^@bazel\/.*/, /^@ngtools\/.*/, - /^@nguniversal\/.*/, /^@schematics\/.*/, /^rxjs$/, /^typescript$/, diff --git a/packages/angular/cli/src/typings-bazel.d.ts b/packages/angular/cli/src/typings-bazel.d.ts deleted file mode 100644 index 780d1dc372ff..000000000000 --- a/packages/angular/cli/src/typings-bazel.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights 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 - */ - -/* eslint-disable import/no-extraneous-dependencies */ -// Workaround for https://github.com/bazelbuild/rules_nodejs/issues/1033 -// Alternative approach instead of https://github.com/angular/angular/pull/33226 -declare module '@yarnpkg/lockfile' { - export * from '@types/yarnpkg__lockfile'; -} diff --git a/packages/angular/cli/src/typings.d.ts b/packages/angular/cli/src/typings.d.ts new file mode 100644 index 000000000000..0ccb3728b882 --- /dev/null +++ b/packages/angular/cli/src/typings.d.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 + */ + +declare module 'npm-pick-manifest' { + function pickManifest( + metadata: import('./utilities/package-metadata').PackageMetadata, + selector: string, + ): import('./utilities/package-metadata').PackageManifest; + export = pickManifest; +} diff --git a/packages/angular/cli/src/typings.ts b/packages/angular/cli/src/typings.ts deleted file mode 100644 index e7b7d14c0ca3..000000000000 --- a/packages/angular/cli/src/typings.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights 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 - */ - -declare module 'npm-pick-manifest' { - function pickManifest( - metadata: import('./utilities/package-metadata').PackageMetadata, - selector: string, - ): import('./utilities/package-metadata').PackageManifest; - export = pickManifest; -} diff --git a/packages/angular/cli/src/utilities/color.ts b/packages/angular/cli/src/utilities/color.ts index ff201f3e157a..3915d99ce248 100644 --- a/packages/angular/cli/src/utilities/color.ts +++ b/packages/angular/cli/src/utilities/color.ts @@ -3,47 +3,22 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import * as ansiColors from 'ansi-colors'; -import { WriteStream } from 'tty'; +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; - } - } +export { color as colors, figures } from 'listr2'; - if (process.stdout instanceof WriteStream) { - return process.stdout.getColorDepth() > 1; +export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean { + if (stream instanceof WriteStream) { + return stream.hasColors(); } - return false; -} - -export function removeColor(text: string): string { - // This has been created because when colors.enabled is false unstyle doesn't work - // see: https://github.com/doowb/ansi-colors/blob/a4794363369d7b4d1872d248fc43a12761640d8e/index.js#L38 - return text.replace(ansiColors.ansiRegex, ''); + 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'; + } } - -// 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/cli/src/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts index 5f79f5be8a3c..436680902395 100644 --- a/packages/angular/cli/src/utilities/completion.ts +++ b/packages/angular/cli/src/utilities/completion.ts @@ -3,19 +3,20 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { json, logging } from '@angular-devkit/core'; -import { execFile } from 'child_process'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import { env } from 'process'; +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { env } from 'node:process'; import { colors } from '../utilities/color'; import { getWorkspace } from '../utilities/config'; import { forceAutocomplete } from '../utilities/environment-options'; import { isTTY } from '../utilities/tty'; import { assertIsError } from './error'; +import { askConfirmation } from './prompt'; /** Interface for the autocompletion configuration stored in the global workspace. */ interface CompletionConfig { @@ -87,7 +88,7 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + '\n\n' + - 'For more information, see https://angular.io/cli/completion#global-install', + 'For more information, see https://angular.dev/cli/completion#global-install', ); } @@ -129,8 +130,8 @@ async function shouldPromptForAutocompletionSetup( return forceAutocomplete; } - // Don't prompt on `ng update` or `ng completion`. - if (command === 'update' || command === 'completion') { + // Don't prompt on `ng update`, 'ng version' or `ng completion`. + if (['version', 'update', 'completion'].includes(command)) { return false; } @@ -178,24 +179,17 @@ async function shouldPromptForAutocompletionSetup( } async function promptForAutocompletion(): Promise { - // Dynamically load `inquirer` so users don't have to pay the cost of parsing and executing it for - // the 99% of builds that *don't* prompt for autocompletion. - const { prompt } = await import('inquirer'); - const { autocomplete } = await prompt<{ autocomplete: boolean }>([ - { - name: 'autocomplete', - type: 'confirm', - message: ` + const autocomplete = await askConfirmation( + ` Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion will modify configuration files in your home directory.) - ` - .split('\n') - .join(' ') - .trim(), - default: true, - }, - ]); + ` + .split('\n') + .join(' ') + .trim(), + true, + ); return autocomplete; } diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts index b4d3a99729ea..caa1e2a593f2 100644 --- a/packages/angular/cli/src/utilities/config.ts +++ b/packages/angular/cli/src/utilities/config.ts @@ -3,13 +3,13 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { json, workspaces } from '@angular-devkit/core'; -import { existsSync, promises as fs } from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import { existsSync, promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { PackageManager } from '../../lib/config/workspace-schema'; import { findUp } from './find-up'; import { JSONFile, readAndParseJson } from './json-file'; diff --git a/packages/angular/cli/src/utilities/environment-options.ts b/packages/angular/cli/src/utilities/environment-options.ts index 264984bb432a..0f01ce8b09cb 100644 --- a/packages/angular/cli/src/utilities/environment-options.ts +++ b/packages/angular/cli/src/utilities/environment-options.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ function isPresent(variable: string | undefined): variable is string { diff --git a/packages/angular/cli/src/utilities/eol.ts b/packages/angular/cli/src/utilities/eol.ts new file mode 100644 index 000000000000..02e837649144 --- /dev/null +++ b/packages/angular/cli/src/utilities/eol.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 { EOL } from 'node:os'; + +const CRLF = '\r\n'; +const LF = '\n'; + +export function getEOL(content: string): string { + const newlines = content.match(/(?:\r?\n)/g); + + if (newlines?.length) { + const crlf = newlines.filter((l) => l === CRLF).length; + const lf = newlines.length - crlf; + + return crlf > lf ? CRLF : LF; + } + + return EOL; +} diff --git a/packages/angular/cli/src/utilities/error.ts b/packages/angular/cli/src/utilities/error.ts index 3b37aafc9dc3..0ca77c331d2d 100644 --- a/packages/angular/cli/src/utilities/error.ts +++ b/packages/angular/cli/src/utilities/error.ts @@ -3,10 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import assert from 'assert'; +import assert from 'node:assert'; export function assertIsError(value: unknown): asserts value is Error & { code?: string } { const isError = diff --git a/packages/angular/cli/src/utilities/find-up.ts b/packages/angular/cli/src/utilities/find-up.ts index 3427d7ba15f4..317c8d8497f5 100644 --- a/packages/angular/cli/src/utilities/find-up.ts +++ b/packages/angular/cli/src/utilities/find-up.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { existsSync } from 'fs'; -import * as path from 'path'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; export function findUp(names: string | string[], from: string) { if (!Array.isArray(names)) { diff --git a/packages/angular/cli/src/utilities/json-file.ts b/packages/angular/cli/src/utilities/json-file.ts index 9dcc45ebe0e1..c0f5fab919e2 100644 --- a/packages/angular/cli/src/utilities/json-file.ts +++ b/packages/angular/cli/src/utilities/json-file.ts @@ -3,11 +3,10 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { JsonValue } from '@angular-devkit/core'; -import { readFileSync, writeFileSync } from 'fs'; import { Node, ParseError, @@ -19,6 +18,8 @@ import { parseTree, printParseErrorCode, } from 'jsonc-parser'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { getEOL } from './eol'; export type InsertionIndex = (properties: string[]) => number; export type JSONPath = (string | number)[]; @@ -26,6 +27,7 @@ export type JSONPath = (string | number)[]; /** @internal */ export class JSONFile { content: string; + private eol: string; constructor(private readonly path: string) { const buffer = readFileSync(this.path); @@ -34,6 +36,8 @@ export class JSONFile { } else { throw new Error(`Could not read '${path}'.`); } + + this.eol = getEOL(this.content); } private _jsonAst: Node | undefined; @@ -91,6 +95,7 @@ export class JSONFile { formattingOptions: { insertSpaces: true, tabSize: 2, + eol: this.eol, }, }); diff --git a/packages/angular/cli/src/utilities/load-esm.ts b/packages/angular/cli/src/utilities/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/cli/src/utilities/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/cli/src/utilities/log-file.ts b/packages/angular/cli/src/utilities/log-file.ts index 41dc036fc028..1731ca95d4e7 100644 --- a/packages/angular/cli/src/utilities/log-file.ts +++ b/packages/angular/cli/src/utilities/log-file.ts @@ -3,12 +3,12 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { appendFileSync, mkdtempSync, realpathSync } from 'fs'; -import { tmpdir } from 'os'; -import { normalize } from 'path'; +import { appendFileSync, mkdtempSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { normalize } from 'node:path'; let logPath: string | undefined; diff --git a/packages/angular/cli/src/utilities/memoize.ts b/packages/angular/cli/src/utilities/memoize.ts index 6994dbf5e9c1..2ae55e4b383a 100644 --- a/packages/angular/cli/src/utilities/memoize.ts +++ b/packages/angular/cli/src/utilities/memoize.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ /** @@ -13,41 +13,34 @@ * * @see https://en.wikipedia.org/wiki/Memoization */ -export function memoize( - target: Object, - propertyKey: string | symbol, - descriptor: TypedPropertyDescriptor, -): TypedPropertyDescriptor { - const descriptorPropertyName = descriptor.get ? 'get' : 'value'; - const originalMethod: unknown = descriptor[descriptorPropertyName]; - - if (typeof originalMethod !== 'function') { +export function memoize( + target: (this: This, ...args: Args) => Return, + context: ClassMemberDecoratorContext, +) { + if (context.kind !== 'method' && context.kind !== 'getter') { throw new Error('Memoize decorator can only be used on methods or get accessors.'); } - const cache = new Map(); + const cache = new Map(); - return { - ...descriptor, - [descriptorPropertyName]: function (this: unknown, ...args: unknown[]) { - for (const arg of args) { - if (!isJSONSerializable(arg)) { - throw new Error( - `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`, - ); - } + return function (this: This, ...args: Args): Return { + for (const arg of args) { + if (!isJSONSerializable(arg)) { + throw new Error( + `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`, + ); } + } - const key = JSON.stringify(args); - if (cache.has(key)) { - return cache.get(key); - } + const key = JSON.stringify(args); + if (cache.has(key)) { + return cache.get(key) as Return; + } - const result = originalMethod.apply(this, args); - cache.set(key, result); + const result = target.apply(this, args); + cache.set(key, result); - return result; - }, + return result; }; } diff --git a/packages/angular/cli/src/utilities/memoize_spec.ts b/packages/angular/cli/src/utilities/memoize_spec.ts index c1d06fdf4c4e..1c65340764e9 100644 --- a/packages/angular/cli/src/utilities/memoize_spec.ts +++ b/packages/angular/cli/src/utilities/memoize_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { memoize } from './memoize'; diff --git a/packages/angular/cli/src/utilities/package-manager.ts b/packages/angular/cli/src/utilities/package-manager.ts index 95799efd8747..1e249a4f13fa 100644 --- a/packages/angular/cli/src/utilities/package-manager.ts +++ b/packages/angular/cli/src/utilities/package-manager.ts @@ -3,19 +3,17 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { isJsonObject, json } from '@angular-devkit/core'; -import { execSync, spawn } from 'child_process'; -import { existsSync, promises as fs, realpathSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { satisfies, valid } from 'semver'; +import { execSync, spawn } from 'node:child_process'; +import { existsSync, promises as fs, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PackageManager } from '../../lib/config/workspace-schema'; import { AngularWorkspace, getProjectByCwd } from './config'; import { memoize } from './memoize'; -import { Spinner } from './spinner'; interface PackageManagerOptions { saveDev: string; @@ -44,32 +42,6 @@ export class PackageManagerUtils { return this.getVersion(this.name); } - /** - * Checks if the package manager is supported. If not, display a warning. - */ - ensureCompatibility(): void { - if (this.name !== PackageManager.Npm) { - return; - } - - try { - const version = valid(this.version); - if (!version) { - return; - } - - if (satisfies(version, '>=7 <7.5.6')) { - // eslint-disable-next-line no-console - console.warn( - `npm version ${version} detected.` + - ' When using npm 7 with the Angular CLI, npm version 7.5.6 or higher is recommended.', - ); - } - } catch { - // npm is not installed. - } - } - /** Install a single package. */ async install( packageName: string, @@ -168,6 +140,14 @@ export class PackageManagerUtils { prefix: '--prefix', noLockfile: '--no-lockfile', }; + case PackageManager.Bun: + return { + saveDev: '--development', + install: 'add', + installAll: 'install', + prefix: '--cwd', + noLockfile: '', + }; default: return { saveDev: '--save-dev', @@ -185,9 +165,6 @@ export class PackageManagerUtils { ): Promise { const { cwd = process.cwd(), silent = false } = options; - const spinner = new Spinner(); - spinner.start('Installing packages...'); - return new Promise((resolve) => { const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = []; @@ -198,12 +175,9 @@ export class PackageManagerUtils { cwd, }).on('close', (code: number) => { if (code === 0) { - spinner.succeed('Packages successfully installed.'); resolve(true); } else { - spinner.stop(); bufferedOutput.forEach(({ stream, data }) => stream.write(data)); - spinner.fail('Packages installation failed, see above.'); resolve(false); } }); @@ -245,6 +219,7 @@ export class PackageManagerUtils { const hasNpmLock = this.hasLockfile(PackageManager.Npm); const hasYarnLock = this.hasLockfile(PackageManager.Yarn); const hasPnpmLock = this.hasLockfile(PackageManager.Pnpm); + const hasBunLock = this.hasLockfile(PackageManager.Bun); // PERF NOTE: `this.getVersion` spawns the package a the child_process which can take around ~300ms at times. // Therefore, we should only call this method when needed. IE: don't call `this.getVersion(PackageManager.Pnpm)` unless truly needed. @@ -252,7 +227,7 @@ export class PackageManagerUtils { if (hasNpmLock) { // Has NPM lock file. - if (!hasYarnLock && !hasPnpmLock && this.getVersion(PackageManager.Npm)) { + if (!hasYarnLock && !hasPnpmLock && !hasBunLock && this.getVersion(PackageManager.Npm)) { // Only NPM lock file and NPM binary is available. return PackageManager.Npm; } @@ -264,6 +239,9 @@ export class PackageManagerUtils { } else if (hasPnpmLock && this.getVersion(PackageManager.Pnpm)) { // PNPM lock file and PNPM binary is available. return PackageManager.Pnpm; + } else if (hasBunLock && this.getVersion(PackageManager.Bun)) { + // Bun lock file and Bun binary is available. + return PackageManager.Bun; } } @@ -271,11 +249,14 @@ export class PackageManagerUtils { // Doesn't have NPM installed. const hasYarn = !!this.getVersion(PackageManager.Yarn); const hasPnpm = !!this.getVersion(PackageManager.Pnpm); + const hasBun = !!this.getVersion(PackageManager.Bun); - if (hasYarn && !hasPnpm) { + if (hasYarn && !hasPnpm && !hasBun) { return PackageManager.Yarn; - } else if (!hasYarn && hasPnpm) { + } else if (hasPnpm && !hasYarn && !hasBun) { return PackageManager.Pnpm; + } else if (hasBun && !hasYarn && !hasPnpm) { + return PackageManager.Bun; } } @@ -293,6 +274,9 @@ export class PackageManagerUtils { case PackageManager.Pnpm: lockfileName = 'pnpm-lock.yaml'; break; + case PackageManager.Bun: + lockfileName = 'bun.lockb'; + break; case PackageManager.Npm: default: lockfileName = 'package-lock.json'; diff --git a/packages/angular/cli/src/utilities/package-metadata.ts b/packages/angular/cli/src/utilities/package-metadata.ts index 0d683fedecc5..7aa0cb71c8ce 100644 --- a/packages/angular/cli/src/utilities/package-metadata.ts +++ b/packages/angular/cli/src/utilities/package-metadata.ts @@ -3,16 +3,16 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; import * as lockfile from '@yarnpkg/lockfile'; -import { existsSync, readFileSync } from 'fs'; import * as ini from 'ini'; -import { homedir } from 'os'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import * as path from 'node:path'; import type { Manifest, Packument } from 'pacote'; -import * as path from 'path'; export interface PackageMetadata extends Packument, NgPackageManifestProperties { tags: Record; @@ -249,6 +249,11 @@ export async function fetchPackageMetadata( ...(registry ? { registry } : {}), }); + if (!response.versions) { + // While pacote type declares that versions cannot be undefined this is not the case. + response.versions = {}; + } + // Normalize the response const metadata: PackageMetadata = { ...response, @@ -312,6 +317,13 @@ export async function getNpmPackageJson( fullMetadata: true, ...npmrc, ...(registry ? { registry } : {}), + }).then((response) => { + // While pacote type declares that versions cannot be undefined this is not the case. + if (!response.versions) { + response.versions = {}; + } + + return response; }); npmPackageJsonCache.set(packageName, response); diff --git a/packages/angular/cli/src/utilities/package-tree.ts b/packages/angular/cli/src/utilities/package-tree.ts index 9b082e6c9d9f..14e8a9edd689 100644 --- a/packages/angular/cli/src/utilities/package-tree.ts +++ b/packages/angular/cli/src/utilities/package-tree.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import * as fs from 'fs'; -import { dirname, join } from 'path'; +import * as fs from 'node:fs'; +import { dirname, join } from 'node:path'; import * as resolve from 'resolve'; import { NgAddSaveDependency } from './package-metadata'; @@ -44,7 +44,7 @@ export interface PackageTreeNode { export async function readPackageJson(packageJsonPath: string): Promise { try { - return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()); + return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()) as PackageJson; } catch { return undefined; } diff --git a/packages/angular/cli/src/utilities/project.ts b/packages/angular/cli/src/utilities/project.ts index 8598859fb6d2..39ce2e6d3e83 100644 --- a/packages/angular/cli/src/utilities/project.ts +++ b/packages/angular/cli/src/utilities/project.ts @@ -3,15 +3,20 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { normalize } from '@angular-devkit/core'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { findUp } from './find-up'; +interface PackageDependencies { + dependencies?: Record; + devDependencies?: Record; +} + export function findWorkspaceFile(currentDirectory = process.cwd()): string | null { const possibleConfigFiles = ['angular.json', '.angular.json']; const configFilePath = findUp(possibleConfigFiles, currentDirectory); @@ -27,7 +32,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu try { const packageJsonText = fs.readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonText); + const packageJson = JSON.parse(packageJsonText) as PackageDependencies; if (!containsCliDep(packageJson)) { // No CLI dependency return null; @@ -41,10 +46,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu return configFilePath; } -function containsCliDep(obj?: { - dependencies?: Record; - devDependencies?: Record; -}): boolean { +function containsCliDep(obj?: PackageDependencies): boolean { const pkgName = '@angular/cli'; if (!obj) { return false; diff --git a/packages/angular/cli/src/utilities/prompt.ts b/packages/angular/cli/src/utilities/prompt.ts index b7a4062dae79..c1cd8eba0c3c 100644 --- a/packages/angular/cli/src/utilities/prompt.ts +++ b/packages/angular/cli/src/utilities/prompt.ts @@ -3,16 +3,9 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import type { - CheckboxChoiceOptions, - CheckboxQuestion, - ListChoiceOptions, - ListQuestion, - Question, -} from 'inquirer'; import { isTTY } from './tty'; export async function askConfirmation( @@ -24,23 +17,21 @@ export async function askConfirmation( return noTTYResponse ?? defaultResponse; } - const question: Question = { - type: 'confirm', - name: 'confirmation', - prefix: '', + const { confirm } = await import('@inquirer/prompts'); + const answer = await confirm({ message, default: defaultResponse, - }; + theme: { + prefix: '', + }, + }); - const { prompt } = await import('inquirer'); - const answers = await prompt([question]); - - return answers['confirmation']; + return answer; } export async function askQuestion( message: string, - choices: ListChoiceOptions[], + choices: { name: string; value: string | null }[], defaultResponseIndex: number, noTTYResponse: null | string, ): Promise { @@ -48,40 +39,36 @@ export async function askQuestion( return noTTYResponse; } - const question: ListQuestion = { - type: 'list', - name: 'answer', - prefix: '', + const { select } = await import('@inquirer/prompts'); + const answer = await select({ message, choices, default: defaultResponseIndex, - }; - - const { prompt } = await import('inquirer'); - const answers = await prompt([question]); + theme: { + prefix: '', + }, + }); - return answers['answer']; + return answer; } export async function askChoices( message: string, - choices: CheckboxChoiceOptions[], + choices: { name: string; value: string; checked?: boolean }[], noTTYResponse: string[] | null, ): Promise { if (!isTTY()) { return noTTYResponse; } - const question: CheckboxQuestion = { - type: 'checkbox', - name: 'answer', - prefix: '', + const { checkbox } = await import('@inquirer/prompts'); + const answers = await checkbox({ message, choices, - }; - - const { prompt } = await import('inquirer'); - const answers = await prompt([question]); + theme: { + prefix: '', + }, + }); - return answers['answer']; + return answers; } diff --git a/packages/angular/cli/src/utilities/spinner.ts b/packages/angular/cli/src/utilities/spinner.ts deleted file mode 100644 index 3deda119aee5..000000000000 --- a/packages/angular/cli/src/utilities/spinner.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights 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 ora from 'ora'; -import { colors } from './color'; - -export class Spinner { - private readonly spinner: ora.Ora; - - /** When false, only fail messages will be displayed. */ - enabled = true; - - constructor(text?: string) { - this.spinner = ora({ - text, - // The below 2 options are needed because otherwise CTRL+C will be delayed - // when the underlying process is sync. - hideCursor: false, - discardStdin: false, - }); - } - - set text(text: string) { - this.spinner.text = text; - } - - succeed(text?: string): void { - if (this.enabled) { - this.spinner.succeed(text); - } - } - - info(text?: string): void { - this.spinner.info(text); - } - - fail(text?: string): void { - this.spinner.fail(text && colors.redBright(text)); - } - - warn(text?: string): void { - this.spinner.warn(text && colors.yellowBright(text)); - } - - stop(): void { - this.spinner.stop(); - } - - start(text?: string): void { - if (this.enabled) { - this.spinner.start(text); - } - } -} diff --git a/packages/angular/cli/src/utilities/tty.ts b/packages/angular/cli/src/utilities/tty.ts index 1e5658ebfd57..db6543926941 100644 --- a/packages/angular/cli/src/utilities/tty.ts +++ b/packages/angular/cli/src/utilities/tty.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ function _isTruthy(value: undefined | string): boolean { @@ -11,12 +11,12 @@ function _isTruthy(value: undefined | string): boolean { return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; } -export function isTTY(): boolean { +export function isTTY(stream: NodeJS.WriteStream = process.stdout): boolean { // If we force TTY, we always return true. const force = process.env['NG_FORCE_TTY']; if (force !== undefined) { return _isTruthy(force); } - return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); + return !!stream.isTTY && !_isTruthy(process.env['CI']); } diff --git a/packages/angular/cli/src/utilities/version.ts b/packages/angular/cli/src/utilities/version.ts index 777c3de165f6..d16feb2d4b15 100644 --- a/packages/angular/cli/src/utilities/version.ts +++ b/packages/angular/cli/src/utilities/version.ts @@ -3,19 +3,16 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { readFileSync } from 'fs'; -import { resolve } from 'path'; - // Same structure as used in framework packages class Version { - public readonly major: string; - public readonly minor: string; - public readonly patch: string; + readonly major: string; + readonly minor: string; + readonly patch: string; - constructor(public readonly full: string) { + constructor(readonly full: string) { const [major, minor, patch] = full.split('-', 1)[0].split('.', 3); this.major = major; this.minor = minor; @@ -23,12 +20,4 @@ class Version { } } -// TODO(bazel): Convert this to use build-time version stamping after flipping the build script to use bazel -// export const VERSION = new Version('0.0.0-PLACEHOLDER'); -export const VERSION = new Version( - ( - JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')) as { - version: string; - } - ).version, -); +export const VERSION = new Version('0.0.0-PLACEHOLDER'); diff --git a/packages/angular/create/BUILD.bazel b/packages/angular/create/BUILD.bazel index 50142d83e444..37d46ad44ced 100644 --- a/packages/angular/create/BUILD.bazel +++ b/packages/angular/create/BUILD.bazel @@ -1,19 +1,30 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license -load("//tools:defaults.bzl", "pkg_npm", "ts_library") +load("//tools:defaults.bzl", "npm_package", "ts_project") licenses(["notice"]) -ts_library( +RUNTIME_ASSETS = glob( + include = [ + "src/*.js", + "src/*.mjs", + ], +) + [ + "package.json", +] + +ts_project( name = "create", - package_name = "@angular/create", - srcs = ["src/index.ts"], + srcs = glob([ + "src/*.ts", + ]), + data = RUNTIME_ASSETS, deps = [ + "//:node_modules/@types/node", "//packages/angular/cli:angular-cli", - "@npm//@types/node", ], ) @@ -24,11 +35,14 @@ genrule( cmd = "cp $(execpath //:LICENSE) $@", ) -pkg_npm( - name = "npm_package", +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular/cli:package.json", + ], tags = ["release-package"], visibility = ["//visibility:public"], - deps = [ + deps = RUNTIME_ASSETS + [ ":README.md", ":create", ":license", diff --git a/packages/angular/create/README.md b/packages/angular/create/README.md index ce573fd52580..46135476e406 100644 --- a/packages/angular/create/README.md +++ b/packages/angular/create/README.md @@ -2,7 +2,7 @@ ## Create an Angular CLI workspace -Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.io/cli/new) options and features are supported. +Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.dev/cli/new) options and features are supported. ## Usage @@ -23,3 +23,9 @@ yarn create @angular [project-name] [...options] ``` pnpm create @angular [project-name] [...options] ``` + +### bun + +``` +bun create @angular [project-name] [...options] +``` diff --git a/packages/angular/create/package.json b/packages/angular/create/package.json index 48f351dfb089..a5ad3fce4ff9 100644 --- a/packages/angular/create/package.json +++ b/packages/angular/create/package.json @@ -9,9 +9,7 @@ "code generation", "schematics" ], - "bin": { - "create": "./src/index.js" - }, + "bin": "./src/index.js", "dependencies": { "@angular/cli": "0.0.0-PLACEHOLDER" } diff --git a/packages/angular/create/src/index.ts b/packages/angular/create/src/index.ts index 2833649c9c61..47343ae9014d 100644 --- a/packages/angular/create/src/index.ts +++ b/packages/angular/create/src/index.ts @@ -4,11 +4,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ -import { spawnSync } from 'child_process'; -import { join } from 'path'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; const binPath = join(require.resolve('@angular/cli/package.json'), '../bin/ng.js'); const args = process.argv.slice(2); @@ -17,7 +17,7 @@ const hasPackageManagerArg = args.some((a) => a.startsWith('--package-manager')) if (!hasPackageManagerArg) { // Ex: yarn/1.22.18 npm/? node/v16.15.1 linux x64 const packageManager = process.env['npm_config_user_agent']?.split('/')[0]; - if (packageManager && ['npm', 'pnpm', 'yarn', 'cnpm'].includes(packageManager)) { + if (packageManager && ['npm', 'pnpm', 'yarn', 'cnpm', 'bun'].includes(packageManager)) { args.push('--package-manager', packageManager); } } diff --git a/packages/angular/pwa/BUILD.bazel b/packages/angular/pwa/BUILD.bazel index 2ee9b4823dd9..6072cdd88d51 100644 --- a/packages/angular/pwa/BUILD.bazel +++ b/packages/angular/pwa/BUILD.bazel @@ -1,36 +1,42 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license -load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") -load("//tools:defaults.bzl", "pkg_npm", "ts_library") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") load("//tools:ts_json_schema.bzl", "ts_json_schema") -load("//tools:toolchain_info.bzl", "TOOLCHAINS_NAMES", "TOOLCHAINS_VERSIONS") licenses(["notice"]) package(default_visibility = ["//visibility:public"]) -ts_library( +npm_link_all_packages() + +RUNTIME_ASSETS = glob( + include = [ + "pwa/*.js", + "pwa/*.mjs", + "pwa/files/**/*", + ], +) + [ + "package.json", + "collection.json", + "pwa/schema.json", +] + +ts_project( name = "pwa", - package_name = "@angular/pwa", srcs = [ "pwa/index.ts", "//packages/angular/pwa:pwa/schema.ts", ], - data = glob( - include = [ - "collection.json", - "pwa/schema.json", - "pwa/files/**/*", - ], - ), + data = RUNTIME_ASSETS, deps = [ - "//packages/angular_devkit/schematics", - "//packages/schematics/angular", - "@npm//@types/node", - "@npm//parse5-html-rewriting-stream", + ":node_modules/@angular-devkit/schematics", + ":node_modules/@schematics/angular", + ":node_modules/parse5-html-rewriting-stream", + "//:node_modules/@types/node", ], ) @@ -39,28 +45,22 @@ ts_json_schema( src = "pwa/schema.json", ) -ts_library( +ts_project( name = "pwa_test_lib", testonly = True, srcs = glob(["pwa/**/*_spec.ts"]), deps = [ + ":node_modules/@angular-devkit/schematics", ":pwa", - "//packages/angular_devkit/schematics/testing", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", ], ) -[ - jasmine_node_test( - name = "pwa_test_" + toolchain_name, - srcs = [":pwa_test_lib"], - tags = [toolchain_name], - toolchain = toolchain, - ) - for toolchain_name, toolchain in zip( - TOOLCHAINS_NAMES, - TOOLCHAINS_VERSIONS, - ) -] +jasmine_test( + name = "test", + data = [":pwa_test_lib"], +) genrule( name = "license", @@ -69,14 +69,14 @@ genrule( cmd = "cp $(execpath //:LICENSE) $@", ) -pkg_npm( - name = "npm_package", +npm_package( + name = "pkg", pkg_deps = [ "//packages/angular_devkit/schematics:package.json", "//packages/schematics/angular:package.json", ], tags = ["release-package"], - deps = [ + deps = RUNTIME_ASSETS + [ ":README.md", ":license", ":pwa", diff --git a/packages/angular/pwa/README.md b/packages/angular/pwa/README.md index 9a2d8181fb8a..26eeb00620a9 100644 --- a/packages/angular/pwa/README.md +++ b/packages/angular/pwa/README.md @@ -1,22 +1,21 @@ # `@angular/pwa` -This is a [schematic](https://angular.io/guide/schematics) for adding -[Progress Web App](https://web.dev/progressive-web-apps/) support to an Angular app. Run the -schematic with the [Angular CLI](https://angular.io/cli): +This is a [schematic](https://angular.dev/tools/cli/schematics) for adding +[Progressive Web App](https://web.dev/progressive-web-apps/) support to an Angular project. Run the +schematic with the [Angular CLI](https://angular.dev/tools/cli): ```shell -ng add @angular/pwa +ng add @angular/pwa --project ``` -This makes a few changes to your project: +Executing the command mentioned above will perform the following actions: -1. Adds [`@angular/service-worker`](https://npmjs.com/@angular/service-worker) as a dependency. +1. Adds [`@angular/service-worker`](https://npmjs.com/@angular/service-worker) as a dependency to your project. 1. Enables service worker builds in the Angular CLI. -1. Imports and registers the service worker in the app module. -1. Adds a [web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest). -1. Updates the `index.html` file to link to the manifest and set theme colors. -1. Adds required icons for the manifest. -1. Creates a config file `ngsw-config.json`, specifying caching behaviors and other settings. +1. Imports and registers the service worker in the application module. +1. Updates the `index.html` file to inlclude a link to add the [manifest.webmanifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) file. +1. Installs icon files to support the installed Progressive Web App (PWA). +1. Creates the service worker configuration file called `ngsw-config.json`, specifying caching behaviors and other settings. -See [Getting started with service workers](https://angular.io/guide/service-worker-getting-started) +See [Getting started with service workers](https://angular.dev/ecosystem/service-workers/getting-started) for more information. diff --git a/packages/angular/pwa/package.json b/packages/angular/pwa/package.json index a670354365f3..4f6fb37b8f99 100644 --- a/packages/angular/pwa/package.json +++ b/packages/angular/pwa/package.json @@ -12,12 +12,12 @@ "save": false }, "dependencies": { - "@angular-devkit/schematics": "0.0.0-PLACEHOLDER", - "@schematics/angular": "0.0.0-PLACEHOLDER", - "parse5-html-rewriting-stream": "7.0.0" + "@angular-devkit/schematics": "workspace:0.0.0-PLACEHOLDER", + "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", + "parse5-html-rewriting-stream": "7.1.0" }, "peerDependencies": { - "@angular/cli": "^16.0.0 || ^16.2.0-next.0" + "@angular/cli": "workspace:^0.0.0-PLACEHOLDER" }, "peerDependenciesMeta": { "@angular/cli": { diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png b/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png index d215b878d32f..5a9a2ccdb34a 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png b/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png index 1393a36677c9..11702cd7bd67 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png b/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png index 2fe7697cbddb..ff4e06b858a9 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png b/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png index df9a5a83a844..afd36a48c681 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png b/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png index e54e8d3eafe5..613ac793e063 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png b/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png index 51ee297df1cb..7574990f2001 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png b/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png index c568de8a76c1..033724e15f54 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png b/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png index 7a71dbc2d953..3090dc2d8f93 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png differ diff --git a/packages/angular/pwa/pwa/files/assets/manifest.webmanifest b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest new file mode 100644 index 000000000000..efa364291a63 --- /dev/null +++ b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest @@ -0,0 +1,57 @@ +{ + "name": "<%= title %>", + "short_name": "<%= title %>", + "display": "standalone", + "scope": "./", + "start_url": "./", + "icons": [ + { + "src": "<%= iconsPath %>/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ] +} diff --git a/packages/angular/pwa/pwa/files/root/manifest.webmanifest b/packages/angular/pwa/pwa/files/root/manifest.webmanifest deleted file mode 100644 index 7d096fae01c5..000000000000 --- a/packages/angular/pwa/pwa/files/root/manifest.webmanifest +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "<%= title %>", - "short_name": "<%= title %>", - "theme_color": "#1976d2", - "background_color": "#fafafa", - "display": "standalone", - "scope": "./", - "start_url": "./", - "icons": [ - { - "src": "assets/icons/icon-72x72.png", - "sizes": "72x72", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-96x96.png", - "sizes": "96x96", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-128x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-144x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-152x152.png", - "sizes": "152x152", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-384x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable any" - } - ] -} diff --git a/packages/angular/pwa/pwa/index.ts b/packages/angular/pwa/pwa/index.ts index 2b4996f0f324..c316ed581695 100644 --- a/packages/angular/pwa/pwa/index.ts +++ b/packages/angular/pwa/pwa/index.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { @@ -19,16 +19,14 @@ import { url, } from '@angular-devkit/schematics'; import { readWorkspace, writeWorkspace } from '@schematics/angular/utility'; -import { posix } from 'path'; -import { Readable, Writable } from 'stream'; +import { posix } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; import { Schema as PwaOptions } from './schema'; function updateIndexFile(path: string): Rule { return async (host: Tree) => { - const buffer = host.read(path); - if (buffer === null) { - throw new SchematicsException(`Could not read index file: ${path}`); - } + const originalContent = host.readText(path); const { RewritingStream } = await loadEsmModule( 'parse5-html-rewriting-stream', @@ -47,7 +45,6 @@ function updateIndexFile(path: string): Rule { rewriter.on('endTag', (endTag) => { if (endTag.tagName === 'head') { rewriter.emitRaw(' \n'); - rewriter.emitRaw(' \n'); } else if (endTag.tagName === 'body' && needsNoScript) { rewriter.emitRaw( ' \n', @@ -57,30 +54,12 @@ function updateIndexFile(path: string): Rule { rewriter.emitEndTag(endTag); }); - return new Promise((resolve) => { - const input = new Readable({ - encoding: 'utf8', - read(): void { - this.push(buffer); - this.push(null); - }, - }); - - const chunks: Array = []; - const output = new Writable({ - write(chunk: string | Buffer, encoding: BufferEncoding, callback: Function): void { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk); - callback(); - }, - final(callback: (error?: Error) => void): void { - const full = Buffer.concat(chunks); - host.overwrite(path, full.toString()); - callback(); - resolve(); - }, - }); - - input.pipe(rewriter).pipe(output); + return pipeline(Readable.from(originalContent), rewriter, async function (source) { + const chunks = []; + for await (const chunk of source) { + chunks.push(Buffer.from(chunk)); + } + host.overwrite(path, Buffer.concat(chunks)); }); }; } @@ -114,35 +93,28 @@ export default function (options: PwaOptions): Rule { const buildTargets = []; const testTargets = []; for (const target of project.targets.values()) { - if (target.builder === '@angular-devkit/build-angular:browser') { + if ( + target.builder === '@angular-devkit/build-angular:browser' || + target.builder === '@angular-devkit/build-angular:application' || + target.builder === '@angular/build:application' + ) { buildTargets.push(target); - } else if (target.builder === '@angular-devkit/build-angular:karma') { + } else if ( + target.builder === '@angular-devkit/build-angular:karma' || + target.builder === '@angular/build:karma' + ) { testTargets.push(target); } } - // Add manifest to asset configuration - const assetEntry = posix.join( - project.sourceRoot ?? posix.join(project.root, 'src'), - 'manifest.webmanifest', - ); - for (const target of [...buildTargets, ...testTargets]) { - if (target.options) { - if (Array.isArray(target.options.assets)) { - target.options.assets.push(assetEntry); - } else { - target.options.assets = [assetEntry]; - } - } else { - target.options = { assets: [assetEntry] }; - } - } - // Find all index.html files in build targets const indexFiles = new Set(); + let checkForDefaultIndex = false; for (const target of buildTargets) { if (typeof target.options?.index === 'string') { indexFiles.add(target.options.index); + } else if (target.options?.index === undefined) { + checkForDefaultIndex = true; } if (!target.configurations) { @@ -152,6 +124,8 @@ export default function (options: PwaOptions): Rule { for (const options of Object.values(target.configurations)) { if (typeof options?.index === 'string') { indexFiles.add(options.index); + } else if (options?.index === undefined) { + checkForDefaultIndex = true; } } } @@ -159,15 +133,48 @@ export default function (options: PwaOptions): Rule { // Setup sources for the assets files to add to the project const sourcePath = project.sourceRoot ?? posix.join(project.root, 'src'); + // Check for a default index file if a configuration path allows for a default usage + if (checkForDefaultIndex) { + const defaultIndexFile = posix.join(sourcePath, 'index.html'); + if (host.exists(defaultIndexFile)) { + indexFiles.add(defaultIndexFile); + } + } + // Setup service worker schematic options const { title, ...swOptions } = options; await writeWorkspace(host, workspace); + let assetsDir = posix.join(sourcePath, 'assets'); + let iconsPath: string; + if (host.exists(assetsDir)) { + // Add manifest to asset configuration + const assetEntry = posix.join( + project.sourceRoot ?? posix.join(project.root, 'src'), + 'manifest.webmanifest', + ); + for (const target of [...buildTargets, ...testTargets]) { + if (target.options) { + if (Array.isArray(target.options.assets)) { + target.options.assets.push(assetEntry); + } else { + target.options.assets = [assetEntry]; + } + } else { + target.options = { assets: [assetEntry] }; + } + } + iconsPath = 'assets'; + } else { + assetsDir = posix.join(project.root, 'public'); + iconsPath = 'icons'; + } return chain([ externalSchematic('@schematics/angular', 'service-worker', swOptions), - mergeWith(apply(url('./files/root'), [template({ ...options }), move(sourcePath)])), - mergeWith(apply(url('./files/assets'), [move(posix.join(sourcePath, 'assets'))])), + mergeWith( + apply(url('./files/assets'), [template({ ...options, iconsPath }), move(assetsDir)]), + ), ...[...indexFiles].map((path) => updateIndexFile(path)), ]); }; diff --git a/packages/angular/pwa/pwa/index_spec.ts b/packages/angular/pwa/pwa/index_spec.ts index 9657b0493b31..42d698ce8f8f 100644 --- a/packages/angular/pwa/pwa/index_spec.ts +++ b/packages/angular/pwa/pwa/index_spec.ts @@ -3,11 +3,11 @@ * Copyright Google LLC All Rights 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 + * found in the LICENSE file at https://angular.dev/license */ import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; -import * as path from 'path'; +import * as path from 'node:path'; import { Schema as PwaOptions } from './schema'; describe('PWA Schematic', () => { @@ -52,122 +52,159 @@ describe('PWA Schematic', () => { ); }); - it('should run the service worker schematic', (done) => { - schematicRunner - .runSchematic('ng-add', defaultOptions, appTree) - - .then((tree) => { - const configText = tree.readContent('/angular.json'); - const config = JSON.parse(configText); - const swFlag = config.projects.bar.architect.build.options.serviceWorker; - expect(swFlag).toEqual(true); - done(); - }, done.fail); + it('should create icon files', async () => { + const dimensions = [72, 96, 128, 144, 152, 192, 384, 512]; + const iconPath = '/projects/bar/public/icons/icon-'; + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + + dimensions.forEach((d) => { + const path = `${iconPath}${d}x${d}.png`; + expect(tree.exists(path)).toBeTrue(); + }); }); - it('should create icon files', (done) => { - const dimensions = [72, 96, 128, 144, 152, 192, 384, 512]; - const iconPath = '/projects/bar/src/assets/icons/icon-'; - schematicRunner - .runSchematic('ng-add', defaultOptions, appTree) - - .then((tree) => { - dimensions.forEach((d) => { - const path = `${iconPath}${d}x${d}.png`; - expect(tree.exists(path)).toEqual(true); - }); - done(); - }, done.fail); + it('should reference the icons in the manifest correctly', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); + for (const icon of manifest.icons) { + expect(icon.src).toMatch(/^icons\/icon-\d+x\d+.png/); + } }); - it('should create a manifest file', (done) => { - schematicRunner - .runSchematic('ng-add', defaultOptions, appTree) + it('should run the service worker schematic', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + const configText = tree.readContent('/angular.json'); + const config = JSON.parse(configText); + const swFlag = config.projects.bar.architect.build.configurations.production.serviceWorker; + + expect(swFlag).toBe('projects/bar/ngsw-config.json'); + }); - .then((tree) => { - expect(tree.exists('/projects/bar/src/manifest.webmanifest')).toEqual(true); - done(); - }, done.fail); + it('should create a manifest file', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + expect(tree.exists('/projects/bar/public/manifest.webmanifest')).toBeTrue(); }); - it('should set the name & short_name in the manifest file', (done) => { - schematicRunner - .runSchematic('ng-add', defaultOptions, appTree) + it('should set the name & short_name in the manifest file', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - .then((tree) => { - const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest'); - const manifest = JSON.parse(manifestText); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); - expect(manifest.name).toEqual(defaultOptions.title); - expect(manifest.short_name).toEqual(defaultOptions.title); - done(); - }, done.fail); + expect(manifest.name).toEqual(defaultOptions.title); + expect(manifest.short_name).toEqual(defaultOptions.title); }); - it('should set the name & short_name in the manifest file when no title provided', (done) => { + it('should set the name & short_name in the manifest file when no title provided', async () => { const options = { ...defaultOptions, title: undefined }; - schematicRunner - .runSchematic('ng-add', options, appTree) + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); - .then((tree) => { - const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest'); - const manifest = JSON.parse(manifestText); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); - expect(manifest.name).toEqual(defaultOptions.project); - expect(manifest.short_name).toEqual(defaultOptions.project); - done(); - }, done.fail); + expect(manifest.name).toEqual(defaultOptions.project); + expect(manifest.short_name).toEqual(defaultOptions.project); }); - it('should update the index file', (done) => { - schematicRunner - .runSchematic('ng-add', defaultOptions, appTree) + it('should update the index file', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + const content = tree.readContent('projects/bar/src/index.html'); - .then((tree) => { - const content = tree.readContent('projects/bar/src/index.html'); - - expect(content).toMatch(//); - expect(content).toMatch(//); - expect(content).toMatch( - /