diff --git a/.bazelignore b/.bazelignore index 284b0692ec13..52ebdac6af4a 100644 --- a/.bazelignore +++ b/.bazelignore @@ -1,3 +1,20 @@ .git dist 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 +packages/angular_devkit/build_webpack/node_modules +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 +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..93c8ddab9fef 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -5.3.0 +7.6.0 diff --git a/.circleci/bazel.common.rc b/.circleci/bazel.common.rc deleted file mode 100644 index f4c1163eb7bb..000000000000 --- a/.circleci/bazel.common.rc +++ /dev/null @@ -1,21 +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 - -# Don't be spammy in the logs -build --noshow_progress - -# 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 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 9b47b93a81f1..000000000000 --- a/.circleci/dynamic_config.yml +++ /dev/null @@ -1,478 +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.13-{{ checksum "yarn.lock" }} -var_1_win: &cache_key_win v1-angular_devkit-win-16.13-{{ checksum "yarn.lock" }} -var_3: &default_nodeversion '16.13' -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+/ - -# All e2e test suites -var_7: &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/docs/2.0/hello-world-windows/#software-pre-installed-in-the-windows-image - image: windows-server-2019-vs2019:stable - -# 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/$%7BCIRCLE_WORKFLOW_ID%7D/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.13 - - run: nvm use 16.13 - - 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 - - lint: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn lint - - validate: - executor: action-executor - steps: - - custom_attach_workspace - - run: - name: Validate Commit Messages - command: > - if [[ -n "${CIRCLE_PR_NUMBER}" ]]; then - yarn ng-dev commit-message validate-range <> <> - else - echo "This build is not over a PR, nothing to do." - fi - - run: - name: Validate Code Formatting - command: yarn -s ng-dev format changed <> --check - - run: - name: Validate NgBot Configuration - command: yarn ng-dev ngbot verify - - run: - name: Validate Circular Dependencies - command: yarn ts-circular-deps:check - - run: yarn -s admin validate - - run: yarn -s check-tooling-setup - - build: - executor: bazel-executor - steps: - - custom_attach_workspace - - setup_bazel_rbe - - run: - name: Bazel Build Packages - command: yarn bazel build //... - - fail_fast - - test: - executor: bazel-executor - parameters: - nodeversion: - type: string - default: *default_nodeversion_major - steps: - - custom_attach_workspace - - setup_bazel_rbe - - when: - # The default nodeversion runs all *excluding* other versions - condition: - equal: [*default_nodeversion_major, << parameters.nodeversion >>] - steps: - - run: - command: yarn bazel test --test_tag_filters=-node18,-node<< parameters.nodeversion >>-broken //packages/... - # 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 - - when: - # Non-default nodeversion runs only that specific nodeversion - condition: - not: - equal: [*default_nodeversion_major, << parameters.nodeversion >>] - steps: - - run: - command: yarn bazel test --test_tag_filters=node<< parameters.nodeversion >>,-node<< parameters.nodeversion >>-broken //packages/... - # 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 - - 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 - - publish_artifacts: - executor: action-executor - resource_class: medium - environment: - steps: - - custom_attach_workspace - - run: - name: Create artifacts for packages - command: yarn ng-dev release build - - run: - name: Copy tarballs to folder - command: | - mkdir -p dist/artifacts/ - cp dist/releases/*.tgz dist/artifacts/ - - store_artifacts: - path: dist/artifacts/ - destination: angular - - # Windows jobs - e2e-cli-win: - executor: windows-executor - parallelism: 12 - steps: - - checkout - - rebase_pr_win - - setup_windows - - 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 - - lint: - requires: - - setup - - validate: - requires: - - setup - - # These jobs only really depend on Setup, but the build job is very quick to run (~35s) and - # will catch any build errors before proceeding to the more lengthy and resource intensive - - test: - name: test-node<< matrix.nodeversion >> - matrix: - parameters: - # Disable running this on Node.js 18 for now. This causes failures when using RBE. - # Example: `GLIBC_2.28' not found. - # Likely this is caused by outdated Ubuntu image https://github.com/angular/dev-infra/blob/main/bazel/remote-execution/BUILD.bazel#L21 - nodeversion: ['16'] - requires: - - build - - # 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_release_branches - requires: - - setup - - e2e-cli - - - publish_artifacts: - <<: *only_pull_requests - requires: - - build 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..524eb1c3a684 --- /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@9a3e28a515bf51cd2ecfd5f4d5b17613845e6f44 + 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 8e3687df4576..0753d9c05b1e 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@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@d28e5f71d00e1d55abafea9eea315bcccd3bc17c + - uses: angular/dev-infra/github-actions/branch-manager@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..d988709a535f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,228 @@ +name: CI + +on: + push: + branches: + - main + - '[0-9]+.[0-9]+.x' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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 tooling setup + run: pnpm check-tooling-setup + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + 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: + os: [ubuntu-latest] + node: [20, 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + with: + allow_windows_rbe: true + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.${{ matrix.subset }}_node${{ matrix.node }} + env: + E2E_SHARD_TOTAL: 6 + E2E_SHARD_INDEX: ${{ matrix.shard }} + + e2e-package-managers: + needs: test + strategy: + fail-fast: false + matrix: + 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + 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 + 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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..c439b667fb5a --- /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@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 + with: + languages: javascript-typescript + build-mode: none + config-file: .github/codeql/config.yml + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 + with: + category: '/language:javascript-typescript' diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index 9e8ba5952f89..4e7ca92ff208 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@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 - - uses: angular/dev-infra/github-actions/commit-message-based-labels@d28e5f71d00e1d55abafea9eea315bcccd3bc17c + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/commit-message-based-labels@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} post_approval_changes: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 - - uses: angular/dev-infra/github-actions/post-approval-changes@d28e5f71d00e1d55abafea9eea315bcccd3bc17c + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: angular/dev-infra/github-actions/post-approval-changes@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/feature-requests.yml b/.github/workflows/feature-requests.yml index 2b0957d66bf2..088bf0e9bb56 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@d28e5f71d00e1d55abafea9eea315bcccd3bc17c + - uses: angular/dev-infra/github-actions/feature-request@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 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..a21fc698a58e --- /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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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..6e4fddcb8973 --- /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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@a4538b2474d3c551f0217c3d1f5f3a99cf4f8eb7 + - 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 ddf47069eca5..c96366b92885 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,12 +25,12 @@ jobs: steps: - name: 'Checkout code' - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: 'Run analysis' - uses: ossf/scorecard-action@80e868c13c90f172d68d1f4501dee99e2479f7af # v2.1.3 + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 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@d186a2a36cc67bfa1b860e6170d37fb9634742c7 # v2.2.11 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 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..2f360047b278 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { 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 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..a48c19fd1baf --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +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. + */ +const BUILD_TIMEOUT = 10_000; + +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, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset.txt').content.toContain('foo'); + + await harness.writeFile('public/asset.txt', 'bar'); + break; + case 1: + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset.txt').content.toContain('bar'); + break; + } + }), + take(2), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(2); + }); + + 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, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + 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'); + break; + case 1: + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset-one.txt').toExist(); + harness.expectFile('dist/browser/asset-two.txt').toNotExist(); + break; + } + }), + take(2), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(2); + }); + }); +}); 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..a252a0580d0b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_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 { concatMap, count, take, timeout } from 'rxjs'; +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 'https://github.com/a';"); + await harness.writeFile('src/app/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + const buildCount = await harness + .execute() + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + expect(result?.success).toBe(true); + + switch (index) { + case 0: + 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; }', + ); + break; + case 1: + 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; }', + ); + break; + case 2: + 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'); + + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + } + }); +}); 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..196cbf4e6b5d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -0,0 +1,373 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { concatMap, count, take, timeout } from 'rxjs'; +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 { } + `, + ); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + 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; + } + `, + ); + + break; + case 1: + 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'); + + break; + case 2: + 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); + + break; + case 3: + 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'); + + break; + case 4: + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(typeErrorText), + }), + ); + + break; + } + }), + take(5), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(5); + }); + + it('detects cumulative block syntax errors', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ logs }, index) => { + switch (index) { + case 0: + // Add invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@one'); + + break; + case 1: + 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'); + + break; + case 2: + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('@one'), + }), + ); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@two'); + + break; + case 3: + 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'); + + break; + case 4: + 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

'); + + break; + case 5: + 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'), + }), + ); + + break; + } + }), + take(6), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(6); + }); + + it('recovers from component stylesheet error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot: false, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + await harness.writeFile('src/app/app.component.css', 'invalid-css-content'); + + break; + case 1: + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('invalid-css-content'), + }), + ); + + await harness.writeFile('src/app/app.component.css', 'p { color: green }'); + + break; + case 2: + 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}'); + + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + + it('recovers from component template error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + // Missing ending `>` on the div will cause an error + await harness.appendToFile('src/app/app.component.html', '
Hello, world!({ + message: jasmine.stringMatching('Unexpected character "EOF"'), + }), + ); + + await harness.appendToFile('src/app/app.component.html', '>'); + + break; + case 2: + 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
'); + + break; + case 3: + 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'); + + break; + } + }), + take(4), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(4); + }); + }); +}); 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..ca88f94e5b63 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +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 { } + `, + ); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + 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'); + + break; + case 1: + // 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';`, ''), + ); + + break; + case 2: + 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, + ); + + break; + case 3: + 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'), + ); + + break; + case 4: + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-B'); + + break; + } + }), + take(5), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(5); + }); + }); +}); 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..e58b2e031a90 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +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 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 'https://github.com/a';"); + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + 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; }', + ); + break; + case 1: + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + break; + case 2: + 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'); + + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + + 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 'https://github.com/a';"); + await harness.writeFile('src/a.scss', 'invalid-invalid-invalid\\nh1 { color: $primary; }'); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + break; + case 1: + 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; }'); + break; + case 2: + 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'); + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + + 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 'https://github.com/a';"); + await harness.writeFile('src/other.scss', "@import 'https://github.com/a'; h1 { color: green; }"); + await harness.writeFile('src/a.scss', 'invalid-invalid-invalid\\nh1 { color: $primary; }'); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + break; + case 1: + 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; }'); + break; + case 2: + 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'); + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + }); +}); 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..df9dbc6f0c93 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +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, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + 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'), + ); + break; + case 1: + 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"'), + ); + break; + case 2: + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + }); +}); 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..421e51f99f5b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { concatMap, count, take, timeout } from 'rxjs'; +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; + +/** + * 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' }); + } + `, + ); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + 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`); + + break; + case 1: + 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'); + + break; + case 2: + 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); + + break; + case 3: + 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'); + + break; + case 4: + 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); + + break; + } + }), + take(5), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(5); + }); + }); +}); 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("https://github.com/~/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 "https://github.com/a.css"; + `, + ); + await harness.writeFile( + 'src/a.css', + ` + .a { + background-image: url("https://github.com/~/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 "https://github.com/a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("https://github.com/~/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("https://github.com/^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 "https://github.com/a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("https://github.com/^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(https://github.com/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 'https://github.com/b'; + .a { + background-image: url(https://github.com/$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(https://github.com/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("https://github.com/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(https://github.com/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("https://github.com/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(https://github.com/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("https://github.com/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 'https://github.com/b'; + .a { + background-image: url(https://github.com/$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("https://github.com/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 'https://github.com/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("https://github.com/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 'https://github.com/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("https://github.com/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 'https://github.com/b'; + .a { + background-image: url(https://github.com/#{$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("https://github.com/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 'https://github.com/b'; + .a { + $asset: my-function-url('https://github.com/logo'); + background-image: url(https://github.com/$asset) + } + `, + 'src/theme/b.scss': `@function my-function-url(https://github.com/$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("https://github.com/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("https://github.com/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(https://github.com/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(https://github.com/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/browser-esbuild/tests/behavior/typescript-path-mapping_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts similarity index 90% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/behavior/typescript-path-mapping_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts index 42f041da77b8..41539df239f2 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/behavior/typescript-path-mapping_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.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 { buildEsbuildBrowser } from '../../index'; -import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; -describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => { +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Behavior: "TypeScript Path Mapping"', () => { it('should resolve TS files when imported with a path mapping', async () => { // Change main module import to use path mapping @@ -103,7 +103,7 @@ describeBuilder(buildEsbuildBrowser, BROWSER_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..c8dd39bfae5d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_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 type { logging } from '@angular-devkit/core'; +import { concatMap, count, firstValueFrom, take, timeout } from 'rxjs'; +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, + }); + + const buildCount = await firstValueFrom( + harness.execute({ outputLogsOnFailure: false }).pipe( + timeout(30_000), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + expect(result?.success).toBeTrue(); + + // Add valid code + await harness.appendToFile('src/lazy.ts', `console.log('foo');`); + + break; + case 1: + expect(result?.success).toBeTrue(); + + // Update type of 'foo' to invalid (number -> string) + await harness.writeFile('src/lazy.ts', `export const foo: string = 1;`); + + break; + case 2: + 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";`); + + break; + case 3: + expect(result?.success).toBeTrue(); + + break; + } + }), + take(4), + count(), + ), + ); + + expect(buildCount).toBe(4); + }); + }); +}); 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..65f0540f2d1b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.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 { concatMap, count, take, timeout } from 'rxjs'; +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, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(30_000), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + expect(result?.success).toBeTrue(); + // Touch a file without doing any changes. + await harness.modifyFile('src/app/app.component.ts', (content) => content); + break; + case 1: + expect(result?.success).toBeTrue(); + await harness.removeFile('src/app/app.component.ts'); + break; + case 2: + expect(result?.success).toBeFalse(); + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + } + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/behavior/typescript-resolve-json_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts similarity index 91% rename from packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/behavior/typescript-resolve-json_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts index 627b1a5560d9..e7d060de1262 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/tests/behavior/typescript-resolve-json_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.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 { buildEsbuildBrowser } from '../../index'; -import { BASE_OPTIONS, BROWSER_BUILDER_INFO, describeBuilder } from '../setup'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; -describeBuilder(buildEsbuildBrowser, BROWSER_BUILDER_INFO, (harness) => { +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Behavior: "TypeScript JSON module resolution"', () => { it('should resolve JSON files when imported with resolveJsonModule enabled', async () => { await harness.writeFiles({ 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/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts new file mode 100644 index 000000000000..ad29d985f712 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "allowedCommonJsDependencies"', () => { + describe('given option is not set', () => { + for (const aot of [true, false]) { + it(`should show warning when depending on a Common JS bundle in ${ + aot ? 'AOT' : 'JIT' + } Mode`, async () => { + // Add a Common JS dependency + await harness.appendToFile('src/app/app.component.ts', `import 'buffer';`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + aot, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + /Module 'buffer' used by 'src\/app\/app\.component\.ts' is not ESM/, + ), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('base64-js'), + }), + 'Should not warn on transitive CommonJS packages which parent is also CommonJS.', + ); + }); + } + }); + + it('should not show warning when depending on a Common JS bundle which is allowed', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'buffer'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['buffer', 'base64-js', 'ieee754'], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + + it('should not show warning when all dependencies are allowed by wildcard', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import '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( + 'src/app/app.component.ts', + ` + import 'zone.js'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + + it(`should not show warning when importing non global local data '@angular/common/locale/fr'`, async () => { + await harness.appendToFile( + 'src/app/app.component.ts', + `import '@angular/common/locales/fr';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + 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 in JIT for templateUrl and styleUrl when using paths', async () => { + await harness.modifyFile('tsconfig.json', (content) => { + return content.replace( + /"baseUrl": ".\/",/, + ` + "baseUrl": "./", + "paths": { + "@app/*": [ + "src/app/*" + ] + }, + `, + ); + }); + + await harness.modifyFile('src/app/app.module.ts', (content) => + content.replace('./app.component', '@app/app.component'), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + aot: false, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + + it('should not show warning for relative imports', async () => { + await harness.appendToFile('src/main.ts', `import './abc';`); + await harness.writeFile('src/abc.ts', 'console.log("abc");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/CommonJS or AMD dependencies/), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts new file mode 100644 index 000000000000..9c8384b29efc --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const appShellRouteFiles: Record = { + 'src/styles.css': `p { color: #000 }`, + 'src/app/app-shell/app-shell.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-app-shell', + standalone: false, + styles: ['div { color: #fff; }'], + template: '

app-shell works!

', + }) + export class AppShellComponent {}`, + 'src/main.server.ts': ` + import { AppServerModule } from './app/app.module.server'; + export default AppServerModule; + `, + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppRoutingModule } from './app-routing.module'; + import { AppComponent } from './app.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + RouterModule + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.module.server.ts': ` + import { NgModule } from '@angular/core'; + import { ServerModule } from '@angular/platform-server'; + + import { AppModule } from './app.module'; + import { AppComponent } from './app.component'; + import { Routes, RouterModule } from '@angular/router'; + import { AppShellComponent } from './app-shell/app-shell.component'; + + const routes: Routes = [ { path: 'shell', component: AppShellComponent }]; + + @NgModule({ + imports: [ + AppModule, + ServerModule, + RouterModule.forRoot(routes), + ], + bootstrap: [AppComponent], + declarations: [AppShellComponent], + }) + export class AppServerModule {} + `, + 'src/main.ts': ` + import { platformBrowser } from '@angular/platform-browser'; + import { AppModule } from './app/app.module'; + + platformBrowser().bootstrapModule(AppModule).catch(err => console.log(err)); + `, + 'src/app/app-routing.module.ts': ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + + const routes: Routes = []; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] + }) + export class AppRoutingModule { } + `, + 'src/app/app.component.html': ``, +}; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles(appShellRouteFiles); + }); + + describe('Option: "appShell"', () => { + it('renders the application shell', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + appShell: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + const indexFileContent = harness.expectFile('dist/browser/index.html').content; + indexFileContent.toContain('app-shell works!'); + }); + + it('critical CSS is inlined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + appShell: true, + styles: ['src/styles.css'], + optimization: { + styles: { + minify: true, + inlineCritical: true, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const indexFileContent = harness.expectFile('dist/browser/index.html').content; + indexFileContent.toContain('app-shell works!'); + indexFileContent.toContain('p{color:#000}'); + indexFileContent.toContain( + ``, + ); + }); + + it('applies CSP nonce to critical CSS', async () => { + await harness.modifyFile('src/index.html', (content) => + content.replace(/`, + ); + indexFileContent.toContain('`); + }); + + it('should inline critical css when using deployUrl', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + deployUrl: 'http://cdn.com/', + }); + + const { content } = await inlineFontsProcessor.process(getContent('http://cdn.com/'), { + outputPath: '/dist/', + }); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should compress inline critical css when minify is enabled', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + minify: true, + }); + + const { content } = await inlineFontsProcessor.process(getContent(''), { + outputPath: '/dist/', + }); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).toContain(''); + }); + + it(`should process the inline 'onload' handlers if a 'autoCsp' is true`, async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + autoCsp: true, + }); + + const { content } = await inlineCssProcessor.process(getContent(''), { + outputPath: '/dist/', + }); + + expect(content).toContain( + '', + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should process the inline `onload` handlers if a CSP nonce is specified', async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + }); + + const { content } = await inlineCssProcessor.process( + getContent('', ''), + { + outputPath: '/dist/', + }, + ); + + expect(content).toContain( + '', + ); + expect(content).toContain( + '', + ); + // Nonces shouldn't be added inside the `noscript` tags. + expect(content).toContain(''); + expect(content).toContain(' + + + + + `); + + 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..b0882187d0c2 --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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))) + .default; + 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') { + // 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))) + .default; + break; + } + + throw e; + } + } + + 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/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..4d1459e221c2 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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: FilesMapping[] = [ + { + path: `${publicPath}${fileName}`, + dynamicImport: false, + }, + ]; + + for (const { kind, external, path } of imports) { + if ( + external || + initialFiles.has(path) || + (kind !== 'dynamic-import' && kind !== 'import-statement') + ) { + continue; + } + + importedPaths.push({ + path: `${publicPath}${path}`, + dynamicImport: kind === 'dynamic-import', + }); + } + + 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..a8a42c7c941a --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -0,0 +1,406 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of 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 { 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(destination.replace(/\\/g, posix.sep))] = 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 79% rename from packages/angular_devkit/build_angular/src/utils/service-worker.ts rename to packages/angular/build/src/utils/service-worker.ts index 17d564bc4212..f9d5c14d27fd 100644 --- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts +++ b/packages/angular/build/src/utils/service-worker.ts @@ -3,19 +3,23 @@ * Copyright Google LLC All Rights 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'; 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), []); @@ -63,15 +67,20 @@ class CliFilesystem implements Filesystem { } class ResultFilesystem implements Filesystem { - private readonly fileReaders = new Map Promise>(); + 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.text); + if (file.type === BuildOutputFileType.Media || file.type === BuildOutputFileType.Browser) { + this.fileReaders.set('/' + file.path.replace(/\\/g, '/'), async () => file.contents); + } } for (const file of assetFiles) { this.fileReaders.set('/' + file.destination.replace(/\\/g, '/'), () => - fsPromises.readFile(file.source, 'utf-8'), + fsPromises.readFile(file.source), ); } } @@ -84,19 +93,25 @@ class ResultFilesystem implements Filesystem { return [...this.fileReaders.keys()]; } - read(file: string): Promise { + async read(file: string): Promise { const reader = this.fileReaders.get(file); if (reader === undefined) { throw new Error('File does not exist.'); } + const contents = await reader(); - return reader(); + return Buffer.from(contents.buffer, contents.byteOffset, contents.byteLength).toString('utf-8'); } async hash(file: string): Promise { + const reader = this.fileReaders.get(file); + if (reader === undefined) { + throw new Error('File does not exist.'); + } + return crypto .createHash('sha1') - .update(await this.read(file)) + .update(await reader()) .digest('hex'); } @@ -111,7 +126,7 @@ export async function augmentAppWithServiceWorker( outputPath: string, baseHref: string, ngswConfigPath?: string, - inputputFileSystem = fsPromises, + inputFileSystem = fsPromises, outputFileSystem = fsPromises, ): Promise { // Determine the configuration file path @@ -122,7 +137,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); @@ -146,11 +161,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); @@ -165,14 +176,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..ca35f7edb46b --- /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: 1000, + // Web containers do not support transferable objects with receiveOnMessagePort which + // is used when the Atomics based wait loop is enable. + useAtomics: !process.versions.webcontainer, + 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 1a555222c97e..efa8d5f601f0 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -1,111 +1,80 @@ # 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 = ["**/*.ts"], + include = [ + "lib/**/*.ts", + "src/**/*.ts", + ], exclude = [ "**/*_spec.ts", - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", ], ) + [ - # @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/**/*", - "**/*.json", - "**/*.md", - ], - exclude = [ - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", - "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/@yarnpkg/lockfile", + ":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/@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/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/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( @@ -126,7 +95,7 @@ ts_json_schema( src = "src/commands/update/schematic/schema.json", ) -ts_library( +ts_project( name = "angular-cli_test_lib", testonly = True, srcs = glob( @@ -138,25 +107,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 = "angular-cli_test", + data = [":angular-cli_test_lib"], +) genrule( name = "license", @@ -165,8 +128,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", @@ -175,13 +138,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..1282f906aef2 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. The cache does not work +// well with Bazel's hermeticity requirements. +if (!process.env['RUNFILES']) { + 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 f5175ea22d29..392578c684cb 100755 --- a/packages/angular/cli/bin/ng.js +++ b/packages/angular/cli/bin/ng.js @@ -4,13 +4,23 @@ * Copyright Google LLC All Rights 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 */ /* eslint-disable import/no-unassigned-import */ 'use strict'; +const path = require('path'); + +// Error if the external CLI appears to be used inside a google3 context. +if (process.cwd().split(path.sep).includes('google3')) { + console.error( + 'This is the external Angular CLI, but you appear to be running in google3. There is a separate, internal version of the CLI which should be used instead. See http://go/angular/cli.', + ); + process.exit(); +} + // Provide a title to the process in `ps`. // Due to an obscure Mac bug, do not start this title with any symbol. try { @@ -41,22 +51,17 @@ if (version[0] % 2 === 1) { 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] < 13) || - (version[0] === 18 && version[1] < 10) -) { - // Error and exit if less than 14.20, 16.13 or 18.10 +} else if (version[0] < 20 || (version[0] === 20 && version[1] < 11)) { + // Error and exit if less than 20.11 console.error( 'Node.js version ' + process.version + ' detected.\n' + - 'The Angular CLI requires a minimum Node.js version of either v14.20, v16.13 or v18.10.\n\n' + + 'The Angular CLI requires a minimum Node.js version of v20.11.\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..a2566853dfc7 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, 11] 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 44e0339f43b8..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,15 +402,25 @@ "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", "@angular-devkit/build-angular:browser-esbuild", "@angular-devkit/build-angular:dev-server", "@angular-devkit/build-angular:extract-i18n", "@angular-devkit/build-angular:karma", - "@angular-devkit/build-angular:protractor", + "@angular-devkit/build-angular:ng-packagr", + "@angular-devkit/build-angular:prerender", + "@angular-devkit/build-angular:jest", + "@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" ] } }, @@ -384,6 +442,50 @@ "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, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular: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, @@ -450,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, @@ -472,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, @@ -494,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, @@ -521,19 +711,85 @@ "additionalProperties": false, "properties": { "builder": { - "const": "@angular-devkit/build-angular:protractor" + "const": "@angular-devkit/build-angular:jest" + }, + "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/jest/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/jest/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "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/protractor/schema.json" + "$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json" }, "configurations": { "type": "object", "additionalProperties": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/protractor/schema.json" + "$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/ssr-dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" } } } @@ -581,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 141d1c55b60b..5493ad2531ab 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -22,29 +22,29 @@ }, "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.5.0", + "@listr2/prompt-adapter-inquirer": "2.0.21", + "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.3", - "ini": "4.0.0", - "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.1.1", - "resolve": "1.22.1", - "semver": "7.3.8", - "symbol-observable": "4.0.0", - "yargs": "17.7.1" + "ini": "5.0.0", + "jsonc-parser": "3.3.1", + "listr2": "8.3.2", + "npm-package-arg": "12.0.2", + "npm-pick-manifest": "10.0.0", + "pacote": "20.0.0", + "resolve": "1.22.10", + "semver": "7.7.1", + "yargs": "17.7.2" }, "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 39848e222faf..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, @@ -182,6 +185,9 @@ export class AnalyticsCollector { }, }, (response) => { + // The below is needed as otherwise the response will never close which will cause the CLI not to terminate. + response.on('data', () => {}); + if (response.statusCode !== 200 && response.statusCode !== 204) { reject( new Error(`Analytics reporting failed with status code: ${response.statusCode}.`), 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..a97815eec027 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -3,20 +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, schema, strings } from '@angular-devkit/core'; -import { readFileSync } from 'fs'; -import * as path from 'path'; +import { logging, schema } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; import yargs, { - Arguments, ArgumentsCamelCase, Argv, CamelCaseKey, - PositionalOptions, CommandModule as YargsCommandModule, - Options as YargsOptions, } from 'yargs'; import { Parser as yargsParser } from 'yargs/helpers'; import { getAnalyticsUserId } from '../analytics/analytics'; @@ -26,15 +23,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 +45,7 @@ export interface CommandContext { globalConfiguration: AngularWorkspace; logger: logging.Logger; packageManager: PackageManagerUtils; + /** Arguments parsed in free-from without parser configuration. */ args: { positional: string[]; @@ -63,10 +63,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 +179,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 +191,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; diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index 36c4b308ecc6..2ad2c07b1eeb 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -3,62 +3,32 @@ * Copyright Google LLC All Rights 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 yargs from 'yargs'; import { Parser } from 'yargs/helpers'; -import { AddCommandModule } from '../commands/add/cli'; -import { AnalyticsCommandModule } from '../commands/analytics/cli'; -import { BuildCommandModule } from '../commands/build/cli'; -import { CacheCommandModule } from '../commands/cache/cli'; -import { CompletionCommandModule } from '../commands/completion/cli'; -import { ConfigCommandModule } from '../commands/config/cli'; -import { DeployCommandModule } from '../commands/deploy/cli'; -import { DocCommandModule } from '../commands/doc/cli'; -import { E2eCommandModule } from '../commands/e2e/cli'; -import { ExtractI18nCommandModule } from '../commands/extract-i18n/cli'; -import { GenerateCommandModule } from '../commands/generate/cli'; -import { LintCommandModule } from '../commands/lint/cli'; -import { AwesomeCommandModule } from '../commands/make-this-awesome/cli'; -import { NewCommandModule } from '../commands/new/cli'; -import { RunCommandModule } from '../commands/run/cli'; -import { ServeCommandModule } from '../commands/serve/cli'; -import { TestCommandModule } from '../commands/test/cli'; -import { UpdateCommandModule } from '../commands/update/cli'; -import { VersionCommandModule } from '../commands/version/cli'; +import { + CommandConfig, + CommandNames, + RootCommands, + RootCommandsAliases, +} from '../commands/command-config'; 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 { addCommandModuleToYargs, demandCommandFailureMessage } from './utilities/command'; +import { + CommandModuleConstructor, + addCommandModuleToYargs, + demandCommandFailureMessage, +} from './utilities/command'; import { jsonHelpUsage } from './utilities/json-help'; import { normalizeOptionsMiddleware } from './utilities/normalize-options-middleware'; -const COMMANDS = [ - VersionCommandModule, - DocCommandModule, - AwesomeCommandModule, - ConfigCommandModule, - AnalyticsCommandModule, - AddCommandModule, - GenerateCommandModule, - BuildCommandModule, - E2eCommandModule, - TestCommandModule, - ServeCommandModule, - ExtractI18nCommandModule, - DeployCommandModule, - LintCommandModule, - NewCommandModule, - UpdateCommandModule, - RunCommandModule, - CacheCommandModule, - CompletionCommandModule, -].sort(); // Will be sorted by class name. - const yargsParser = Parser as unknown as typeof Parser.default; export async function runCommand(args: string[], logger: logging.Logger): Promise { @@ -111,7 +81,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis }; let localYargs = yargs(args); - for (const CommandModule of COMMANDS) { + for (const CommandModule of await getCommandsToRegister(positional[0])) { localYargs = addCommandModuleToYargs(localYargs, CommandModule, context); } @@ -121,6 +91,11 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis usageInstance.help = () => jsonHelpUsage(); } + // 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 @@ -149,7 +124,7 @@ 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) @@ -166,5 +141,25 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis .wrap(yargs.terminalWidth()) .parseAsync(); - return process.exitCode ?? 0; + return +(process.exitCode ?? 0); +} + +/** + * Get the commands that need to be registered. + * @returns One or more command factories that needs to be registered. + */ +async function getCommandsToRegister( + commandName: string | number, +): Promise { + const commands: CommandConfig[] = []; + if (commandName in RootCommands) { + commands.push(RootCommands[commandName as CommandNames]); + } else if (commandName in RootCommandsAliases) { + commands.push(RootCommandsAliases[commandName]); + } else { + // Unknown command, register every possible command. + Object.values(RootCommands).forEach((c) => commands.push(c)); + } + + return Promise.all(commands.map((command) => command.factory().then((m) => m.default))); } 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 44631afa7988..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, tags } 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 3c3a1fa566ad..04a88c1f7113 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'; @@ -16,13 +16,15 @@ import { } from '../command-module'; export const demandCommandFailureMessage = `You need to specify a command before moving on. Use '--help' to view the available commands.`; +export type CommandModuleConstructor = Partial & { + new (context: CommandContext): Partial & CommandModule; +}; -export function addCommandModuleToYargs< - T extends object, - U extends Partial & { - new (context: CommandContext): Partial & CommandModule; - }, ->(localYargs: Argv, commandModule: U, context: CommandContext): Argv { +export function addCommandModuleToYargs( + localYargs: Argv, + commandModule: U, + context: CommandContext, +): Argv { const cmd = new commandModule(context); const { 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..6e673804ed84 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-help.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-help.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 yargs from 'yargs'; 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..a46b06646197 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.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 { json } from '@angular-devkit/core'; -import yargs from 'yargs'; +import { json, strings } from '@angular-devkit/core'; +import yargs, { Arguments, Argv, PositionalOptions, Options as YargsOptions } from 'yargs'; /** * An option description. @@ -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..709f9e5a7c67 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,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 * as yargs from 'yargs'; 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 16bcc2d9f30a..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 class AddCommandModule +export default class AddCommandModule extends SchematicsCommandModule implements CommandModuleImplementation { @@ -91,8 +100,12 @@ export class AddCommandModule // `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 class AddCommandModule } 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 class AddCommandModule 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 class AddCommandModule } } - 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 class AddCommandModule 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 class AddCommandModule }); } 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 bdba1ccafd11..56841a95bd6b 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'; @@ -24,7 +24,10 @@ import { AnalyticsPromptModule, } from './settings/cli'; -export class AnalyticsCommandModule extends CommandModule implements CommandModuleImplementation { +export default class AnalyticsCommandModule + extends CommandModule + implements CommandModuleImplementation +{ command = 'analytics'; describe = 'Configures the gathering of Angular CLI usage metrics.'; longDescriptionPath = join(__dirname, 'long-description.md'); 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 434ff4f22f84..365420ca3734 100644 --- a/packages/angular/cli/src/commands/build/cli.ts +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -3,20 +3,21 @@ * Copyright Google LLC All Rights 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'; -export class BuildCommandModule +export default class BuildCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { multiTarget = false; command = 'build [project]'; - aliases = ['b']; + aliases = RootCommands['build'].aliases; describe = 'Compiles an Angular application or library into an output directory named dist/ at the given output path.'; longDescriptionPath = join(__dirname, 'long-description.md'); 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 f30c4acd3b81..97f3ed60f007 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, @@ -22,7 +22,10 @@ import { CacheCleanModule } from './clean/cli'; import { CacheInfoCommandModule } from './info/cli'; import { CacheDisableModule, CacheEnableModule } from './settings/cli'; -export class CacheCommandModule extends CommandModule implements CommandModuleImplementation { +export default class CacheCommandModule + extends CommandModule + implements CommandModuleImplementation +{ command = 'cache'; describe = 'Configure persistent disk cache and retrieve cache statistics.'; longDescriptionPath = join(__dirname, 'long-description.md'); 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 new file mode 100644 index 000000000000..cd048cbb2240 --- /dev/null +++ b/packages/angular/cli/src/commands/command-config.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { CommandModuleConstructor } from '../command-builder/utilities/command'; + +export type CommandNames = + | 'add' + | 'analytics' + | 'build' + | 'cache' + | 'completion' + | 'config' + | 'deploy' + | 'e2e' + | 'extract-i18n' + | 'generate' + | 'lint' + | 'make-this-awesome' + | 'new' + | 'run' + | 'serve' + | 'test' + | 'update' + | 'version'; + +export interface CommandConfig { + aliases?: string[]; + factory: () => Promise<{ default: CommandModuleConstructor }>; +} + +export const RootCommands: Record< + /* Command */ CommandNames & string, + /* Command Config */ CommandConfig +> = { + 'add': { + factory: () => import('./add/cli'), + }, + 'analytics': { + factory: () => import('./analytics/cli'), + }, + 'build': { + factory: () => import('./build/cli'), + aliases: ['b'], + }, + 'cache': { + factory: () => import('./cache/cli'), + }, + 'completion': { + factory: () => import('./completion/cli'), + }, + 'config': { + factory: () => import('./config/cli'), + }, + 'deploy': { + factory: () => import('./deploy/cli'), + }, + + 'e2e': { + factory: () => import('./e2e/cli'), + aliases: ['e'], + }, + 'extract-i18n': { + factory: () => import('./extract-i18n/cli'), + }, + 'generate': { + factory: () => import('./generate/cli'), + aliases: ['g'], + }, + 'lint': { + factory: () => import('./lint/cli'), + }, + 'make-this-awesome': { + factory: () => import('./make-this-awesome/cli'), + }, + 'new': { + factory: () => import('./new/cli'), + aliases: ['n'], + }, + 'run': { + factory: () => import('./run/cli'), + }, + 'serve': { + factory: () => import('./serve/cli'), + aliases: ['dev', 's'], + }, + 'test': { + factory: () => import('./test/cli'), + aliases: ['t'], + }, + 'update': { + factory: () => import('./update/cli'), + }, + 'version': { + factory: () => import('./version/cli'), + aliases: ['v'], + }, +}; + +export const RootCommandsAliases = Object.values(RootCommands).reduce( + (prev, current) => { + current.aliases?.forEach((alias) => { + prev[alias] = current; + }); + + 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 f6166c28b325..ebbf4ffc992f 100644 --- a/packages/angular/cli/src/commands/completion/cli.ts +++ b/packages/angular/cli/src/commands/completion/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 yargs, { Argv } from 'yargs'; import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; import { addCommandModuleToYargs } from '../../command-builder/utilities/command'; @@ -14,7 +14,10 @@ import { colors } from '../../utilities/color'; import { hasGlobalCliInstall, initializeAutocomplete } from '../../utilities/completion'; import { assertIsError } from '../../utilities/error'; -export class CompletionCommandModule extends CommandModule implements CommandModuleImplementation { +export default class CompletionCommandModule + extends CommandModule + implements CommandModuleImplementation +{ command = 'completion'; describe = 'Set up Angular CLI autocompletion for your terminal.'; longDescriptionPath = join(__dirname, 'long-description.md'); @@ -48,7 +51,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', ); } 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 5977d8cfa02d..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, @@ -25,7 +25,7 @@ interface ConfigCommandArgs { global?: boolean; } -export class ConfigCommandModule +export default class ConfigCommandModule extends CommandModule implements CommandModuleImplementation { @@ -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 e335b0633e31..947dc90af2d4 100644 --- a/packages/angular/cli/src/commands/deploy/cli.ts +++ b/packages/angular/cli/src/commands/deploy/cli.ts @@ -3,19 +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 { 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'; -export class DeployCommandModule +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 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 73b7826fc066..000000000000 --- a/packages/angular/cli/src/commands/doc/cli.ts +++ /dev/null @@ -1,90 +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'; - -interface DocCommandArgs { - keyword: string; - search?: boolean; - version?: string; -} - -export class DocCommandModule - extends CommandModule - implements CommandModuleImplementation -{ - command = 'doc '; - aliases = ['d']; - 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: - 'Contains 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 2aecfb3ac5a6..85d9aab173a0 100644 --- a/packages/angular/cli/src/commands/e2e/cli.ts +++ b/packages/angular/cli/src/commands/e2e/cli.ts @@ -3,18 +3,23 @@ * Copyright Google LLC All Rights 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'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; -export class E2eCommandModule +export default class E2eCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Playwright', + value: 'playwright-ng-schematics', + }, { name: 'Cypress', value: '@cypress/schematic', @@ -27,11 +32,15 @@ export class E2eCommandModule name: 'WebdriverIO', value: '@wdio/schematics', }, + { + name: 'Puppeteer', + value: '@puppeteer/ng-schematics', + }, ]; multiTarget = true; command = 'e2e [project]'; - aliases = ['e']; + aliases = RootCommands['e2e'].aliases; describe = 'Builds and serves an Angular application, then runs end-to-end tests.'; longDescriptionPath?: string; } diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts index 5283204f4e9b..4f3dea2d8e7e 100644 --- a/packages/angular/cli/src/commands/extract-i18n/cli.ts +++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts @@ -3,13 +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 { 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'; -export class ExtractI18nCommandModule +export default class ExtractI18nCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { @@ -17,4 +20,38 @@ export 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 c4029e5a7e1f..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'; @@ -25,17 +25,18 @@ import { } from '../../command-builder/schematics-command-module'; import { demandCommandFailureMessage } from '../../command-builder/utilities/command'; import { Option } from '../../command-builder/utilities/json-schema'; +import { RootCommands } from '../command-config'; interface GenerateCommandArgs extends SchematicsCommandArgs { schematic?: string; } -export class GenerateCommandModule +export default class GenerateCommandModule extends SchematicsCommandModule implements CommandModuleImplementation { command = 'generate'; - aliases = 'g'; + aliases = RootCommands['generate'].aliases; describe = 'Generates and/or modifies files based on a schematic.'; longDescriptionPath?: string | undefined; diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts index bf145d31db0c..9510dd7afe53 100644 --- a/packages/angular/cli/src/commands/lint/cli.ts +++ b/packages/angular/cli/src/commands/lint/cli.ts @@ -3,22 +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 { 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'; -export class LintCommandModule +export default class LintCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { 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 fda66b295088..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,14 +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 { Argv } from 'yargs'; import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; import { colors } from '../../utilities/color'; -export class AwesomeCommandModule extends CommandModule implements CommandModuleImplementation { +export default class AwesomeCommandModule + extends CommandModule + implements CommandModuleImplementation +{ command = 'make-this-awesome'; describe = false as const; deprecated = false; diff --git a/packages/angular/cli/src/commands/new/cli.ts b/packages/angular/cli/src/commands/new/cli.ts index c4f8bdebcece..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'; @@ -20,12 +20,13 @@ import { SchematicsCommandModule, } from '../../command-builder/schematics-command-module'; import { VERSION } from '../../utilities/version'; +import { RootCommands } from '../command-config'; interface NewCommandArgs extends SchematicsCommandArgs { collection?: string; } -export class NewCommandModule +export default class NewCommandModule extends SchematicsCommandModule implements CommandModuleImplementation { @@ -34,7 +35,7 @@ export class NewCommandModule protected override allowPrivateSchematics = true; command = 'new [name]'; - aliases = 'n'; + aliases = RootCommands['new'].aliases; describe = 'Creates a new Angular workspace.'; longDescriptionPath = join(__dirname, 'long-description.md'); @@ -54,7 +55,7 @@ export 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); @@ -73,15 +74,6 @@ export 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 de7c185e9f3d..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 { @@ -22,7 +22,7 @@ export interface RunCommandArgs { target: string; } -export class RunCommandModule +export default class RunCommandModule extends ArchitectBaseCommandModule implements CommandModuleImplementation { @@ -39,7 +39,7 @@ export class RunCommandModule const localYargs: Argv = argv .positional('target', { describe: - 'The Architect target to run provided in the the following format `project:target[:configuration]`.', + 'The Architect target to run provided in the following format `project:target[:configuration]`.', type: 'string', demandOption: true, // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. diff --git a/packages/angular/cli/src/commands/serve/cli.ts b/packages/angular/cli/src/commands/serve/cli.ts index 537345cc568d..3b38fa122acd 100644 --- a/packages/angular/cli/src/commands/serve/cli.ts +++ b/packages/angular/cli/src/commands/serve/cli.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 { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; -export class ServeCommandModule +export default class ServeCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { multiTarget = false; command = 'serve [project]'; - aliases = ['s']; + aliases = RootCommands['serve'].aliases; describe = 'Builds and serves your application, rebuilding on file changes.'; longDescriptionPath?: string | undefined; } diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts index fd650fee01c9..600e9f41f517 100644 --- a/packages/angular/cli/src/commands/test/cli.ts +++ b/packages/angular/cli/src/commands/test/cli.ts @@ -3,20 +3,21 @@ * Copyright Google LLC All Rights 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'; -export class TestCommandModule +export default class TestCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { multiTarget = true; command = 'test [project]'; - aliases = ['t']; + aliases = RootCommands['test'].aliases; describe = 'Runs unit tests in a project.'; longDescriptionPath = join(__dirname, 'long-description.md'); } diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 2095df7c33bd..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 class UpdateCommandModule extends CommandModule { +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 class UpdateCommandModule extends CommandModule { }) .option('name', { description: - 'The name of the migration to run. ' + - `Only available with a single package being updated, and only with 'migrate-only' option.`, + 'The name of the migration to run. Only available when a single package is updated.', type: 'string', - implies: ['migrate-only'], conflicts: ['to', 'from'], }) .option('from', { description: 'Version from which to migrate from. ' + - `Only available with a single package being updated, and only with 'migrate-only'.`, + `Only available when a single package is updated, and only with 'migrate-only'.`, type: 'string', implies: ['migrate-only'], conflicts: ['name'], }) .option('to', { describe: - 'Version up to which to apply migrations. Only available with a single package being updated, ' + + 'Version up to which to apply migrations. Only available when a single package is updated, ' + `and only with 'migrate-only' option. Requires 'from' to be specified. Default to the installed version detected.`, type: 'string', implies: ['from', 'migrate-only'], @@ -146,6 +150,14 @@ export class UpdateCommandModule extends CommandModule { alias: ['C'], default: false, }) + .middleware((argv) => { + 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 class UpdateCommandModule extends CommandModule { async run(options: Options): 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 class UpdateCommandModule extends CommandModule { } } - logger.info(`Using package manager: ${colors.grey(packageManager.name)}`); + logger.info(`Using package manager: ${colors.gray(packageManager.name)}`); logger.info('Collecting installed dependencies...'); const rootDependencies = await getProjectDependencies(this.context.root); @@ -246,7 +256,7 @@ export class UpdateCommandModule extends CommandModule { packageManagerForce: this.packageManagerForce(options.verbose), // __dirname -> 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 class UpdateCommandModule extends CommandModule { return { success: !workflowSubscription.error, files: workflowSubscription.files }; } catch (e) { if (e instanceof UnsuccessfulWorkflowExecution) { - logger.error(`${colors.symbols.cross} Migration failed. See above for further details.\n`); + logger.error(`${figures.cross} Migration failed. See above for further details.\n`); } else { assertIsError(e); const logPath = writeErrorToLogFile(e); logger.fatal( - `${colors.symbols.cross} Migration failed: ${e.message}\n` + + `${figures.cross} Migration failed: ${e.message}\n` + ` See "${logPath}" for further details.\n`, ); } @@ -432,7 +442,7 @@ export class UpdateCommandModule extends CommandModule { for (const migration of migrations) { const { title, description } = getMigrationTitleAndDescription(migration); - logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title)); + logger.info(colors.cyan(figures.pointer) + ' ' + colors.bold(title)); if (description) { logger.info(' ' + description); @@ -550,7 +560,7 @@ export class UpdateCommandModule extends CommandModule { // This avoids issues with package hoisting. try { const packageRequire = createRequire(packagePath + '/'); - migrations = packageRequire.resolve(migrations); + migrations = packageRequire.resolve(migrations, { paths: this.resolvePaths }); } catch (e) { assertIsError(e); if (e.code === 'MODULE_NOT_FOUND') { @@ -712,7 +722,7 @@ export class UpdateCommandModule extends CommandModule { // Example @angular/core skipped version 3, @angular/cli skipped versions 2-5. logger.error( `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + - `For more information about the update process, see https://update.angular.io/.`, + `For more information about the update process, see https://update.angular.dev/.`, ); } else { const nextMajorVersionFromCurrent = currentMajorVersion + 1; @@ -721,7 +731,7 @@ export class UpdateCommandModule extends CommandModule { `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + `Run 'ng update ${name}@${nextMajorVersionFromCurrent}' in your workspace directory ` + `to update to latest '${nextMajorVersionFromCurrent}.x' version of '${name}'.\n\n` + - `For more information about the update process, see https://update.angular.io/?v=${currentMajorVersion}.0-${nextMajorVersionFromCurrent}.0`, + `For more information about the update process, see https://update.angular.dev/?v=${currentMajorVersion}.0-${nextMajorVersionFromCurrent}.0`, ); } @@ -750,21 +760,46 @@ export class UpdateCommandModule extends CommandModule { ); if (success) { - try { - await fs.rm(path.join(this.context.root, 'node_modules'), { - force: true, - recursive: true, - maxRetries: 3, - }); - } catch {} + const { root: commandRoot, packageManager } = this.context; + const installArgs = this.packageManagerForce(options.verbose) ? ['--force'] : []; + const tasks = new Listr([ + { + title: 'Cleaning node modules directory', + async task(_, task) { + try { + await fs.rm(path.join(commandRoot, 'node_modules'), { + force: true, + recursive: true, + maxRetries: 3, + }); + } catch (e) { + assertIsError(e); + if (e.code === 'ENOENT') { + task.skip('Cleaning not required. Node modules directory not found.'); + } + } + }, + }, + { + title: 'Installing packages', + async task() { + const installationSuccess = await packageManager.installAll(installArgs, commandRoot); - const installationSuccess = await this.context.packageManager.installAll( - this.packageManagerForce(options.verbose) ? ['--force'] : [], - this.context.root, - ); + if (!installationSuccess) { + throw new CommandError('Unable to install packages'); + } + }, + }, + ]); - if (!installationSuccess) { - return 1; + try { + await tasks.run(); + } catch (e) { + if (e instanceof CommandError) { + return 1; + } + + throw e; } } @@ -867,6 +902,7 @@ export class UpdateCommandModule extends CommandModule { return success ? 0 : 1; } + /** * @return Whether or not the commit was successful. */ @@ -1011,7 +1047,7 @@ export class UpdateCommandModule extends CommandModule { if (existsSync(packageJsonPath)) { const content = await fs.readFile(packageJsonPath, 'utf-8'); if (content) { - const { bin = {} } = JSON.parse(content); + const { bin = {} } = JSON.parse(content) as { bin: Record }; const binKeys = Object.keys(bin); if (binKeys.length) { @@ -1078,29 +1114,32 @@ export class UpdateCommandModule extends CommandModule { numberOfMigrations > 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 62f2980d37ce..4fe53b6596ba 100644 --- a/packages/angular/cli/src/commands/version/cli.ts +++ b/packages/angular/cli/src/commands/version/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 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'; +import { RootCommands } from '../command-config'; interface PartialPackageInfo { name: string; @@ -22,24 +23,26 @@ 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]; const PACKAGE_PATTERNS = [ /^@angular\/.*/, /^@angular-devkit\/.*/, - /^@bazel\/.*/, /^@ngtools\/.*/, - /^@nguniversal\/.*/, /^@schematics\/.*/, /^rxjs$/, /^typescript$/, /^ng-packagr$/, /^webpack$/, + /^zone\.js$/, ]; -export class VersionCommandModule extends CommandModule implements CommandModuleImplementation { +export default class VersionCommandModule + extends CommandModule + implements CommandModuleImplementation +{ command = 'version'; - aliases = ['v']; + aliases = RootCommands['version'].aliases; describe = 'Outputs Angular CLI version.'; longDescriptionPath?: string | undefined; 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.ts b/packages/angular/cli/src/typings.ts index e7b7d14c0ca3..0ccb3728b882 100644 --- a/packages/angular/cli/src/typings.ts +++ b/packages/angular/cli/src/typings.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 */ declare module 'npm-pick-manifest' { 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 85973cfd9452..37d46ad44ced 100644 --- a/packages/angular/create/BUILD.bazel +++ b/packages/angular/create/BUILD.bazel @@ -1,25 +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 = glob( - ["**/*.ts"], - exclude = [ - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", - ], - ), + srcs = glob([ + "src/*.ts", + ]), + data = RUNTIME_ASSETS, deps = [ + "//:node_modules/@types/node", "//packages/angular/cli:angular-cli", - "@npm//@types/node", ], ) @@ -30,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 25b94cbb3f05..4fc36b05adc6 100644 --- a/packages/angular/pwa/BUILD.bazel +++ b/packages/angular/pwa/BUILD.bazel @@ -1,48 +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 = glob( - ["**/*.ts"], - # Currently, this library is used only with the rollup plugin. - # To make it simpler for downstream repositories to compile this, we - # neither provide compile-time deps as an `npm_install` rule, nor do we - # expect the downstream repository to install @types/webpack[-*] - # So we exclude files that depend on webpack typings. - exclude = [ - "pwa/files/**/*", - "**/*_spec.ts", - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", - ], - ) + [ + 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", ], ) @@ -51,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 = "pwa_test", + data = [":pwa_test_lib"], +) genrule( name = "license", @@ -81,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..c7ecbdaa99af 100644 --- a/packages/angular/pwa/README.md +++ b/packages/angular/pwa/README.md @@ -1,22 +1,23 @@ # `@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: + - Includes a link to add the [manifest.webmanifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) file. + - Adds a meta tag for `theme-color`. +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 13251a4d8da2..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-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 9f9241f0be40..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 4a5f8c16389c..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 34a1a8d64587..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 9172e5dd29e4..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 2814a3f30caf..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 d271025c4f22..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..f8c1e3960511 --- /dev/null +++ b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest @@ -0,0 +1,59 @@ +{ + "name": "<%= title %>", + "short_name": "<%= title %>", + "theme_color": "#1976d2", + "background_color": "#fafafa", + "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 03627ca5a615..f78095b7a564 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', @@ -57,30 +55,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 +94,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 +125,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,19 +134,47 @@ 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('https://github.com/files/root'), [template({ ...options }), move(sourcePath)])), mergeWith( - apply(url('https://github.com/files/assets'), [ - template({ ...options }), - move(posix.join(sourcePath, 'assets')), - ]), + apply(url('https://github.com/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..37677894b446 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,161 @@ 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( - /